Testing PyQGIS with pytest-qgis
Unit-test PyQGIS with pytest-qgis: the session-scoped qgis_app fixture, testing layers and geometries, mocking iface, temporary-project fixtures, and running…
Almost every QGIS object you want to test — a QgsVectorLayer, a QgsGeometry, a Processing algorithm — needs a live, initialised QgsApplication behind it before its constructor will even return something usable. Drop a bare import qgis.core into a naive pytest module and the first test that touches the provider registry either raises an obscure C++ error or crashes the interpreter outright. You cannot simply initQgis() in a fixture and exitQgis() in its teardown, because a second initialisation in the same process is undefined behaviour and usually segfaults halfway through the run. The result is a test suite that is flaky, slow, or impossible to run in CI.
pytest-qgis solves exactly this. It is a pytest plugin that stands up a single QgsApplication for the whole session, exposes it (and a mock iface, a fresh project, and a Processing-ready registry) as fixtures, and tears everything down cleanly at the end. This guide, part of the Headless Automation, CI/CD & Testing for PyQGIS guide, walks through the fixture model, a working conftest.py, your first assertions against a memory layer and a geometry, and the patterns that keep a suite fast and deterministic — including running it unattended in a container.
Prerequisites Checklist
Before writing the first test, confirm your environment is wired up correctly:
- QGIS 3.28+ (LTR recommended): the fixture names and Processing registration below assume the stable 3.28 API surface.
Qgis.QGIS_VERSION_INTshould report>= 32800. - The QGIS Python on your path:
pytest-qgismust import the sameqgis.coreyour code uses. On Linux this is the system Python that ships with QGIS; verify withpython -c "import qgis.core; print(qgis.core.Qgis.QGIS_VERSION)". - pytest and pytest-qgis installed into that interpreter:
pip install pytest pytest-qgis. Installing into a separate virtualenv that cannot see the QGIS bindings is the single most common setup failure. - A headless display target: either
xvfb(invoke tests viaxvfb-run) or the offscreen Qt plugin (export QT_QPA_PLATFORM=offscreen). Establishing a reliable headless runtime is covered in depth in standalone PyQGIS scripts and headless execution. - A project layout with a discoverable
conftest.py: pytest must be able to find it at or above your test directory so the plugin’s fixtures load.
If any of these are shaky, fix the runtime first. A test harness built on an unreliable QGIS import will produce failures that look like test bugs but are really environment bugs.
How pytest-qgis Works
The whole design turns on one hard constraint from the QGIS/Qt runtime: a process may hold exactly one live QgsApplication, and it may be initialised exactly once. QgsApplication.initQgis() registers data providers, the coordinate reference system database, the expression engine, and the Processing framework against global C++ singletons. Calling exitQgis() deregisters them; calling initQgis() again afterwards does not cleanly rebuild that state and typically ends the process. So the application cannot be a per-test fixture — it must live for the entire session.
pytest-qgis owns that lifecycle for you. When pytest starts, the plugin creates and initialises the application once, before the first test collects, and calls exitQgis() after the last test finishes. Between those two points it hands your tests small, cheap, function-scoped fixtures that assume the application already exists. The fixtures you will use most are:
qgis_app— the session-scopedQgsApplicationinstance itself. You rarely reference it directly; requesting any other fixture pulls it in transitively. Its job is to guarantee the runtime is up.qgis_iface— a lightweight stand-in for theifaceobject that QGIS injects into plugins at runtime. It implements enough of theQgisInterfacesurface (active layer, message bar, map canvas) to let plugin code that callsiface.addVectorLayer(...)run under test without a real desktop.qgis_new_project— clearsQgsProject.instance()to an empty state for the test, so layers or settings left by an earlier test cannot leak into this one.qgis_processing— initialises and registers the Processing framework (native algorithms and the QGIS provider) soprocessing.run("native:...")works inside a test. Without it, the algorithm registry is empty and everyprocessing.runraises.
The lifecycle below shows where each piece lives relative to the single application initialisation.
Memory-provider layers are the natural companion to this model. A QgsVectorLayer("Point?crs=EPSG:4326", "scratch", "memory") lives entirely in RAM, constructs in microseconds, needs no file cleanup, and cannot leak temp artefacts between tests. Using them for fixtures keeps each test hermetic and the suite fast — reserve on-disk GeoPackage or shapefile fixtures for the handful of integration tests that specifically probe a provider’s file behaviour.
Step-by-Step Implementation
Step 1 — Add a conftest.py
pytest-qgis is a plugin, so once it is installed its fixtures are available to any test. A conftest.py at your project root is where you register project-specific fixtures on top of it and set headless-friendly defaults. The minimum is almost empty, because the plugin does the heavy lifting.
"""conftest.py — project-wide pytest fixtures for a PyQGIS test suite.
pytest-qgis is auto-discovered once installed; it provides the session-scoped
qgis_app, plus qgis_iface, qgis_new_project and qgis_processing fixtures.
This file adds convenience fixtures layered on top of them.
"""
from __future__ import annotations
import pytest
from qgis.core import QgsVectorLayer, QgsFeature, QgsGeometry, QgsPointXY
@pytest.fixture
def point_layer(qgis_new_project) -> QgsVectorLayer:
"""A fresh in-memory point layer, one feature per test, EPSG:4326.
Depends on qgis_new_project so QgsProject.instance() is empty for the test.
The memory provider means no files are written and nothing needs cleanup.
"""
layer = QgsVectorLayer("Point?crs=EPSG:4326&field=name:string", "scratch", "memory")
assert layer.isValid(), "memory layer failed to construct"
feature = QgsFeature(layer.fields())
feature.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(-1.25, 51.75)))
feature.setAttribute("name", "oxford")
provider = layer.dataProvider()
provider.addFeatures([feature])
layer.updateExtents()
return layer
Note what is not here: no QgsApplication() construction, no initQgis(), no exitQgis(). Requesting the qgis_new_project fixture (which itself depends on qgis_app) is enough to guarantee the runtime is initialised before your fixture body executes.
Step 2 — Write your first assertions
With point_layer available, a first test reads like plain pytest. Assert on the feature count and on the geometry — the two things that break most often when data-loading logic regresses.
"""test_point_layer.py — first assertions against an in-memory layer."""
from __future__ import annotations
from qgis.core import QgsVectorLayer, QgsPointXY
def test_layer_has_one_feature(point_layer: QgsVectorLayer) -> None:
"""The fixture layer should contain exactly the feature we added."""
assert point_layer.featureCount() == 1
def test_feature_geometry_is_expected_point(point_layer: QgsVectorLayer) -> None:
"""The stored geometry should round-trip to the coordinate we set."""
feature = next(point_layer.getFeatures())
point: QgsPointXY = feature.geometry().asPoint()
assert point.x() == -1.25
assert point.y() == 51.75
assert feature["name"] == "oxford"
The full walk-through of building this test from scratch — including asserting on layer validity, field definitions, and geometry types — is covered in writing your first pytest-qgis test.
Step 3 — Test code that runs Processing
Code that calls processing.run(...) needs the Processing framework registered. That is exactly what the qgis_processing fixture does — request it and the native provider’s algorithms become available for the duration of the test.
"""test_processing.py — exercising a native Processing algorithm under test."""
from __future__ import annotations
import processing
from qgis.core import QgsVectorLayer
def test_buffer_produces_polygon(qgis_processing, point_layer: QgsVectorLayer) -> None:
"""native:buffer should turn a point layer into a polygon output layer.
The qgis_processing fixture registers the native algorithm provider;
without it processing.run raises QgsProcessingException.
"""
result = processing.run(
"native:buffer",
{
"INPUT": point_layer,
"DISTANCE": 0.01,
"SEGMENTS": 8,
"OUTPUT": "memory:",
},
)
output: QgsVectorLayer = result["OUTPUT"]
assert output.isValid()
assert output.featureCount() == 1
assert output.geometryType() == 2 # Qgis.GeometryType.Polygon
Because both INPUT and OUTPUT are memory layers, the whole test stays in RAM, leaves no temp files, and runs in milliseconds.
Advanced Patterns
Mocking iface for UI-touching code
Plugin code frequently reaches for the global iface — iface.addVectorLayer(...), iface.messageBar().pushMessage(...), iface.activeLayer(). Under test there is no desktop, so iface does not exist unless you provide it. The qgis_iface fixture supplies a substitute that implements enough of QgisInterface to let that code run, and you can wrap it in a unittest.mock.MagicMock when you want to assert that a call happened rather than let it no-op.
"""test_iface_usage.py — verifying a plugin calls iface correctly."""
from __future__ import annotations
from unittest.mock import MagicMock
from qgis.core import QgsVectorLayer
def load_and_announce(iface, layer: QgsVectorLayer) -> None:
"""Production helper: add a layer and tell the user via the message bar."""
iface.addVectorLayer(layer.source(), layer.name(), "memory")
iface.messageBar().pushMessage("Loaded", layer.name(), level=0)
def test_helper_pushes_a_message(qgis_iface, point_layer: QgsVectorLayer) -> None:
"""The helper should push exactly one message bar notification."""
qgis_iface.messageBar = MagicMock()
load_and_announce(qgis_iface, point_layer)
qgis_iface.messageBar().pushMessage.assert_called_once()
The trade-offs between the built-in qgis_iface stand-in and a hand-rolled MagicMock — and when each is appropriate — are examined in mocking the QGIS iface object in unit tests.
Parametrising over QGIS versions
If your plugin must support more than one QGIS release, guard version-sensitive branches with Qgis.QGIS_VERSION_INT and cover both sides in a single parametrised test. This keeps the version logic honest without a full test matrix in every module.
"""test_version_guard.py — asserting version-guarded behaviour."""
from __future__ import annotations
import pytest
from qgis.core import Qgis
def resolve_flag() -> str:
"""Return a capability name that differs across QGIS releases."""
if Qgis.QGIS_VERSION_INT >= 34000:
return "modern"
return "legacy"
@pytest.mark.parametrize("threshold, expected", [(34000, "modern"), (33400, "legacy")])
def test_flag_matches_running_version(threshold: int, expected: str) -> None:
"""Document which branch the running interpreter takes."""
running_is_modern = Qgis.QGIS_VERSION_INT >= 34000
if running_is_modern == (threshold >= 34000):
assert resolve_flag() == expected
The real value of the matrix comes at the CI layer, where you run the same suite against several QGIS Docker tags — see continuous integration for QGIS projects for the workflow that spans versions.
Testing background QgsTask code
Long-running work belongs on a QgsTask so the desktop stays responsive, but a task’s run() method executes on a worker thread, which makes it awkward to assert on. The robust pattern is to test the pure computation directly and drive the task synchronously in tests. Because pytest runs on the main thread, call run() yourself and then invoke finished() rather than handing the task to QgsTaskManager and racing its signals.
"""test_task.py — driving a QgsTask synchronously in a unit test."""
from __future__ import annotations
from qgis.core import QgsTask
class CountTask(QgsTask):
"""Toy task that squares a number off the main thread."""
def __init__(self, value: int) -> None:
super().__init__("count", QgsTask.CanCancel)
self.value = value
self.result: int | None = None
def run(self) -> bool:
"""Compute the result; returns True on success."""
self.result = self.value * self.value
return True
def test_task_run_computes_result(qgis_app) -> None:
"""Call run() directly instead of scheduling on the task manager."""
task = CountTask(7)
assert task.run() is True
assert task.result == 49
Testing tasks that genuinely need the manager and its signals is a deeper topic covered by the asynchronous task execution with QgsTask guide; the takeaway for unit tests is to keep the schedulable surface thin and assert on the synchronous computation.
Pitfalls and Debugging
-
Creating a second QgsApplication. Symptom: a hard crash or
QgsApplicationerrors partway through the run, often only when more than one test file is collected. Root cause: a stray fixture or test module constructing its ownQgsApplication()or callinginitQgis(), colliding with the session instance. Fix: delete all manual application setup and rely solely onqgis_app; grep the suite forinitQgis,exitQgis, andQgsApplication(and remove every hit outside the plugin. -
Layers leaking between tests. Symptom: a test passes alone but fails when run after another, usually with an unexpected
featureCount()or a layer that “already exists”. Root cause: features or layers added toQgsProject.instance()in one test persist into the next. Fix: depend onqgis_new_projectin fixtures that touch the project, and prefer memory layers scoped to a single function so nothing survives teardown. -
Flaky signal timing. Symptom: assertions on task or signal results pass intermittently. Root cause: handing work to
QgsTaskManagerand asserting before the worker thread finishes, or waiting on a Qt signal without a running event loop. Fix: drive tasks synchronously with a directrun()call as shown above, or if you must wait on a signal, spin aQSignalSpyor a boundedQEventLoopwith a timeout rather than a baresleep. -
Processing not initialised. Symptom:
processing.run("native:buffer", ...)raisesQgsProcessingException: Error: Algorithm native:buffer not found. Root cause: the test never requested theqgis_processingfixture, so the native provider was never registered. Fix: addqgis_processingto the test signature (or an autouse fixture that depends on it for modules that use Processing throughout). -
Wrong Python interpreter. Symptom:
ModuleNotFoundError: No module named 'qgis'at collection time. Root cause: pytest is running under a virtualenv that cannot see the QGIS bindings. Fix: run pytest with the QGIS Python, or setPYTHONPATHto include the QGISpythondirectory, and verify withpython -c "import qgis.core"before invoking pytest. -
No display in CI. Symptom: tests pass locally but the CI job aborts with
qt.qpa.plugin: could not connect to displayorCould not load the Qt platform plugin "xcb". Root cause: the container has no display server. Fix: wrap the run inxvfb-run -a pytestor exportQT_QPA_PLATFORM=offscreenbefore the command.
Conclusion
pytest-qgis removes the single hardest thing about testing PyQGIS: the one-time, one-per-process QgsApplication lifecycle. It initialises the runtime once per session and hands you cheap, function-scoped fixtures — qgis_app, qgis_iface, qgis_new_project, and qgis_processing — so your tests can focus on behaviour rather than boilerplate. Build fixtures on memory-provider layers to keep each test hermetic, reset the project between tests to prevent leakage, drive QgsTask code synchronously to kill flakiness, and request qgis_processing wherever you call processing.run. With those patterns in place, the same suite that passes on your workstation passes unattended in a headless container, ready to gate every commit.
Related
- Headless Automation, CI/CD & Testing for PyQGIS — parent guide covering the full headless and testing stack
- Writing Your First pytest-qgis Test — a from-scratch walk-through of the first test and its assertions
- Mocking the QGIS iface Object in Unit Tests — patterns for testing UI-touching plugin code without a desktop
- Running QGIS Plugin Tests in GitHub Actions — taking this suite into CI with a headless runner
- Asynchronous Task Execution with QgsTask — the background-task model these tests exercise