Test Processing Algorithms with pytest-qgis
A Processing algorithm is the easiest part of a QGIS plugin to test properly: it takes a parameter dictionary, produces outputs, and touches no interface. What normally stops people is the boilerplate around it — initialising the application, registering providers, and tearing it all down without crashing. pytest-qgis handles that, leaving tests that read like ordinary Python.
This recipe belongs to Testing & CI for Plugins. It covers installing and configuring the plugin, the fixtures worth knowing, building input layers in memory, asserting on geometry and attributes, and running the whole thing headlessly.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer, importable by the Python running pytest — either the system install or a conda environment.
- A custom algorithm to test; see writing a custom processing algorithm.
pytestandpytest-qgisinstalled into the same environment.
Install and configure
Two lines of configuration make the fixtures available everywhere.
python3 -m pip install pytest pytest-qgis
# pytest.ini
[pytest]
qgis_qui_enabled = false
qgis_canvas_enabled = false
addopts = -q
Breakdown: Disabling the interface and the canvas is what makes the suite fast and headless — the fixtures still initialise the QGIS application, which is what registers providers and loads the PROJ database, but no widgets are created. pytest-qgis starts the application once for the whole session and shuts it down at the end, which matters because initialising QGIS repeatedly is slow and exiting it repeatedly is unstable.
On a headless machine, QT_QPA_PLATFORM=offscreen is still worth setting in the environment, because some Qt builds construct a platform plugin regardless of whether widgets are used.
Register your provider once
An algorithm has to be in the registry before processing.run can find it.
# conftest.py
import pytest
from qgis.core import QgsApplication
from my_plugin.provider import MyProvider
@pytest.fixture(scope="session", autouse=True)
def provider(qgis_app):
provider = MyProvider()
QgsApplication.processingRegistry().addProvider(provider)
yield provider
QgsApplication.processingRegistry().removeProvider(provider)
Breakdown: Depending on the qgis_app fixture is what orders this after the application exists — without it the registry may not be ready. Session scope means the provider is added once rather than per test, which is both faster and closer to how it behaves in QGIS. Keeping a reference by yielding the provider rather than a bare yield prevents it being garbage collected while registered, which produces a crash rather than an error. Removing it afterwards keeps the session clean if other tests inspect the registry.
Build input in memory
Reading a fixture file makes tests slow and makes failures ambiguous. Constructing the input in the test makes the expected result obvious.
from qgis.core import QgsVectorLayer, QgsFeature, QgsGeometry, QgsPointXY, QgsField
from qgis.PyQt.QtCore import QVariant
def make_points(coords, values):
layer = QgsVectorLayer("Point?crs=EPSG:27700", "input", "memory")
layer.dataProvider().addAttributes([QgsField("value", QVariant.Double)])
layer.updateFields()
features = []
for (x, y), value in zip(coords, values):
feature = QgsFeature(layer.fields())
feature.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(x, y)))
feature.setAttribute("value", value)
features.append(feature)
layer.dataProvider().addFeatures(features)
layer.updateExtents()
return layer
Breakdown: The URI string carries the geometry type and CRS, so a memory layer needs no file and no provider setup. updateFields() after addAttributes() is mandatory — the layer caches its field list and a feature built before the refresh gets the wrong field count. updateExtents() at the end matters for any algorithm that reads the extent, and its absence produces an empty result that looks like an algorithm bug. Adding features in one addFeatures() call rather than one at a time is both faster and the only way to get a single provider transaction.
Write the test
With the input in hand, the test is three lines of setup and specific assertions.
import processing
from qgis.core import QgsProcessingFeedback
def test_buffer_by_value_produces_expected_areas():
layer = make_points([(0, 0), (100, 0)], [10.0, 20.0])
feedback = QgsProcessingFeedback()
result = processing.run(
"myplugin:bufferbyvalue",
{"INPUT": layer, "FIELD": "value", "OUTPUT": "TEMPORARY_OUTPUT"},
feedback=feedback,
)
output = result["OUTPUT"]
assert output.featureCount() == 2
areas = sorted(f.geometry().area() for f in output.getFeatures())
assert areas[0] == pytest.approx(3.14159 * 10 ** 2, rel=0.01)
assert areas[1] == pytest.approx(3.14159 * 20 ** 2, rel=0.01)
Breakdown: TEMPORARY_OUTPUT returns a layer object rather than a path, so nothing touches the disk and the assertions run against the result directly. pytest.approx with a relative tolerance is essential for any geometry assertion — a buffer is a polygon approximation of a circle, so the area is close but never exact, and the segment count affects it. Sorting the areas removes any dependence on feature order, which no algorithm guarantees. Passing a feedback object even when nothing reads it keeps the call identical to production and makes it easy to add warning assertions later.
Parametrise instead of repeating
Most algorithms have a handful of interesting cases, and pytest's parametrisation turns them into one test with a table.
import pytest
@pytest.mark.parametrize(
"values,expected_count,note",
[
([10.0, 20.0], 2, "both positive"),
([10.0, None], 1, "null is skipped"),
([10.0, -5.0], 1, "negative is skipped"),
([0.0, 0.0], 0, "zero produces nothing"),
],
)
def test_skipping_rules(values, expected_count, note):
layer = make_points([(0, 0), (100, 0)][: len(values)], values)
result = processing.run(
"myplugin:bufferbyvalue",
{"INPUT": layer, "FIELD": "value", "OUTPUT": "TEMPORARY_OUTPUT"},
feedback=QgsProcessingFeedback(),
)
assert result["OUTPUT"].featureCount() == expected_count, note
Breakdown: The note column is not decoration — pytest prints it on failure, so a red test says "null is skipped" rather than only naming an index. Each row documents one rule of the algorithm's contract, which makes the table a readable specification as well as a test. Adding a case is one line, which is what keeps the edge cases getting tested as they are discovered rather than fixed and forgotten.
Test the failures too
An algorithm's error handling is as much its behaviour as its happy path.
import pytest
from qgis.core import QgsProcessingException
def test_empty_input_reports_a_warning():
layer = make_points([], [])
feedback = CollectingFeedback()
processing.run(
"myplugin:bufferbyvalue",
{"INPUT": layer, "FIELD": "value", "OUTPUT": "TEMPORARY_OUTPUT"},
feedback=feedback,
)
assert any("no features" in w.lower() for w in feedback.warnings)
def test_missing_field_raises():
layer = make_points([(0, 0)], [1.0])
with pytest.raises(QgsProcessingException):
processing.run(
"myplugin:bufferbyvalue",
{"INPUT": layer, "FIELD": "nonexistent", "OUTPUT": "TEMPORARY_OUTPUT"},
)
Breakdown: CollectingFeedback is the small subclass described in handling processing feedback and errors, collecting messages into lists. Asserting on a substring rather than the exact message keeps the test from breaking when the wording is improved, while still checking that something relevant was said. A parameter that does not validate raises before the algorithm runs, which is why the second test uses pytest.raises rather than inspecting feedback.
Run it in CI
The suite needs QGIS present and a display absent, which a container gives directly.
jobs:
test:
runs-on: ubuntu-latest
container: qgis/qgis:release-3_34
steps:
- uses: actions/checkout@v4
- run: pip install pytest pytest-qgis
- run: xvfb-run -a pytest tests/
env:
QT_QPA_PLATFORM: offscreen
Breakdown: The official QGIS container images carry a matching Python with the bindings already on the path, so no environment setup is needed beyond the test dependencies. xvfb-run and QT_QPA_PLATFORM=offscreen are belt and braces — either alone usually suffices, and having both costs nothing and saves a confusing failure on an image where one is missing. Pinning the container to a release tag rather than latest is what makes a green build mean something six months later; see running QGIS plugin tests in GitHub Actions for the full workflow.
QGIS version compatibility
The examples target QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.16 LTR | 3.7 | pytest-qgis supports this line; fixture names as described. |
| 3.22 LTR | 3.9 | Memory provider URI options unchanged. |
| 3.28 LTR | 3.9 | TEMPORARY_OUTPUT returns layer objects consistently across native algorithms. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40+ | 3.12 | Container images continue per release; check the tag exists before pinning. |
Troubleshooting
- The algorithm id is not found. The provider was not registered, or the fixture did not depend on
qgis_app. - The suite crashes at teardown. The provider object was collected while still registered. Yield it from the fixture.
- Features have the wrong field count.
updateFields()was not called afteraddAttributes(). - An algorithm returns nothing from a memory layer.
updateExtents()was skipped, so the layer reports an empty extent. - Geometry assertions fail by a tiny amount. Compare with
pytest.approxand a relative tolerance, never exactly. - Tests pass locally and fail in CI. A different GDAL or PROJ version. Pin the container tag and the environment.
Conclusion
Configure pytest-qgis with the interface and canvas disabled, register the provider once in a session fixture that yields it, build inputs as memory layers so the expected result is visible in the test, and assert on counts exactly and geometry approximately. Test the failure paths as deliberately as the happy one, and pin the container so CI results stay comparable.
Frequently Asked Questions
Do I need pytest-qgis at all?
No — the initialisation can be written by hand in a conftest.py. The plugin exists because getting the setup and, especially, the teardown right is fiddly and easy to get subtly wrong.
Can I test dialogs the same way?
Yes, with qgis_qui_enabled = true and pytest-qt for interaction. It is slower and more brittle than testing an algorithm, which is a good argument for keeping logic out of dialogs.
How do I test against a real file?
Put small fixture files in the test directory and load them with QgsVectorLayer(path, name, "ogr"). Keep them tiny; a fixture nobody can read in a text editor makes failures hard to diagnose.
Should I test the native algorithms my code calls?
No. Test that your algorithm passes the right parameters and handles the results, not that QGIS's buffer works. Mocking processing.run is occasionally the right way to assert on the parameters.