Testing & CI for QGIS Plugins
Plugins that load cleanly on your machine routinely break on a colleague's older QGIS, on a different operating system, or after an API deprecation you never noticed. Automated tests and continuous integration are how serious plugin authors catch those failures before users do. This cluster, part of QGIS Plugin Development, covers the full quality pipeline: structuring code so it is testable, writing pytest/unittest cases against real PyQGIS objects, initializing a headless QGIS application, mocking iface, running everything under xvfb in CI, and building a GitHub Actions matrix that exercises your plugin across multiple QGIS releases.
The central challenge is that PyQGIS is not a normal importable library — it only works inside an initialized QGIS application. Once you solve that initialization problem, the rest of the testing stack is conventional Python, and your plugin gains the same safety net any well-engineered codebase enjoys.
Prerequisites
- A plugin with logic worth testing, structured per Plugin Boilerplate & Structure.
- QGIS 3.34 LTR installed (bundles Python 3.12), used as the interpreter for tests.
pytestavailable to the QGIS Python environment.- Familiarity with running scripts against QGIS's bundled Python.
- A Git repository hosted on GitHub (for the Actions section).
The CI Pipeline at a Glance
A plugin CI run moves through linting, a headless test stage spanning several QGIS versions, and an optional build/release step. The matrix is the heart of it: the same test suite runs against each supported QGIS image in parallel.
Bootstrapping a Headless QGIS for Tests
PyQGIS classes such as QgsVectorLayer and QgsCoordinateTransform need an initialized QgsApplication with its provider registry and PROJ/GEOS bindings loaded. The qgis.testing module exists precisely for this. Its start_app() helper creates a single application instance, initializes processing providers, and returns a handle you can reuse across the whole test session.
# A standalone smoke test that proves the QGIS app boots headlessly
from qgis.testing import start_app
from qgis.core import QgsVectorLayer, QgsApplication
QGISAPP = start_app()
layer = QgsVectorLayer("Point?crs=EPSG:4326&field=id:integer", "pts", "memory")
assert layer.isValid(), "memory provider not available"
print("QGIS version:", QgsApplication.libraryVersion())
print("Provider works:", layer.isValid())
Breakdown: start_app() is idempotent — calling it again returns the existing application rather than crashing on a duplicate QgsApplication. The memory-provider URI builds a layer with no file on disk, which is the workhorse fixture for fast tests. If isValid() is False, your provider registry never initialized, almost always because the script ran under a plain Python interpreter instead of the QGIS one.
There are two distinct things a test environment can lack. The first is the qgis package itself — solved by using the QGIS-bundled Python. The second is a running application context, which is what start_app() provides. Both must be in place before any QgsVectorLayer, QgsCoordinateReferenceSystem, or processing.run() call. A common mistake is importing qgis.core successfully and assuming everything works, only to find layers come back invalid because no application booted. Treat start_app() as a hard prerequisite, established once in conftest.py, never per test body.
Separating Logic From iface
The biggest determinant of how testable a plugin is comes down to one design decision: keep your spatial logic in plain functions that take and return QGIS objects, and confine iface to a thin GUI layer. iface (the QgisInterface) only exists inside running QGIS Desktop, so any logic entangled with it cannot run in CI.
# logic.py — pure, testable, no iface anywhere
from qgis.core import QgsVectorLayer, QgsFeatureRequest
def count_features_over_area(layer: QgsVectorLayer, threshold: float) -> int:
"""Return how many polygon features exceed the given area threshold."""
request = QgsFeatureRequest().setSubsetOfAttributes([])
return sum(
1
for feat in layer.getFeatures(request)
if feat.geometry().area() > threshold
)
Breakdown: This function receives a QgsVectorLayer and returns a number — no iface, no message bar, no dialog. That makes it trivially unit-testable with a memory layer. The setSubsetOfAttributes([]) request skips loading attribute values you do not need, a small performance win when iterating geometry only. Your GUI code then calls count_features_over_area(self.iface.activeLayer(), 1000.0) and handles presentation separately.
A useful rule of thumb is the three-layer split: a logic layer of pure functions over QGIS objects, a controller that pulls inputs from iface and pushes results back to the GUI, and the UI itself (dialogs, dock widgets). The logic layer carries the bulk of your test coverage because it is cheap to test and most likely to contain bugs. The controller gets a handful of mock-iface tests. The UI is exercised lightly, mostly to confirm signals are wired. This proportion — many logic tests, few GUI tests — is what keeps the suite fast and the failures meaningful. Code that mixes a topology calculation with a pushMessage call in the same function defeats the whole arrangement, so refactor those apart before writing tests.
Writing the Test Suite with pytest
With logic isolated, tests are ordinary pytest. A conftest.py boots QGIS once per session and provides reusable fixtures; individual test files assert on your logic functions. This is the foundation expanded in detail in Unit Test a QGIS Plugin with pytest.
# tests/conftest.py
import pytest
from qgis.testing import start_app
from qgis.core import QgsVectorLayer, QgsFeature, QgsGeometry
start_app()
@pytest.fixture
def square_layer():
"""A memory polygon layer with two squares of different sizes."""
layer = QgsVectorLayer("Polygon?crs=EPSG:3857", "squares", "memory")
provider = layer.dataProvider()
small = QgsFeature()
small.setGeometry(QgsGeometry.fromWkt("POLYGON((0 0,0 10,10 10,10 0,0 0))"))
big = QgsFeature()
big.setGeometry(QgsGeometry.fromWkt("POLYGON((0 0,0 50,50 50,50 0,0 0))"))
provider.addFeatures([small, big])
layer.updateExtents()
return layer
# tests/test_logic.py
from logic import count_features_over_area
def test_counts_only_large_polygons(square_layer):
# small square area = 100, big square area = 2500
assert count_features_over_area(square_layer, threshold=500) == 1
def test_threshold_below_all(square_layer):
assert count_features_over_area(square_layer, threshold=50) == 2
Breakdown: The square_layer fixture builds deterministic geometry with known areas (100 and 2500 in projected units), so assertions are exact rather than approximate. Calling start_app() at module import in conftest.py guarantees QGIS is ready before any fixture runs. Each test states its expectation in a comment so a future maintainer understands the intent without recomputing areas.
Mocking iface for the GUI Layer
Some code legitimately touches iface — pushing messages, reading the active layer, adding a toolbar action. You test these paths by substituting a mock. unittest.mock.MagicMock impersonates iface and records the calls your code makes, letting you assert on behavior without a running GUI.
# tests/test_gui.py
from unittest.mock import MagicMock
from qgis.core import Qgis
from plugin_actions import warn_if_no_layer
def test_warns_when_no_active_layer():
iface = MagicMock()
iface.activeLayer.return_value = None
handled = warn_if_no_layer(iface)
assert handled is False
iface.messageBar().pushMessage.assert_called_once()
# confirm the warning level was used
_, kwargs = iface.messageBar().pushMessage.call_args
assert kwargs.get("level") == Qgis.Warning
# plugin_actions.py — the code under test
from qgis.core import Qgis
def warn_if_no_layer(iface):
"""Push a warning and return False if there is no active layer."""
if iface.activeLayer() is None:
iface.messageBar().pushMessage(
"No layer", "Select a layer first.",
level=Qgis.Warning, duration=3,
)
return False
return True
Breakdown: MagicMock auto-creates any attribute you access, so iface.messageBar().pushMessage is callable without defining it. assert_called_once() verifies the warning fired exactly once, and call_args inspects the keyword arguments to confirm the severity. This tests the decision the function makes without ever opening a window — exactly what you want in headless CI.
Testing Code That Runs Processing Algorithms
Many plugins delegate the heavy lifting to the Processing framework rather than hand-rolling geometry loops. That code is testable too, but it needs one extra initialization step: start_app() loads the native provider registry, yet the Processing plugin's own algorithms require Processing.initialize() before processing.run(...) will resolve an algorithm id.
# tests/test_processing.py
import processing
from processing.core.Processing import Processing
from qgis.core import QgsVectorLayer
def setup_module():
Processing.initialize()
def test_buffer_produces_polygons(point_layer):
result = processing.run(
"native:buffer",
{
"INPUT": point_layer,
"DISTANCE": 0.01,
"SEGMENTS": 8,
"OUTPUT": "memory:",
},
)
out = result["OUTPUT"]
assert isinstance(out, QgsVectorLayer)
assert out.featureCount() == point_layer.featureCount()
Breakdown: setup_module() runs once before the module's tests and calls Processing.initialize(), registering the bundled algorithms so native:buffer resolves. The "OUTPUT": "memory:" sink keeps the result in RAM, avoiding temp-file cleanup. Asserting featureCount() round-trips — one buffer per input point — verifies the algorithm actually executed rather than silently returning an empty layer. This pattern lets you test a plugin built on the Processing framework, including the custom algorithms covered in Processing Provider Plugins, with the same speed and determinism as pure-logic tests.
Running Headless Under xvfb
Even initialized via start_app(), parts of Qt expect an X display. On a headless CI runner there is none, so Qt aborts with could not connect to display. The fix is xvfb, a virtual framebuffer that supplies a fake display. xvfb-run wraps your test command transparently.
# Run the suite against the QGIS-bundled Python on a headless machine
export QT_QPA_PLATFORM=offscreen
xvfb-run -a python -m pytest tests/ -v
Breakdown: xvfb-run -a allocates a free display number automatically and tears it down when pytest exits. Setting QT_QPA_PLATFORM=offscreen is a belt-and-braces measure that tells Qt to render to an offscreen buffer; on many setups it alone is enough, but combined with xvfb it covers widgets that still poke at the X server. Always invoke the QGIS Python, not your system python, or the imports fail.
The distinction between offscreen and xvfb matters when something goes wrong. The offscreen platform plugin is a pure Qt feature: it renders without any windowing system at all and is the lightest option. xvfb instead provides a genuine (virtual) X11 server, which a few widgets and OpenGL-backed canvases still require. If a test that instantiates a real widget passes locally but crashes in CI with an X error under offscreen alone, wrapping the command in xvfb-run usually resolves it. Running both together costs almost nothing and eliminates an entire class of flaky, environment-dependent failures.
A GitHub Actions Matrix Across QGIS Versions
The official qgis/qgis Docker images ship a complete QGIS plus its Python, which makes containerized CI dramatically simpler than installing QGIS on a bare runner. A matrix strategy runs the same job once per QGIS tag.
# .github/workflows/tests.yml
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
qgis: ["release-3_28", "release-3_34", "latest"]
container:
image: qgis/qgis:${{ matrix.qgis }}
steps:
- uses: actions/checkout@v4
- name: Install test deps
run: pip install pytest pytest-cov flake8
- name: Lint
run: flake8 . --max-line-length=100 --exclude=resources_rc.py
- name: Run tests
env:
QT_QPA_PLATFORM: offscreen
run: xvfb-run -a python -m pytest tests/ --cov=. -v
Breakdown: fail-fast: false lets every QGIS version finish even if one fails, so you see the full compatibility picture in one run. The container.image pins each job to a QGIS Docker tag — release-3_34 is the LTR baseline, release-3_28 guards your declared minimum, and latest warns you about upcoming deprecations early. flake8 excludes the generated resources_rc.py so machine output does not pollute lint results. Coverage via pytest-cov quantifies how much of your logic the suite actually exercises.
Build Automation with pb_tool and paver
CI should also prove the plugin still packages. Two community tools cover this. pb_tool reads a pb_tool.cfg manifest and offers compile, deploy, zip, and test subcommands; paver is an older convention driven by a pavement.py script. Either turns a release into one command, the same archive you submit when Publishing to the QGIS Plugin Repository.
pip install pb_tool
pb_tool compile # build resources/UI
pb_tool zip # produce repository-ready archive
unzip -l zip_build/*.zip # confirm no __pycache__ or tests leaked in
Breakdown: Running pb_tool zip in CI and inspecting the archive listing catches packaging regressions — a stray __pycache__, a missing icon, an accidentally excluded module — before they reach the repository. Because the [files] manifest is explicit, the build is reproducible regardless of what junk sits in your working tree.
Linting and Configuration
Linting is the fastest feedback in the pipeline and catches a category of bugs tests never will — undefined names, unused imports, shadowed variables. Run flake8 (or ruff for speed) before the test stage so an obvious typo fails in seconds rather than after a multi-minute matrix. A small project-level config keeps the rules consistent across every machine and every CI job.
# setup.cfg
[flake8]
max-line-length = 100
extend-ignore = E203, W503
exclude = resources_rc.py, .git, __pycache__, ui/*.py
[tool:pytest]
testpaths = tests
addopts = -ra -q
Breakdown: Generated files (resources_rc.py, any statically compiled ui/*.py) are excluded because machine output should not be held to hand-written standards. E203/W503 are ignored to coexist with the black formatter, which has slightly different opinions about whitespace and line breaks. The [tool:pytest] block pins testpaths so a bare pytest always finds the suite, and -ra prints a summary of skips and failures at the end of every run. Committing this file means a contributor's local run matches CI exactly, eliminating "passes on my machine" lint disputes.
Compatibility Notes
| QGIS line | Bundled Python | Docker tag | Role in matrix |
|---|---|---|---|
| 3.28 LTR | 3.9 | release-3_28 | Guards your declared qgisMinimumVersion; watch for f-string/3.10+ syntax |
| 3.34 LTR | 3.12 | release-3_34 | Primary baseline; develop and assert against this |
| 3.40 / latest | 3.12 | latest | Early warning for deprecations in upcoming releases |
qgis.testing.start_app() and the unittest.mock approach are stable across the entire 3.x line, so the same test code runs unchanged on every matrix entry. The only version-sensitive parts are API calls inside your logic, which the matrix exists to police.
Key Takeaways
- PyQGIS only works inside an initialized application; use
qgis.testing.start_app()to boot one headlessly. - Keep spatial logic in pure functions that take/return QGIS objects; confine
ifaceto a thin GUI layer. - Use memory-provider layers as fast, deterministic fixtures with known geometry.
- Mock
ifacewithunittest.mock.MagicMockto test GUI decisions without a window. - Run tests under
xvfb-runwithQT_QPA_PLATFORM=offscreenso Qt has a display in CI. - Drive a GitHub Actions matrix off the
qgis/qgisDocker images to test multiple QGIS versions at once.
Frequently Asked Questions
Why do my tests fail with "QgsApplication not initialized"?
You ran them under a plain Python interpreter instead of the QGIS-bundled one, or you forgot to call start_app(). Tests must use the same Python that ships with QGIS, and conftest.py must call start_app() before any fixture creates QGIS objects.
Do I need a real shapefile to test layer logic?
No, and you should avoid it. Memory-provider layers (QgsVectorLayer("Polygon?crs=EPSG:3857", "x", "memory")) are faster, deterministic, and leave no files behind. Reserve on-disk fixtures for code that specifically exercises file I/O.
pytest or unittest?
Both work because qgis.testing is framework-agnostic. pytest is recommended for its fixtures, parametrization, and concise assertions, but if your team standardizes on unittest.TestCase, qgis.testing.unittest provides a compatible base class.
How do I test code that calls processing algorithms?start_app() initializes the native providers, but third-party algorithms require Processing.initialize(). Once initialized you can call processing.run(...) inside a test exactly as in production code and assert on the output layer.
Can I run the GUI dialogs in CI?
You can instantiate and exercise QDialog subclasses under xvfb, but avoid exec_() which blocks on user input. Test the dialog's logic methods and signal handlers directly instead of opening a modal loop.