Skip to content

Test layers

Every test in packages/qgis-sdk-python/tests belongs to exactly one layer, and each layer is a pytest marker you can select on its own (TASK-7). The markers are registered under --strict-markers by tests/conftest.py from tests/layers.py:

Marker Needs Selected by
pure Python only - no QGIS, Qt, display or network derived: every test that declares no native layer
qt a QApplication (offscreen) pytestmark = pytest.mark.qt
qgis an initialised QgsApplication pytestmark = pytest.mark.qgis
webengine Qt WebEngine and WebChannel under the application pytestmark = pytest.mark.webengine
desktop a real QGIS Desktop or headless native qualification host @pytest.mark.desktop
server a QGIS built with server bindings (qgis.server), which conda-forge does not provide pytestmark = pytest.mark.server, skipped by importorskip elsewhere; run by the QGIS 3.44 LTS row
native umbrella for qt, qgis, webengine, desktop, server derived
smoke explicit real-QGIS qualification probes, excluded by default @pytest.mark.smoke
Terminal window
# the pure half, the way the macOS/Windows matrix runs it (QGIS is not installed there)
pixi run -e default pytest -c packages/qgis-sdk-python/pyproject.toml packages/qgis-sdk-python/tests -m pure
# one native layer, or all of them, offscreen
QT_QPA_PLATFORM=offscreen pixi run -e default pytest -c packages/qgis-sdk-python/pyproject.toml packages/qgis-sdk-python/tests -m webengine
QT_QPA_PLATFORM=offscreen QGIS_SDK_NATIVE_SERVICES=1 pixi run -e default pytest -c packages/qgis-sdk-python/pyproject.toml packages/qgis-sdk-python/tests -m 'native and not smoke'

How the layers stay honest:

  • A native module imports its bindings in one guarded try at the top and calls pytest.skip(..., allow_module_level=True) with the reason, so a host without Qt reports what it lacks instead of failing. tests/test_layers.py runs a pure module next to the native ones in a child interpreter that refuses qgis and PyQt6 imports and checks exactly that.
  • The one application per process is owned by the session fixtures qt_app and qgis_app in tests/conftest.py (qt_app is already a QgsApplication whenever the QGIS bindings import, so the two layers never disagree about the application a process holds; qgis_app skips when only Qt is there) and stopped once at the end of the session; webengine_profile is the one QWebEngineProfile, because Chromium tears a profile down once per process. Native modules take app from these fixtures rather than building their own, so module order no longer decides which application a test sees. tests/layers.py prepares the headless environment (QT_QPA_PLATFORM, QGIS_PREFIX_PATH, the Chromium flags, an auth database directory) before any native module is collected, without importing Qt.
  • The service backends in tests/services/ run every contract against the in-memory fakes in the pure layer, and against real QGIS as a second qgis-marked parameter when QGIS_SDK_NATIVE_SERVICES=1 is set; tests/native_support.py carries the loopback HTTP origin and the pump/settle loop helpers the network, WebEngine and WebChannel modules share.
  • pytest-bdd features under tests/features/ are ordinary pytest tests: the steps are functions in the same modules, and tests/bridge_steps.py - loaded as a plugin by tests/conftest.py - shows the shared form, where the contract_host fixture and the step definitions serve both bridge_contract.feature and test_bridge_contract_fixtures.py. A scenario can carry a native marker like any other test.
  • The bridge contract vectors in packages/qgis-sdk-typescript/tests/contracts/bridge/cases.json are generated by scripts/generate_bridge_cases.py from the real dispatcher and consumed by the TypeScript client, the pure replay test and the feature above; tests/test_webchannel_native.py is the separate integration evidence that a real page over QWebChannel is bound to the host-minted identity and loses its subscriptions when the host disposes the view.

The server layer and the QGIS 3.44 LTS row

Section titled “The server layer and the QGIS 3.44 LTS row”

qgis.server - QgsServer, QgsServerInterface, QgsServerFilter - exists only in a QGIS configured with WITH_SERVER=ON and its Python bindings. conda-forge passes -D WITH_SERVER=FALSE on every platform, so the Pixi environment these harnesses run in has no such module (decision-52 implementation note). The server layer is therefore the one layer the default environment cannot run.

The QGIS a Linux user can actually install from a repository is the 3.44 LTS, and that build is also the one with qgis.server in it - and it is a Qt 5 / PyQt5 build, while the locked environment is Qt 6 / PyQt6. One CI job covers both facts (decision-70):

  • it installs qgis, qgis-server and python3-qgis from the qgis.org Debian repository on ubuntu-24.04, creates a virtual environment over the system interpreter (--system-site-packages, which is what makes the apt bindings importable next to pytest), and calls scripts/ci.py lts;
  • that helper probes the build first and prints ::notice::LTS host: qgis 3.44.x | qgisserver QgsServer | qt 5.15.x | binding PyQt5, then runs tests/test_server_native.py (the server layer), tests/test_lts_native.py (the Desktop host on this binding, offscreen) and tests/test_server_devtools_native.py (the dev --host server loop, opted in with QGIS_SDK_SERVER_DEV=1), each in its own pytest process with its own log, so no half can hide behind another’s summary line;
  • a missing qgis.server, a qgis.core that does not import, a run that collected nothing, or a module that reports skipped test count is a failure with an annotation, never a pass - a suite that skips everything must not read as evidence, and importorskip is precisely the way a native module says “this host cannot run me”, which on this row is the thing being disproved;
  • the job is part of the checks aggregate, so it gates.

First green run: 2026-10-30, run 41115792940 job 124269563846, probe qgis 3.44.7-Solothurn | qgisserver QgsServer | qt 5.15.13 | binding PyQt5, 7 passed for the Desktop module and 3 passed for the server module. Getting there took two defects the row itself exposed, both of which are the reason it is worth a job rather than a note: the adapters imported PyQt6 by name (so a PyQt5 QGIS crashed on the first import), and the session fixture that starts the application did too and pointed QGIS_PREFIX_PATH at sys.prefix - a virtual environment built with --system-site-packages owns no QGIS, and a QgsApplication with the wrong prefix initialises without providers. The fixtures now import Qt through qgis.PyQt like the adapters, and tests/layers.py finds the installation by walking up from the bindings to the directory that carries share/qgis (/usr on Debian and Ubuntu), falling back to sys.prefix where there is no QGIS at all.

The development loop for a server plugin is a third layer: qgis-sdk dev --host server starts a real QgsServer in the process and answers HTTP on loopback, so the harness drives the command a user types (spawn the CLI, wait for the port, request the plugin’s own SERVICE, save an edit to the @server.filter, request again). What it proves is the reload, not a restart: QGIS Server cannot withdraw a registration, so the reload hands the objects QGIS holds the new hook set (QgsServerHost.rebind) and the second body is the one the edited filter stamped.

The server host has two layers of its own, because a deployed QGIS Server calls three objects at three moments of one request and none of them exists on the default environment:

  • tests/qgis_server_support.py holds stand-ins for QgsServerFilter, QgsService, QgsAccessControlFilter, QgsServerInterface, QgsRequestHandler, QgsServerRequest and QgsServerResponse - QGIS’s own spellings, no behaviour beyond what the adapter reads. tests/test_server_wire.py pins the mapping between those objects and the plain ServerRequest/ServerResponse models, and tests/test_server_adapter.py pins the wiring: which hook runs in which slot, what a rewrite leaves in the request QGIS’s services read, what an answer leaves in the handler, and that a refused layer clears all four permissions. Both are pure, so a wrong parameterMap/setResponseHeader costs a second locally rather than a CI round trip.
  • tests/test_server_native.py drives the same adapter through a real QgsServer on the QGIS 3.44 LTS row: a QgsBufferServerRequest whose SERVICE=WMS a @server.filter rewrites to the plugin’s own @server.service, a body that proves the service read the rewritten parameters, an access hook answering QGIS’s per-layer question on real QgsVectorLayers, and the registration landing in serverInterface().filters(). No FCGI, no network.
  • tests/test_server_adapter_native.py runs in the Pixi environment, where QGIS is real and qgis.server is not: the adapter imports there and refuses a host by name, naming qgis-server/WITH_SERVER=ON and warning off conda-forge.

tests/test_lts_native.py is small on purpose: it reports which binding this QGIS has and proves the three places Qt 5 and Qt 6 disagree - a dock area, a QAction, an item flag - through qgis_sdk.adapters._qt, then runs one job on a QgsTask. Everything else about the hosts is proven in the native suite on the locked Qt 6 environment, and about the server dispatcher in the pure layer against qgis_sdk.testing.FakeServerInterface. The row does not run the WebEngine layer, so the matrix row leaves its Chromium cell empty and says why (decision-70 point 4).