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:

bash
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).

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

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

bash
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.

pytest-qgis session fixture to assertion flow A left-to-right flow: the session-scoped qgis_app fixture initialises QgsApplication once, each test builds an in-memory layer and adds features, then asserts on feature count and geometry. qgis_app fixture session scope, once memory layer addFeatures() assert count + geometry initQgis() runs before any test body

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:

yaml
- 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_app in every test that touches qgis.core. It is the guarantee that QgsApplication is initialised; without it, the first API call crashes the interpreter.
  • Keep the fixture session-scoped. Never construct a second QgsApplication or call initQgis() 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() for area() and length(); use QgsGeometry.equals() for shape equality rather than comparing WKT strings.
  • Check the boolean return of addFeatures(). It returns (success, added_features) — a silent False means your features never landed, and a bare featureCount() 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.