Running QGIS Plugin Tests in GitHub Actions

A complete GitHub Actions workflow that runs pytest-qgis inside the qgis/qgis container — headless with Xvfb, a QGIS version matrix, and dependency caching…

TL;DR: Run the job with container: qgis/qgis:release-3_34, pip install pytest-qgis, then execute xvfb-run -a pytest with QT_QPA_PLATFORM=offscreen, and fan the whole thing out across a matrix of QGIS image tags. Everything you need is in the single tests.yml below.

This page is part of the continuous integration for QGIS projects guide, which sits inside the broader headless automation, CI/CD & testing for PyQGIS reference. It assumes you already have a working local suite; if you are still writing tests, start with testing PyQGIS with pytest-qgis and come back to wire it into CI.

The hard part of testing a QGIS plugin in CI is never the assertions — it is getting a QGIS runtime, a Qt platform plugin, and a virtual display to coexist on an ephemeral runner. The official qgis/qgis Docker images solve the first problem: they ship a full QGIS build with matching PyQGIS bindings and PROJ data already in place. GitHub Actions’ container: key lets you run every step inside that image, so you never install QGIS on the host at all. The remaining pieces — Xvfb, the offscreen platform, and a version matrix — are configuration, and the workflow below spells them out.

Complete Runnable Workflow

Drop this file at .github/workflows/tests.yml in your plugin repository. It runs on every push and pull request, spins up a job per QGIS version, installs the test tooling, and runs the suite headlessly. Adjust the PYTHONPATH export if your importable package does not sit at the repository root.

yaml
name: tests

on:
  push:
    branches: [main]
  pull_request:
  workflow_dispatch:

# Cancel superseded runs on the same ref to save runner minutes.
concurrency:
  group: tests-${{ github.ref }}
  cancel-in-progress: true

jobs:
  pytest:
    name: pytest (QGIS ${{ matrix.qgis-tag }})
    runs-on: ubuntu-latest

    # Run the entire job inside the official QGIS image. It already
    # ships QGIS, the PyQGIS bindings, PROJ, and GDAL.
    container:
      image: qgis/qgis:${{ matrix.qgis-tag }}

    strategy:
      # Let each version report independently instead of aborting the
      # whole matrix on the first red build.
      fail-fast: false
      matrix:
        qgis-tag:
          - release-3_28   # oldest supported LTR
          - release-3_34   # current LTR
          - latest         # development / master build (allowed to fail)

    # QGIS talks to Qt; force the headless platform plugin globally so
    # even indirect widget construction does not need a real display.
    env:
      QT_QPA_PLATFORM: offscreen
      PYTHONPATH: ${{ github.workspace }}

    # The dev build breaks occasionally on unrelated upstream changes;
    # keep its failures informational rather than merge-blocking.
    continue-on-error: ${{ matrix.qgis-tag == 'latest' }}

    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Cache pip downloads
        uses: actions/cache@v4
        with:
          # The container runs as root, so pip caches under /root.
          path: /root/.cache/pip
          key: pip-${{ matrix.qgis-tag }}-${{ hashFiles('requirements-dev.txt') }}
          restore-keys: |
            pip-${{ matrix.qgis-tag }}-

      - name: Install test dependencies
        run: |
          python3 -m pip install --upgrade pip
          # --break-system-packages: the image uses an externally managed
          # environment, and we deliberately install into it.
          python3 -m pip install --break-system-packages \
            pytest pytest-qgis pytest-cov -r requirements-dev.txt

      - name: Show environment
        run: |
          python3 -c "from qgis.core import Qgis; print('QGIS', Qgis.QGIS_VERSION)"
          python3 -c "import pytest_qgis; print('pytest-qgis OK')"

      - name: Run tests (headless via Xvfb)
        run: |
          xvfb-run -a python3 -m pytest \
            --cov=. --cov-report=xml --junitxml=report.xml -v

      - name: Upload test report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: report-${{ matrix.qgis-tag }}
          path: |
            report.xml
            coverage.xml
          if-no-files-found: ignore

A requirements-dev.txt that pins your own test-only libraries keeps the cache key meaningful — if the file is empty, the hashFiles expression still resolves and caching still works. The pipeline this workflow describes is deliberately linear: a push fans out into one container per QGIS tag, each container runs the same pytest invocation, and each emits an independent report.

QGIS Plugin CI Pipeline A push triggers three parallel container jobs, one per QGIS image tag. Each container runs pytest under Xvfb and produces a test report; the three reports gate the merge. push / pull request container release-3_28 xvfb-run pytest container release-3_34 xvfb-run pytest container latest xvfb-run pytest report.xml report.xml report.xml merge gate

Architecture Breakdown

Running inside the qgis/qgis container image

The container: key on a job tells GitHub Actions to execute every run step inside a Docker container instead of directly on the runner VM. Because the qgis/qgis images already bundle a matched QGIS build, PyQGIS bindings, PROJ data, and GDAL, there is nothing to install and no version drift between the interpreter and the C++ libraries it wraps. This is the single most important decision in the whole file: you are testing against a real QGIS runtime, not a pip-installed approximation of one.

Two consequences follow. First, the container runs as root, which is why the pip cache path is /root/.cache/pip rather than the usual ~/.cache/pip on a hosted runner. Second, recent images ship an externally managed Python environment (PEP 668), so pip install needs --break-system-packages to write into it. That flag looks alarming but is correct here — the container is disposable, and you want your test dependencies alongside the pre-installed PyQGIS.

The Xvfb wrapper and the offscreen platform

QGIS is a Qt application, and importing large parts of PyQGIS — anything under qgis.gui, and some of qgis.core that touches rendering — requires a Qt platform plugin. On a headless runner there is no X server, so Qt aborts with could not connect to display. Two layers guard against this. Setting QT_QPA_PLATFORM=offscreen tells Qt to use its non-rendering platform plugin, which covers most widget construction. Wrapping the command in xvfb-run -a additionally provides a real virtual framebuffer for the handful of operations (map canvas rendering, layout export, some symbology) that the offscreen plugin cannot service. Using both is belt-and-braces: offscreen for speed, xvfb-run as the fallback display. The -a flag auto-selects a free display number so parallel matrix jobs never collide. The same headless recipe applies outside CI — see running headless QGIS with Xvfb in Docker for the standalone container version.

The matrix strategy and fail-fast

The strategy.matrix block expands the single job definition into one job per qgis-tag. Testing across release-3_28 (the oldest LTR you support), release-3_34 (the current LTR), and latest (the development build) catches the two failure classes that matter: API removals that break you on newer QGIS, and API additions you accidentally depend on that break you on older QGIS. Setting fail-fast: false is deliberate — the default true cancels every sibling job the moment one fails, which hides whether a bug is version-specific or universal. With fail-fast disabled you always get the full picture. Pairing that with continue-on-error on the latest tag means a broken nightly upstream build reports as an informational amber rather than blocking your merges, while the two LTR jobs stay strictly required.

Caching pip downloads

actions/cache@v4 restores /root/.cache/pip before the install step and saves it afterward, keyed on the QGIS tag plus a hash of requirements-dev.txt. The tag belongs in the key because wheels can be built against a specific Python ABI, and the images may differ; the requirements hash invalidates the cache the moment your dependencies change. The restore-keys fallback lets a run with changed requirements still seed itself from the previous cache and only download the delta. This typically turns a 40-second install into a few seconds on warm runs — modest per job, but multiplied across every matrix entry on every push.

Gating Merges and Releasing on Tags

A green workflow is only useful if it is enforced. In the repository’s branch-protection settings, mark the two LTR matrix jobs — pytest (QGIS release-3_28) and pytest (QGIS release-3_34) — as required status checks for the default branch. Because those job names are derived from the matrix, they are stable and can be selected by name. The latest job, being continue-on-error, is intentionally not required. With this in place, no pull request can merge until the suite passes on both supported LTR versions.

The same repository usually carries a second, tag-triggered workflow for releases. Keep the concerns separate: this file proves correctness on every change, while a release.yml reacts to a pushed version tag, builds the plugin ZIP, and uploads it. That release path is covered end to end in publishing a plugin to the QGIS plugin repository. A common and sensible pattern is to make the release job needs: the test job, so an artefact is never published from a red commit.

Production Best Practices

  • Pin the container to explicit tags, not floating names, for required checks. Keep release-3_28 and release-3_34 as the gate; treat latest as informational via continue-on-error.
  • Set fail-fast: false so a version-specific failure never masks the status of the other matrix legs.
  • Export QT_QPA_PLATFORM=offscreen at the job level and still wrap the run in xvfb-run -a. The two cover different Qt operations; use both.
  • Cache /root/.cache/pip, not ~/.cache/pip, because the QGIS container runs as root — the wrong path silently caches nothing.
  • Add python3 -c "from qgis.core import Qgis; print(Qgis.QGIS_VERSION)" as a cheap smoke step. It fails fast and legibly when a container image is broken, before pytest muddies the log.
  • Emit --junitxml and coverage reports and upload them with if: always(), so you can inspect failures even when the test step exits non-zero.
  • Use concurrency with cancel-in-progress to stop stacking runs on force-pushed pull-request branches and conserve runner minutes.
  • Guard version-sensitive code paths with Qgis.QGIS_VERSION_INT in the plugin itself, so the older-LTR matrix leg exercises the fallback branch rather than crashing.