Skip to content

Testing Fixtures

Testing Fixtures — qgis_sdk.testing

Test QGIS plugins without QGIS or Qt.

Installation

Terminal window
pip install qgis-sdk pytest

Setup

tests/conftest.py
pytest_plugins = ["qgis_sdk.testing"]

Entry point pytest11 in pyproject.toml also auto-discovers:

[project.entry-points.pytest11]
qgis_sdk = "qgis_sdk.testing"

Module map

qgis_sdk.testing is a package; importing from it is unchanged, and every fixture keeps its name.

ModuleWhat lives there
environmentQgisTestEnvironment, detect_qgis_environment() — pure / Qt / QGIS / WebEngine
callsCall, CallLog — one recorded shape for every fake
ifaceFakeIface, FakeAction
uiFakeDialog, FakeDialogWidget, FakeWebView, FakeWebPage, FakeWebChannel
bridgeBridgeHarness, HostError, validate_value(), and the legacy FakeBridge
qgis_apiFakeQgisAPI and the window.qgis sub-APIs
networkFakeNetworkTransport, FakeResponse, and the requests-like fakes
tasksthe PENDING/RUNNING/SUCCESS/FAILURE/CANCELED machine, plus the celery-shaped fakes
processing, dataFakeContext, FakeSink, mock_source(), FakeFeature, FakeGeometry
strategiesHypothesis strategies for pure values
pluginthe pytest plugin: fixtures and markers

Fake Classes

FakeNetworkResponse, FakeNetworkManager, FakeContentFetcher

from qgis_sdk.testing import FakeNetworkResponse, FakeNetworkManager, FakeContentFetcher
resp = FakeNetworkResponse(url="https://example.com", status_code=200,
content=b'{"ok": true}', headers={"content-type": "application/json"})
resp.ok # True
resp.text # '{"ok": true}'
resp.json() # {"ok": true}
resp.raise_for_status()
mgr = FakeNetworkManager(responses={
"example.com/api": FakeNetworkResponse(url="https://example.com/api", content=b'{"data": 1}'),
})
resp = mgr.get("https://example.com/api")
mgr.requests # [("GET", "https://example.com/api", ...)]
mgr.download("https://example.com/file.zip", "/tmp/file.zip")
mgr.fetch("https://example.com/api")
mgr.fetch_blocking("https://example.com/api")
fetcher = FakeContentFetcher(url="https://example.com", response=resp)
fetcher.content_as_string()
fetcher.content_as_bytes()
fetcher.finished.connect(lambda: print("done")) # calls immediately if response set

FakeTask, FakeTaskManager

from qgis_sdk.testing import FakeTask, FakeTaskManager, fake_task_manager_factory
def work(task, wait_time=0):
task.set_progress(100)
return 42
task = FakeTask("Test", work, wait_time=0, on_finished=lambda e, r: print(r))
task.description
task.can_cancel()
task.set_progress(50)
task.progress() # 50
task.is_canceled()
task.cancel()
task._execute() # runs work
task.is_finished()
task.result()
mgr = FakeTaskManager()
t = mgr.add_task(work, description="Test") # executes synchronously
mgr.count() # 0 after sync execution
mgr.added_tasks # history
mgr.tasks()
mgr.cancel_all()
mgr = fake_task_manager_factory()

FakeIface

from qgis_sdk.testing import FakeIface
iface = FakeIface()
iface.addToolBarIcon(widget)
iface.addPluginToMenu("Plugins/My Plugin", widget)
iface.removeToolBarIcon(widget)
iface.removePluginMenu("Plugins/My Plugin", widget)
iface.messageBar().pushMessage("hello")
iface.messages # ["hello"]
iface.toolbar_icons # List[FakeAction]
iface.menu_entries # List[Tuple[str, FakeAction]]
iface.mainWindow() # None by default
iface.activeLayer() # None

FakeAction

from qgis_sdk.testing import FakeAction, fake_action_factory
action = FakeAction(spec, callback)
action = fake_action_factory(spec, callback)
action.trigger() # calls callback
action.triggered # int count
action.tooltip
action.objectName
action.setObjectName("name")

FakeContext, FakeSink, FakeFeature, FakeGeometry, FakeFields

from qgis_sdk.testing import FakeContext, FakeSink, FakeFeature, FakeGeometry
ctx = FakeContext(values={"input": "test.gpkg"})
ctx.get("input")
ctx.get("missing", "default")
ctx.set_progress(0.5)
ctx.progress # [0.5]
ctx.is_canceled # False
ctx.create_sink(None) # FakeSink
ctx.source_path(param)
ctx.sink_path(param)
sink = FakeSink()
sink.add_feature(FakeFeature(fid=1))
sink.feature_count
list(sink.features())
sink.dissolve()
feature = FakeFeature(fid=0, attributes={"name": "a"}, geometry=FakeGeometry())
feature["name"]
feature.geometry
feature.clone()
geom = FakeGeometry(wkt="POINT(0 0)", area=1.0)
geom.area
geom.buffer(10)
geom.to_wkt()

mock_features, mock_source, mock_context

from qgis_sdk.testing import mock_features, mock_source, mock_context
features = mock_features(count=10, geometry_type="Point")
source = mock_source(feature_count=3)
source.feature_count
list(source.features(limit=5))
source.fields
source.geometry_type
ctx = mock_context(values={"a": 1})
ctx.get("a")

FakeDialog

from qgis_sdk.testing import FakeDialog, FakeDialogWidget
dlg = FakeDialog(values={"name": "test"})
dlg.get("name")
dlg.exec() # 1 Accepted, 0 Rejected
dlg.accept()
dlg.reject()
widget = FakeDialogWidget(value=42, text="42")
widget.text()
widget.setText("hi")
widget.value()
widget.setValue(10)
widget.isChecked()
widget.currentText()
widget.currentLayer()

FakeWebView, FakeWebPage, FakeWebChannel, FakeBridge

from qgis_sdk.testing import FakeWebView, FakeWebPage, FakeWebChannel, FakeBridge
view = FakeWebView()
view.page() # FakeWebPage
view.setHtml("<html>...</html>")
view.setUrl("https://example.com")
page = FakeWebPage()
page.runJavaScript("console.log('hi')", callback=lambda r: print(r))
page.js_calls
page.setWebChannel(channel)
channel = FakeWebChannel()
channel.registerObject("bridge", FakeBridge())
channel.objects
bridge = FakeBridge()
bridge.get_layer() # {"name": "test_layer", "count": 42}
bridge.get_extent()
bridge.log("msg")
bridge.process('{"action": "buffer"}')

CallLog — one shape for every call

from qgis_sdk.testing import CallLog, FakeIface, FakeNetworkTransport
calls = CallLog()
iface = FakeIface(calls=calls)
transport = FakeNetworkTransport(calls=calls)
transport.reply("GET", "https://example.test/api", json_data={"ok": True})
transport.get("https://example.test/api")
iface.messageBar().pushMessage("done")
calls.paths() # ["network.get", "iface.message.push"]
calls.for_target("iface")
calls.for_method("network.get")
calls.assert_called_once("iface", "message.push", text="done")
calls.assert_not_called("network", "post")
calls.reset()

Sharing one log across fakes is what makes cross-fake ordering assertable. The old per-fake lists (iface.messages, manager.requests) still work.

FakeNetworkTransport — scripted, no sockets, no sleeping

from qgis_sdk.testing import FakeNetworkTransport, FakeResponse, NoScriptedReply
transport = FakeNetworkTransport()
transport.reply("GET", "https://example.test/layers", json_data={"layers": []})
transport.reply_sequence("GET", "https://example.test/flaky", [
FakeResponse(status_code=503),
FakeResponse(status_code=200, json_data={"ok": True}),
])
transport.fail("GET", "https://example.test/down", error="connection refused")
transport.fail("GET", "https://example.test/slow", raises=TimeoutError("timed out"))
transport.delay("GET", "https://example.test/layers", 2.5) # virtual seconds
transport.redirect("GET", "https://example.test/old", to="https://example.test/new")
transport.get("https://example.test/layers")
transport.clock # 2.5 — nothing slept
transport.requests # request history
transport.calls # CallLog
transport.reset()

An unscripted route raises NoScriptedReply; a redirect cycle raises RedirectLoop. (FakeNetworkManager keeps its permissive {"mock": true} default for older suites.)

Deterministic tasks

from qgis_sdk.testing import FakeTaskManager
manager = FakeTaskManager(auto_run=False)
task = manager.submit(work, description="count", on_progress=print)
task.state # "PENDING"
manager.run_next()
task.state # "SUCCESS" — or "FAILURE" when work raised
task.transitions # ["PENDING", "RUNNING", "SUCCESS"]
task.progress_history
chain = manager.chain([first, then, finally_]) # each link gets the previous result
group = manager.group([a, b, c]) # independent
manager.cancel(task) # PENDING/RUNNING -> CANCELED

Progress is monotonic and illegal moves raise IllegalTransition. The celery-shaped FakeTask/add_task surface is unchanged and keeps celery’s STARTED/REVOKED names; canonical_state() maps between the two.

BridgeHarness — the protocol, not a bag of methods

from qgis_sdk.testing import BridgeHarness, HostError
harness = BridgeHarness(
descriptions={"qgis": qgis_manifest},
handlers={("qgis", "layers.list"): lambda args: []},
session_id="sess-1",
permissions=["layer.read"], # omit to grant everything the manifest declares
)
response = harness.invoke({
"bridge_version": 1, "request_id": "req-1",
"target": "qgis", "method": "layers.list", "args": {},
})
response["ok"], response["request_id"]
harness.invoke_json(text) # the shape a QWebChannel endpoint sees
harness.emit("qgis", "task.progress", {"task_id": "task-3", "progress": 40.0},
request_id="req-7")
harness.on_event(listener) # returns unsubscribe
harness.calls # CallLog of every request
harness.handle_for("qgis.layer", "obj-9c1a")

Checks run in the order a host must make them — envelope, version, target, method, permissions, arguments — and answer the closed error-kind set (invalid_request, unknown_target, unknown_method, invalid_arguments, permission_denied, unknown_object, host_unavailable, internal_error). A handler raises HostError(kind, message) to pick its own kind; anything else it raises becomes internal_error.

Hypothesis strategies

from qgis_sdk.testing import strategies as qst
qst.extents() qst.zoom_ranges() qst.crs_auth_ids()
qst.plugin_names() qst.field_specs() qst.object_handles()
qst.bridge_requests(manifest) qst.bridge_responses()
qst.network_responses() qst.task_transitions() qst.values_for_schema(schema)

Pure data only — no live QGIS objects, by design.

Pytest Fixtures

FixtureReturnsDescription
fake_ifaceFakeIfaceFake QgisInterface
fake_action_factoryCallablespec, callback → FakeAction
fake_contextFakeContextFake processing context
fake_dialog_factoryCallablevalues → FakeDialog
fake_dialogFakeDialogEmpty dialog
fake_webview_factoryCallablehtml → FakeWebView
fake_webviewFakeWebViewEmpty webview
fake_bridgeFakeBridgeFake bridge
fake_network_responseFakeNetworkResponseMock network response
fake_network_managerFakeNetworkManagerFake QgsNetworkAccessManager
fake_content_fetcherFakeContentFetcherFake QgsNetworkContentFetcher
fake_taskFakeTaskFake QgsTask
fake_task_managerFakeTaskManagerFake QgsTaskManager
mock_featuresCallablecount → List[FakeFeature]
mock_sourceCallablefeature_count → FakeSource
mock_contextCallablevalues → FakeContext
fake_network_transportFakeNetworkTransportScripted transport; unscripted routes raise
manual_task_managerFakeTaskManager(auto_run=False)Runs nothing until run_next()
bridge_harness_factorytype[BridgeHarness]Build a harness from your manifests
shared_callsCallLogAn empty log to hand several fakes
qgis_environmentQgisTestEnvironmentWhat this process can reach
qgis_available, pure_pythonboolSession-scoped layer answers
qt_appQApplicationOffscreen Qt app, or a skip
qgis_appQgsApplicationThe host’s live app, or a skip
webengine_appQApplicationQt app with QtWebEngine imported, or a skip

Aliases: mock_features_fixture, mock_source_fixture, mock_context_fixture. Markers: pure_python, qt, qgis, webengine, network, tasks. A test marked for a layer this process cannot reach is skipped at collection time, before its body runs — never handed a fake instead.

Usage

def test_toolbar(fake_iface, fake_action_factory):
from my_plugin import MyPlugin
MyPlugin.action_factory = staticmethod(fake_action_factory)
plugin = MyPlugin(fake_iface)
plugin.init_gui()
assert len(fake_iface.toolbar_icons) == 1
def test_bridge(fake_bridge):
assert fake_bridge.get_layer()["name"] == "test_layer"

Markers

@pytest.mark.qgis
def test_needs_qgis(): ...
@pytest.mark.webengine
def test_needs_webengine(): ...
MarkerMeansSkipped when
qtneeds real PyQt5 widgetsPyQt5 is not importable
qgisneeds qgis.corePyQGIS is not importable
webengineneeds PyQt5.QtWebEngineWidgetsQtWebEngine is not installed
pure_pythonasserts the no-QGIS fallbackQGIS is importable
network, tasksdescribes what the test is aboutnever — they demand no runtime

A test whose layer is missing is skipped before its body runs, never quietly handed a fake.

Execution-layer gates

The markers above make one command work everywhere by skipping what it cannot reach. That is right for the default suite and wrong for a gate: “the QGIS layer works here” cannot be proved by a run in which every QGIS test skipped.

Setting QGIS_TEST_LAYER flips the policy — it narrows the run to one layer and makes a missing layer an error:

GateCommandRuns
purebun --filter=qgis-sdk-py run test:pureevery test that demands no runtime (including the fakes and BridgeHarness)
qtbun --filter=qgis-sdk-py run test:qttests marked qt, offscreen
qgisbun --filter=qgis-sdk-py run test:qgistests marked qgis, against a real QgsApplication, serialized
webenginebun --filter=qgis-sdk-py run test:webenginetests marked webengine — optional, and not on the default path
Terminal window
$ QGIS_TEST_LAYER=webengine python -m pytest tests
ERROR: the webengine gate was requested but this environment cannot reach the
webengine layer (reachable: pure, qgis, qt). A gate that skips is a gate that
proved nothing, so this is an error rather than a skip.

Only the ungated test verb is fanned out by turbo, which is what keeps QtWebEngine off the critical path of ordinary bridge protocol tests. The qgis gate additionally refuses pytest-xdist: QgsApplication is a process-wide singleton, and parallel workers would share it.

The cross-language contract — the shared test-fixtures/bridge vectors that Rust, Python and TypeScript all consume — is a separate axis, gated by pixi run xtask validate-bridge-fixtures and covered in the full pixi run gates.

Fixtures by layer

FixtureLayerGives you
qgis_environment—the detected layers, as an immutable value
qt_appqtone session QApplication, QT_QPA_PLATFORM=offscreen
qgis_appqgisthe host’s application, or one the qgis gate built
qgis_runtimeqgisa QgsApplication this fixture constructs and shuts down
webengine_appwebengineqt_app with QtWebEngineWidgets imported first

Scaffold

qgis-plugin new generates:

my_plugin/tests/conftest.py -> pytest_plugins = ["qgis_sdk.testing"]
my_plugin/tests/test_dialog.py -> uses fake_iface, fake_bridge
Terminal window
pytest # no QGIS needed

See Also