Run QGIS Plugin Tests in GitHub Actions
A test suite that only runs when someone remembers to run it is a suite that stops passing quietly. Continuous integration fixes that, and for a QGIS plugin it is easier than it looks: the official Docker images carry QGIS and its Python bindings, so a workflow is a container, a virtual display and a pytest invocation.
This recipe belongs to Testing and CI for Plugins. It covers the workflow file, why a display is still needed for a headless run, testing across several QGIS versions at once, caching, and using the result as a release gate.
Prerequisites
- A plugin with tests that pass locally — see Unit Test a QGIS Plugin with pytest.
- A GitHub repository with Actions enabled.
- Tests that do not need a real display beyond what
xvfbprovides.
A working workflow
name: tests
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
container:
image: qgis/qgis:release-3_34
steps:
- uses: actions/checkout@v4
- name: Install test dependencies
run: pip3 install --break-system-packages pytest pytest-cov
- name: Run tests
env:
QT_QPA_PLATFORM: offscreen
PYTHONPATH: ${{ github.workspace }}
run: xvfb-run -a pytest tests/ -v --cov=parcel_tools
Breakdown: Running the job inside the QGIS container is what makes import qgis.core work without installing anything — the image already has QGIS, its bindings and a matched GDAL and PROJ. --break-system-packages is required because the image's Python is externally managed; that is acceptable here because the container is disposable. Setting PYTHONPATH to the workspace lets the tests import the plugin package from the repository root. Both QT_QPA_PLATFORM=offscreen and xvfb-run appear, and the redundancy is deliberate: the offscreen platform covers most cases, while a handful of QGIS classes still initialise an X connection, and xvfb-run -a gives them one to connect to.
Test across QGIS versions
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
qgis: ["release-3_28", "release-3_34", "release-3_40"]
container:
image: qgis/qgis:${{ matrix.qgis }}
steps:
- uses: actions/checkout@v4
- run: pip3 install --break-system-packages pytest
- run: xvfb-run -a pytest tests/ -v
env:
QT_QPA_PLATFORM: offscreen
PYTHONPATH: ${{ github.workspace }}
Breakdown: The matrix runs the same suite once per image, in parallel. fail-fast: false is important — the default cancels the remaining jobs on the first failure, and when you are specifically looking for a version-specific break, seeing which versions pass is the whole answer. Covering the oldest LTR your metadata.txt claims to support plus the newest release is the minimum honest test of that claim; the compatibility notes on every page of this site exist because those differences are real.
Write tests that survive CI
Two habits make the difference between a suite that runs anywhere and one that only runs on your machine.
Initialise QGIS once per session. Creating and destroying QgsApplication repeatedly is slow and, in some builds, unstable.
import pytest
from qgis.core import QgsApplication
@pytest.fixture(scope="session")
def qgis_app():
app = QgsApplication([], False)
app.initQgis()
from processing.core.Processing import Processing
Processing.initialize()
yield app
app.exitQgis()
Breakdown: A session-scoped fixture pays the start-up cost once for the whole run. Yielding the application rather than returning it lets the teardown run after the last test. Processing.initialize() belongs here too, because a test that calls processing.run() fails with "algorithm not found" without it — the same trap described in Headless QGIS and Server Automation.
Build fixtures rather than shipping data files. A memory layer created in a fixture has no path, no permissions and no encoding surprises:
@pytest.fixture
def parcels():
from qgis.core import QgsVectorLayer, QgsFeature, QgsGeometry
layer = QgsVectorLayer("Polygon?crs=EPSG:27700&field=ref:string&field=area:double",
"parcels", "memory")
feature = QgsFeature(layer.fields())
feature.setGeometry(QgsGeometry.fromWkt("POLYGON((0 0, 0 10, 10 10, 10 0, 0 0))"))
feature.setAttributes(["P-001", 100.0])
layer.dataProvider().addFeatures([feature])
return layer
Breakdown: The URI syntax defines the CRS and fields inline, so the fixture is self-contained and readable. WKT geometry keeps the test's intent visible — a reviewer can see the square without opening a file. Tests built this way run identically on every machine and in every container, and they fail for reasons that are about your code rather than about a missing file.
Gate the release on the tests
package:
needs: test
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build plugin zip
run: |
NAME=parcel_tools
mkdir -p dist
zip -r "dist/${NAME}.zip" "$NAME" \
-x "*.pyc" "*/__pycache__/*" "*/tests/*"
- uses: actions/upload-artifact@v4
with:
name: plugin-zip
path: dist/*.zip
Breakdown: needs: test makes this job wait for every matrix entry to pass, so a tag can never produce a package from a failing commit. The if condition restricts packaging to version tags, keeping ordinary pushes cheap. Excluding __pycache__ and the tests directory from the zip matters because the QGIS plugin repository rejects archives containing compiled artefacts, and the packaging rules are covered in Write metadata.txt for a QGIS Plugin.
Make the feedback fast enough to be used
A suite nobody waits for is a suite people stop reading. Three changes keep a QGIS matrix inside a couple of minutes.
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ matrix.qgis }}-${{ hashFiles('requirements-dev.txt') }}
- name: Run tests
run: xvfb-run -a pytest tests/ -q -x --durations=10 -p no:cacheprovider
env:
QT_QPA_PLATFORM: offscreen
PYTHONPATH: ${{ github.workspace }}
Breakdown: Keying the cache on both the QGIS image and a hash of the requirements file means each matrix entry keeps its own wheels and the cache invalidates precisely when a dependency changes. -x stops at the first failure, which is what you want on a pull request — the second failure is usually the first one repeating. --durations=10 prints the ten slowest tests, and reading that list once is normally enough to find the fixture that should have been session-scoped. -q keeps the log readable, since a wall of dots is easier to scan than a wall of names.
Split the workflow when the suite grows. Fast unit tests can run on every push against a single QGIS version, while the full matrix runs on pull requests and on the main branch — the arrangement gives a thirty-second answer during development and full coverage before anything merges. Uploading the coverage report as an artefact, rather than gating on a coverage percentage, tends to be the more useful trade: it puts the information in front of a reviewer without failing builds for a number that rarely reflects test quality.
QGIS version compatibility
| Image tag | QGIS | Python | Notes |
|---|---|---|---|
| qgis/qgis:release-3_28 | 3.28 LTR | 3.9 | --break-system-packages not needed. |
| qgis/qgis:release-3_34 | 3.34 LTR | 3.12 | Baseline for this page. |
| qgis/qgis:release-3_40 | 3.40 | 3.12 | Scoped Qt enums; a good early warning for Qt 6. |
| qgis/qgis:latest | development | 3.12 | Rebuilt continuously — useful as an allowed-to-fail matrix entry, never as a gate. |
Troubleshooting
- "No module named qgis". The job is not running inside the QGIS container, or a
setup-pythonstep replaced the interpreter. Do not add one — use the container's Python. - "could not connect to display".
xvfb-runis missing, orQT_QPA_PLATFORMwas not set. Use both. - Tests pass locally and fail in CI with "algorithm not found".
Processing.initialize()is not called in the fixture. - pip refuses to install. The image's Python is externally managed; add
--break-system-packages. - The suite is very slow.
QgsApplicationis being created per test. Make the fixture session-scoped. - A test fails only on the newest image. Usually a scoped-enum change. Write
Qgis.MessageLevel.Warningrather thanQgis.Warning, and prefer imports fromqgis.PyQt.
Conclusion
CI for a QGIS plugin is a container image, xvfb-run, an offscreen Qt platform and pytest. Run the suite as a matrix across the QGIS versions your metadata claims to support, with fail-fast: false so you learn which ones break; build fixtures in memory rather than shipping data files; and make packaging depend on the test job so a failing commit can never be released.
Frequently Asked Questions
Do I need xvfb if I set the offscreen platform?
Usually not, but a few QGIS classes still touch X during initialisation. xvfb-run -a costs nothing and removes an entire class of intermittent failure.
Can I use GitHub's Ubuntu runner without a container?
Yes, by adding the QGIS apt repository and installing qgis-python, but it is slower and the version is whatever the repository currently offers. The container pins it.
How do I test the plugin's GUI?pytest-qt drives widgets under the offscreen platform. Keep GUI tests thin — most logic should live outside the dialog so it can be tested without one.
Should I run tests on Windows and macOS too? Only if the plugin has platform-specific code. QGIS's Python API behaves consistently across platforms; the differences that bite are versions, not operating systems.
How do I publish to the plugin repository from CI?
Upload the artefact and submit it with the repository's API using a token stored as a repository secret. Keep the submission on tags only, and treat the version bump in metadata.txt as part of the release commit.