Writing Your First pytest-qgis Test
A step-by-step first PyQGIS unit test with pytest-qgis: install the plugin, use the qgis_app fixture, build a memory layer, and assert on feature counts and…
TL;DR: pip install pytest pytest-qgis, then let the session-scoped qgis_app fixture initialise QgsApplication once. Write a test that builds an in-memory QgsVectorLayer, adds features through dataProvider().addFeatures(), and asserts on featureCount() and a geometry property such as QgsGeometry.area(). Run it headless with QT_QPA_PLATFORM=offscreen pytest -q.
This page is part of the testing PyQGIS with pytest-qgis guide, which sits inside the broader Headless Automation, CI/CD & Testing for PyQGIS reference. If you have ever tried to unit-test PyQGIS code and been met with a QGISAPPLICATION must be constructed before... crash, the problem is almost always that QgsApplication was never initialised. pytest-qgis solves exactly that: it wires QGIS bootstrap and teardown into pytest’s fixture lifecycle so your tests read like ordinary Python tests.
Install and Verify
pytest-qgis is a pytest plugin that must run against the interpreter QGIS was built for, because it imports the compiled qgis.core bindings. On Linux the system QGIS ships a matching python3; on Windows use the OSGeo4W shell. Install both packages into that environment:
pip install pytest pytest-qgis
python -c "from qgis.core import QgsApplication; print('bindings OK')"
If the second line prints bindings OK, the plugin will find everything it needs. If it raises ModuleNotFoundError: No module named 'qgis', you are pointing at the wrong interpreter — resolve that before writing a single test, because no fixture can compensate for missing bindings.
Complete Runnable Test Module
The module below is a drop-in first test. It builds an in-memory point layer in EPSG:4326, inserts three features, and asserts on both the feature count and a derived geometry measurement. Save it as test_first_layer.py anywhere pytest can discover it (a file name starting with test_ is the default discovery rule).
"""
test_first_layer.py
A first headless PyQGIS unit test using pytest-qgis. Builds an
in-memory vector layer, adds features through the data provider,
and asserts on feature count and geometry.
Compatible with QGIS 3.28+ LTR.
"""
from __future__ import annotations
import pytest
from qgis.core import (
QgsFeature,
QgsGeometry,
QgsPointXY,
QgsVectorLayer,
)
def build_point_layer() -> QgsVectorLayer:
"""
Create an in-memory point layer in EPSG:4326 with a single field.
The memory provider keeps everything in RAM, so the test never
touches disk and needs no fixture teardown of its own.
Returns:
A valid QgsVectorLayer backed by the memory provider.
"""
uri: str = "Point?crs=EPSG:4326&field=id:integer&field=name:string(40)"
layer = QgsVectorLayer(uri, "sites", "memory")
assert layer.isValid(), "memory layer URI failed to build a valid layer"
return layer
def make_feature(fid: int, name: str, lon: float, lat: float) -> QgsFeature:
"""
Assemble a single point QgsFeature with attributes and geometry.
Args:
fid: Integer identifier written to the 'id' field.
name: Label written to the 'name' field.
lon: Longitude (X) in decimal degrees.
lat: Latitude (Y) in decimal degrees.
Returns:
A populated QgsFeature ready for addFeatures().
"""
feature = QgsFeature()
feature.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(lon, lat)))
feature.setAttributes([fid, name])
return feature
def test_layer_is_valid(qgis_app) -> None:
"""The memory layer must construct cleanly once QGIS is initialised."""
layer = build_point_layer()
assert layer.isValid()
assert layer.crs().authid() == "EPSG:4326"
def test_feature_count_after_insert(qgis_app) -> None:
"""featureCount() must reflect exactly the features committed."""
layer = build_point_layer()
features = [
make_feature(1, "alpha", 13.4050, 52.5200), # Berlin
make_feature(2, "bravo", 2.3522, 48.8566), # Paris
make_feature(3, "charlie", -0.1276, 51.5072), # London
]
ok, _added = layer.dataProvider().addFeatures(features)
assert ok, "addFeatures() reported failure"
assert layer.featureCount() == 3
def test_geometry_area_of_buffer(qgis_app) -> None:
"""A 1-degree buffer around a point must yield a positive area."""
point = QgsGeometry.fromPointXY(QgsPointXY(0.0, 0.0))
buffered: QgsGeometry = point.buffer(1.0, segments=16)
# A circle of radius 1 has area pi; the 16-segment polygon
# approximation is slightly under pi but comfortably positive.
assert buffered.area() == pytest.approx(3.1214, abs=1e-3)
def test_point_geometry_equality(qgis_app) -> None:
"""Round-tripping a point through a feature preserves its geometry."""
feature = make_feature(1, "origin", 5.0, 10.0)
geom: QgsGeometry = feature.geometry()
assert geom.asPoint() == QgsPointXY(5.0, 10.0)
Every test above takes qgis_app as an argument. That single parameter is what makes the QGIS bindings usable — requesting the fixture guarantees QgsApplication has been initialised before the test body runs. You never call QgsApplication([], False) yourself; the plugin owns that lifecycle.
A note on conftest.py
For this minimal example you do not need a conftest.py at all — installing pytest-qgis registers the fixtures automatically through pytest’s entry-point mechanism. You only add a conftest.py when you want shared, project-specific fixtures (a reusable sample layer, a temporary GeoPackage, a mocked interface). A first useful addition looks like this:
"""conftest.py — shared fixtures for the PyQGIS test suite."""
from __future__ import annotations
import pytest
from qgis.core import QgsVectorLayer
@pytest.fixture
def empty_point_layer(qgis_app) -> QgsVectorLayer:
"""Provide a fresh, empty in-memory point layer to each test."""
layer = QgsVectorLayer("Point?crs=EPSG:4326&field=id:integer",
"fixture_sites", "memory")
assert layer.isValid()
return layer
Because empty_point_layer itself depends on qgis_app, any test requesting it transitively pulls in the initialised application — a clean way to compose fixtures.
Running the Suite Headlessly
The one environment variable that matters is QT_QPA_PLATFORM. QGIS is a Qt application, and Qt refuses to start without a display platform. Setting it to offscreen lets Qt initialise with no X server, no Wayland, and no window manager — exactly what you want on a laptop terminal or a build runner:
QT_QPA_PLATFORM=offscreen pytest -q
Expect output resembling .... [100%] and 4 passed. The -q flag keeps the report terse; drop it while debugging to see per-test names. If you omit QT_QPA_PLATFORM on a machine with a display, the tests still pass — but the setting is what makes the same command portable to a headless server, so make it a habit.
Architecture Breakdown
The qgis_app fixture — session scope and why
qgis_app is declared with scope="session", meaning it is created once for the entire test run and reused by every test that requests it. This is deliberate and non-negotiable: QgsApplication is effectively a process-level singleton. Calling initQgis() twice, or constructing a second QgsApplication in the same process, leads to undefined behaviour and segfaults. A session-scoped fixture guarantees exactly one bootstrap and one teardown, no matter how many tests run.
The practical consequence is that fixture setup cost is paid once. QGIS initialisation — loading providers, the processing registry, and the CRS database — takes a second or two. Amortised across a hundred tests that overhead disappears. Do not try to make the fixture function-scoped to get “clean” state between tests; instead keep state out of the application by using fresh memory layers per test, as the module above does.
Memory-provider layers as test fixtures
The memory provider ("memory") is the workhorse of PyQGIS unit testing. Its URI encodes the geometry type, CRS, and fields in one string: "Point?crs=EPSG:4326&field=id:integer&field=name:string(40)". Because the data lives in RAM, each test starts from a known empty state, nothing is written to disk, and there is no cleanup to forget. Supported geometry tokens include Point, LineString, Polygon, and their Multi* variants, plus None for attribute-only tables.
Build these layers inside the test (or a function-scoped fixture) rather than at module load — construction requires the initialised application, so it must happen after qgis_app has run. That is why every helper above is called from within a test body, never at import time.
Assertion patterns for geometry
Geometry assertions fall into two families. For measurements, call the numeric methods and compare with tolerance: QgsGeometry.area(), .length(), or .distance() return floats that rarely land on exact values, so wrap them in pytest.approx(expected, abs=1e-6). For shape equality, prefer QgsGeometry.equals(other), which performs a topological comparison that ignores vertex ordering and coincident duplicate points — more robust than string-comparing WKT. For simple points, geom.asPoint() == QgsPointXY(x, y) is exact and readable. Reserve raw WKT comparison for cases where you genuinely need byte-for-byte reproduction.
Running the Same Test in CI
Once the suite is green locally, the identical command runs unchanged on a build server — that is the payoff of the offscreen platform. The continuous-integration companion page, running QGIS plugin tests in GitHub Actions, walks through the workflow YAML, but the operative step is simply this:
- name: Run PyQGIS tests
env:
QT_QPA_PLATFORM: offscreen
run: pytest -q
Because the tests never open a window or touch external data, they are deterministic and fast in a container — no display server, no fixture files, no network. That property is what makes memory-layer unit tests the foundation of a maintainable PyQGIS test suite.
Production Best Practices
- Request
qgis_appin every test that touchesqgis.core. It is the guarantee thatQgsApplicationis initialised; without it, the first API call crashes the interpreter. - Keep the fixture session-scoped. Never construct a second
QgsApplicationor callinitQgis()yourself — the plugin owns that lifecycle. - Build memory layers inside the test, not at import time. Layer construction needs the initialised application, which only exists once a test requests the fixture.
- Assert geometry with tolerance. Use
pytest.approx()forarea()andlength(); useQgsGeometry.equals()for shape equality rather than comparing WKT strings. - Check the boolean return of
addFeatures(). It returns(success, added_features)— a silentFalsemeans your features never landed, and a barefeatureCount()assertion would then fail confusingly. - Always export
QT_QPA_PLATFORM=offscreen. It costs nothing on a desktop and makes the exact same command portable to any headless runner. - Name test files and functions with the
test_prefix so pytest’s default discovery finds them without extra configuration.
Related
- Testing PyQGIS with pytest-qgis — parent guide covering the full pytest-qgis fixture set, plugin loading, and processing-algorithm testing
- Mocking the QGIS iface Object in Unit Tests — how to fake the interface object so UI-facing plugin code is testable headlessly
- Optimizing Feature Iteration with QgsVectorLayer.getFeatures — efficient patterns for reading the features you assert against in larger fixtures