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_INT should report >= 32800.
  • The QGIS Python on your path: pytest-qgis must import the same qgis.core your code uses. On Linux this is the system Python that ships with QGIS; verify with python -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 via xvfb-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-scoped QgsApplication instance 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 the iface object that QGIS injects into plugins at runtime. It implements enough of the QgisInterface surface (active layer, message bar, map canvas) to let plugin code that calls iface.addVectorLayer(...) run under test without a real desktop.
  • qgis_new_project — clears QgsProject.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) so processing.run("native:...") works inside a test. Without it, the algorithm registry is empty and every processing.run raises.

The lifecycle below shows where each piece lives relative to the single application initialisation.

pytest-qgis test lifecycle with a single QgsApplication per session A timeline: pytest session start leads to a session-scoped qgis_app fixture that calls QgsApplication.initQgis exactly once. Inside the session, each test sets up a function-scoped memory-layer or project fixture, runs its assertions, then tears that fixture down. The loop repeats per test. When the session ends, exitQgis is called once. The single initialisation is highlighted. pytest session one process, one interpreter qgis_app (session) initQgis() × ONCE session end exitQgis() × once for each test per-test (function scope) fixture setup memory layer / qgis_new_project test body assertions teardown drop layer, reset project next test reuses the same QgsApplication The application is created once (bold box) and never re-initialised; only per-test state is set up and torn down.

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.

python
"""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.

python
"""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.

python
"""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 ifaceiface.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.

python
"""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.

python
"""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.

python
"""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 QgsApplication errors partway through the run, often only when more than one test file is collected. Root cause: a stray fixture or test module constructing its own QgsApplication() or calling initQgis(), colliding with the session instance. Fix: delete all manual application setup and rely solely on qgis_app; grep the suite for initQgis, exitQgis, and QgsApplication( 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 to QgsProject.instance() in one test persist into the next. Fix: depend on qgis_new_project in 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 QgsTaskManager and asserting before the worker thread finishes, or waiting on a Qt signal without a running event loop. Fix: drive tasks synchronously with a direct run() call as shown above, or if you must wait on a signal, spin a QSignalSpy or a bounded QEventLoop with a timeout rather than a bare sleep.

  • Processing not initialised. Symptom: processing.run("native:buffer", ...) raises QgsProcessingException: Error: Algorithm native:buffer not found. Root cause: the test never requested the qgis_processing fixture, so the native provider was never registered. Fix: add qgis_processing to 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 set PYTHONPATH to include the QGIS python directory, and verify with python -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 display or Could not load the Qt platform plugin "xcb". Root cause: the container has no display server. Fix: wrap the run in xvfb-run -a pytest or export QT_QPA_PLATFORM=offscreen before 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