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.

One push, three QGIS versions, one gateA push triggers a matrix of three jobs, each running the plugin's test suite inside a container for a different QGIS release. All three must pass before the packaging and release job runs, so a plugin cannot be published against a version it fails on.The matrix is the point — one version passing proves littlepush / PRany branchqgis/qgis:release-3_28pytest · Python 3.9qgis/qgis:release-3_34pytest · Python 3.12qgis/qgis:release-3_40pytest · Python 3.12all green?needs: testpackagezip + release

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 xvfb provides.

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.

File-based fixtures against in-memory onesA suite that loads shapefiles from a test data directory can fail because of a missing file, a path separator, a locale-dependent encoding or a repository that grew large. A suite that builds memory layers in fixtures has none of those failure modes and runs identically everywhere.Fewer moving parts, fewer red builds that mean nothingtests/data/*.shpa missing sidecar filea path separator differencean encoding that depends on localea repository nobody wants to clonefour ways to fail before reaching your codememory layer fixturesno files at allCRS and fields declared inlinegeometry visible as WKTthe repository stays smalla red build means a real bug

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.

Where a matrix build spends its minutesWithout a dependency cache, each of three matrix entries spends most of its time installing packages before the tests begin. With the cache restored, installation shrinks to a few seconds and the run is dominated by the tests themselves, cutting total feedback time by more than half.The tests were never the slow partcoldcheckoutpip install — downloaded again for every entrypytestcachedcheckoutrestorepytestdone, in under a third of the time0:00feedbackKey the cache on the image and a hash of the requirements file, so it invalidates exactly when it should

QGIS version compatibility

Image tagQGISPythonNotes
qgis/qgis:release-3_283.28 LTR3.9--break-system-packages not needed.
qgis/qgis:release-3_343.34 LTR3.12Baseline for this page.
qgis/qgis:release-3_403.403.12Scoped Qt enums; a good early warning for Qt 6.
qgis/qgis:latestdevelopment3.12Rebuilt 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-python step replaced the interpreter. Do not add one — use the container's Python.
  • "could not connect to display". xvfb-run is missing, or QT_QPA_PLATFORM was 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. QgsApplication is 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.Warning rather than Qgis.Warning, and prefer imports from qgis.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.