Unit Test a QGIS Plugin with pytest

A QGIS plugin is just Python, but it imports modules that only function inside a running QGIS application. That single fact is why so many plugin authors never write tests — import qgis.core from a plain interpreter fails, and it is not obvious how to get past it. This task page, part of Testing & CI for QGIS Plugins, walks through a complete pytest setup: a conftest.py that initializes QGIS with qgis.testing.start_app(), fixtures that build memory QgsVectorLayer objects, the practice of testing pure logic apart from iface, mocking iface with unittest.mock, and running it all headlessly.

By the end you will have a tests/ directory you can run with one command and wire into the CI matrix described in the parent cluster.

Prerequisites

  • A plugin whose spatial logic is reasonably separated from GUI code.
  • QGIS 3.34 LTR installed (bundles Python 3.12) — its Python is the interpreter you run pytest with.
  • pytest installed into that interpreter.
  • On Linux, xvfb for headless runs.

Confirm you are using the right Python before anything else. The interpreter that runs your tests must be the one QGIS ships, because that is where the qgis package lives.

# Linux: QGIS typically uses the system python3 with qgis on its path
python3 -c "import qgis.core; print(qgis.core.Qgis.QGIS_VERSION)"
# Windows: use the OSGeo4W shell, then
python -c "import qgis.core; print(qgis.core.Qgis.QGIS_VERSION)"

If that import succeeds, pip install pytest into the same environment and proceed.

Recipe: A conftest.py That Boots QGIS

conftest.py is loaded by pytest before your test modules, which makes it the right place to initialize QGIS exactly once. qgis.testing.start_app() creates the QgsApplication, loads the provider registry, and is safe to call repeatedly.

# tests/conftest.py
import pytest
from qgis.testing import start_app
from qgis.core import (
    QgsVectorLayer,
    QgsFeature,
    QgsGeometry,
    QgsField,
)
from qgis.PyQt.QtCore import QVariant

# Boot QGIS once for the whole session, before any fixture runs.
QGIS_APP = start_app()


@pytest.fixture
def point_layer():
    """Memory point layer with three cities and a population field."""
    layer = QgsVectorLayer(
        "Point?crs=EPSG:4326&field=name:string&field=pop:integer",
        "cities",
        "memory",
    )
    provider = layer.dataProvider()
    rows = [
        ("Lisbon", 545000, "POINT(-9.139 38.722)"),
        ("Porto", 237000, "POINT(-8.611 41.150)"),
        ("Faro", 64000, "POINT(-7.930 37.019)"),
    ]
    features = []
    for name, pop, wkt in rows:
        feat = QgsFeature(layer.fields())
        feat.setAttributes([name, pop])
        feat.setGeometry(QgsGeometry.fromWkt(wkt))
        features.append(feat)
    provider.addFeatures(features)
    layer.updateExtents()
    return layer

Breakdown: Calling start_app() at module level guarantees QGIS is ready before pytest collects any fixture. The layer URI declares two fields inline, so QgsFeature(layer.fields()) produces features with the correct attribute schema. setAttributes matches that schema positionally, and fromWkt parses well-known text into geometry. Because nothing touches disk, the fixture is fast and isolated — each test gets a fresh layer.

Recipe: A Reusable Empty-Layer Fixture

For tests that build their own features, a parametrizable empty-layer factory is more flexible than a fixed dataset.

# add to tests/conftest.py
@pytest.fixture
def memory_layer_factory():
    """Return a factory that builds an empty memory layer of a given type."""
    created = []

    def _make(geometry="Polygon", crs="EPSG:3857", fields="field=id:integer"):
        uri = f"{geometry}?crs={crs}&{fields}"
        layer = QgsVectorLayer(uri, "scratch", "memory")
        assert layer.isValid(), f"invalid memory layer URI: {uri}"
        created.append(layer)
        return layer

    return _make

Breakdown: The inner _make function lets each test request precisely the layer it needs — a polygon layer in EPSG:3857, a line layer in EPSG:4326, and so on — without duplicating boilerplate. The assert layer.isValid() fails loudly if a URI is malformed, turning a silent empty layer into an obvious test error. Returning a callable from a fixture is the standard pytest pattern for parameterized resource creation.

Recipe: Testing Pure Logic

The most valuable tests target plain functions that take QGIS objects and return results, with no iface involved. Suppose your plugin filters features by an attribute threshold:

# selection.py — production logic, no iface
from qgis.core import QgsVectorLayer


def names_above_population(layer: QgsVectorLayer, minimum: int) -> list[str]:
    """Return names of features whose 'pop' attribute meets the minimum."""
    return sorted(
        feat["name"]
        for feat in layer.getFeatures()
        if feat["pop"] is not None and feat["pop"] >= minimum
    )
# tests/test_selection.py
from selection import names_above_population


def test_filters_by_population(point_layer):
    assert names_above_population(point_layer, 100000) == ["Lisbon", "Porto"]


def test_inclusive_threshold(point_layer):
    # Porto has exactly 237000 — boundary must be included
    assert "Porto" in names_above_population(point_layer, 237000)


def test_none_population_is_skipped(memory_layer_factory):
    from qgis.core import QgsFeature, QgsGeometry
    layer = memory_layer_factory(
        geometry="Point", crs="EPSG:4326",
        fields="field=name:string&field=pop:integer",
    )
    feat = QgsFeature(layer.fields())
    feat.setAttributes(["Unknown", None])
    feat.setGeometry(QgsGeometry.fromWkt("POINT(0 0)"))
    layer.dataProvider().addFeatures([feat])
    assert names_above_population(layer, 1) == []

Breakdown: The three tests cover a typical filter, the inclusive boundary (>=), and the null-handling path — the cases that actually break in production. Using the shared point_layer fixture for the first two keeps them terse, while the third uses memory_layer_factory to construct an edge-case dataset with a NULL attribute. Sorting the output makes assertions order-independent and stable.

Recipe: Mocking iface

Code that reads the active layer or pushes messages needs iface, which does not exist in tests. Replace it with a MagicMock.

# notify.py — production code that touches iface
from qgis.core import Qgis


def export_active_layer(iface, exporter):
    """Export the active layer; warn via the message bar if none is selected."""
    layer = iface.activeLayer()
    if layer is None:
        iface.messageBar().pushMessage(
            "Export", "No active layer to export.",
            level=Qgis.Warning, duration=4,
        )
        return None
    return exporter(layer)
# tests/test_notify.py
from unittest.mock import MagicMock
from qgis.core import Qgis
from notify import export_active_layer


def test_warns_when_no_active_layer():
    iface = MagicMock()
    iface.activeLayer.return_value = None
    exporter = MagicMock()

    result = export_active_layer(iface, exporter)

    assert result is None
    exporter.assert_not_called()
    iface.messageBar().pushMessage.assert_called_once()
    _, kwargs = iface.messageBar().pushMessage.call_args
    assert kwargs["level"] == Qgis.Warning


def test_exports_when_layer_present(point_layer):
    iface = MagicMock()
    iface.activeLayer.return_value = point_layer
    exporter = MagicMock(return_value="/tmp/out.gpkg")

    result = export_active_layer(iface, exporter)

    assert result == "/tmp/out.gpkg"
    exporter.assert_called_once_with(point_layer)

Breakdown: Injecting iface and exporter as parameters (dependency injection) is what makes this testable — the function never reaches for a global. MagicMock fabricates iface.messageBar().pushMessage on demand, and assert_called_once/assert_not_called verify the branching. The second test wires a real point_layer fixture into the mocked iface, proving the happy path forwards the actual layer to the exporter. This is the same mocking pattern introduced in Testing & CI for QGIS Plugins.

Recipe: Testing Code That Edits a Layer

Plenty of plugin logic mutates a layer — adding a field, recomputing attributes, fixing geometries. Memory layers fully support editing, so you can test the round trip end to end without writing files.

# attribution.py — production logic that adds and fills a field
from qgis.core import QgsVectorLayer, QgsField, edit
from qgis.PyQt.QtCore import QVariant


def add_area_field(layer: QgsVectorLayer, field_name: str = "area_m2") -> None:
    """Add a double field and populate it with each feature's geometry area."""
    if layer.fields().indexOf(field_name) == -1:
        layer.dataProvider().addAttributes([QgsField(field_name, QVariant.Double)])
        layer.updateFields()
    with edit(layer):
        for feat in layer.getFeatures():
            feat[field_name] = feat.geometry().area()
            layer.updateFeature(feat)
# tests/test_attribution.py
from attribution import add_area_field
from qgis.core import QgsFeature, QgsGeometry


def test_adds_and_populates_area(memory_layer_factory):
    layer = memory_layer_factory(geometry="Polygon", crs="EPSG:3857")
    feat = QgsFeature(layer.fields())
    feat.setGeometry(QgsGeometry.fromWkt("POLYGON((0 0,0 20,20 20,20 0,0 0))"))
    layer.dataProvider().addFeatures([feat])

    add_area_field(layer)

    assert layer.fields().indexOf("area_m2") != -1
    stored = next(layer.getFeatures())["area_m2"]
    assert stored == 400.0


def test_is_idempotent(memory_layer_factory):
    layer = memory_layer_factory(geometry="Polygon", crs="EPSG:3857")
    add_area_field(layer)
    add_area_field(layer)  # second call must not duplicate the field
    names = [f.name() for f in layer.fields()]
    assert names.count("area_m2") == 1

Breakdown: The edit() context manager opens and commits an edit session, so the test verifies committed state, not pending edits. The first test asserts both that the field was created and that its value equals the known polygon area (a 20×20 square is 400 square units). The second test calls the function twice to prove the indexOf(...) == -1 guard makes it idempotent — a property that breaks plugins which re-run on the same layer. Memory layers make this destructive test safe because nothing persists between test functions.

Recipe: Running Headless

With the suite written, run it. On a desktop with a display, plain pytest works:

python -m pytest tests/ -v

On a headless server or CI runner there is no X display, so wrap the command:

export QT_QPA_PLATFORM=offscreen
xvfb-run -a python -m pytest tests/ -v

Breakdown: QT_QPA_PLATFORM=offscreen tells Qt to use its offscreen rendering backend, and xvfb-run -a supplies a throwaway virtual display for any widget that still needs one. The -a flag auto-selects a free display number. Run via python -m pytest rather than the bare pytest script to guarantee the QGIS-bundled interpreter is used. The same metadata.txt you validate before release — see Write metadata.txt for a QGIS Plugin — can also be checked in a test with configparser, keeping packaging honest alongside logic.

QGIS Version Compatibility

The 3.34 LTR baseline (Python 3.12) is what these examples target. qgis.testing.start_app() and the mocking approach are unchanged across the 3.x line, so the same suite runs everywhere.

QGIS lineBundled PythonNotes for testing
3.28 LTR3.9list[str] return annotations are fine; avoid match statements and 3.10+ syntax in tested code
3.34 LTR3.12Baseline; all examples here run as written
3.40 / 3.443.12Identical test API; use as the "latest" entry to catch deprecations early

If you support 3.28, run the suite under its Docker image too; the only differences you will encounter are in the plugin's own logic, never in the testing scaffolding.

Troubleshooting

ModuleNotFoundError: No module named 'qgis'. You are running a non-QGIS Python. Use the interpreter that QGIS ships (the OSGeo4W shell on Windows, the system python3 with QGIS on its path on Linux).

Application path not initialized or empty provider list. start_app() was not called before a fixture created QGIS objects. Ensure the call is at module level in conftest.py, not inside a fixture.

qt.qpa.xcb: could not connect to display. No X display in a headless environment. Run under xvfb-run -a and set QT_QPA_PLATFORM=offscreen.

Layer is invalid (isValid() is False). The memory URI is malformed — check field declarations and that the geometry type and CRS are spelled correctly, e.g. Polygon?crs=EPSG:3857&field=id:integer.

Mocked pushMessage assertion fails unexpectedly. Remember iface.messageBar() returns a new mock each call unless you bind it. Assert against iface.messageBar().pushMessage consistently, or capture bar = iface.messageBar() once and assert on bar.pushMessage.

Conclusion

The hard part of testing a QGIS plugin is the bootstrap, and qgis.testing.start_app() solves it in one line. Once QGIS is initialized in conftest.py, memory-layer fixtures give you fast deterministic data, dependency-injected iface becomes a trivial MagicMock, and xvfb makes the whole suite headless-friendly. Write logic as pure functions, test those exhaustively, mock the thin GUI layer, and you have a suite ready to drop into the CI matrix from the parent cluster.

Frequently Asked Questions

Where exactly should start_app() be called? At module level in tests/conftest.py, so it runs once when pytest imports the file, before any fixture or test executes. Calling it per-test is wasteful but harmless since it is idempotent.

Do memory layers support editing and attribute changes? Yes. The memory provider supports adding fields and features, edit() transactions, and attribute updates, which makes it ideal for testing logic that modifies layers without writing files.

How do I test a function that calls processing.run()? After start_app(), also call Processing.initialize() so the algorithm registry loads, then call processing.run("native:buffer", params) inside the test and assert on the returned output layer just as you would in production.

Can I parametrize a test across several layers? Yes — use @pytest.mark.parametrize for inline data, or return a factory from a fixture (like memory_layer_factory) so each test builds the exact layer it needs.