Testing Fixtures
Testing Fixtures — qgis_sdk.testing
Test QGIS plugins without QGIS or Qt.
Installation
pip install qgis-sdk pytestSetup
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.
| Module | What lives there |
|---|---|
environment | QgisTestEnvironment, detect_qgis_environment() — pure / Qt / QGIS / WebEngine |
calls | Call, CallLog — one recorded shape for every fake |
iface | FakeIface, FakeAction |
ui | FakeDialog, FakeDialogWidget, FakeWebView, FakeWebPage, FakeWebChannel |
bridge | BridgeHarness, HostError, validate_value(), and the legacy FakeBridge |
qgis_api | FakeQgisAPI and the window.qgis sub-APIs |
network | FakeNetworkTransport, FakeResponse, and the requests-like fakes |
tasks | the PENDING/RUNNING/SUCCESS/FAILURE/CANCELED machine, plus the celery-shaped fakes |
processing, data | FakeContext, FakeSink, mock_source(), FakeFeature, FakeGeometry |
strategies | Hypothesis strategies for pure values |
plugin | the 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 # Trueresp.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 setFakeTask, 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.descriptiontask.can_cancel()task.set_progress(50)task.progress() # 50task.is_canceled()task.cancel()task._execute() # runs worktask.is_finished()task.result()
mgr = FakeTaskManager()t = mgr.add_task(work, description="Test") # executes synchronouslymgr.count() # 0 after sync executionmgr.added_tasks # historymgr.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 defaultiface.activeLayer() # NoneFakeAction
from qgis_sdk.testing import FakeAction, fake_action_factory
action = FakeAction(spec, callback)action = fake_action_factory(spec, callback)
action.trigger() # calls callbackaction.triggered # int countaction.tooltipaction.objectNameaction.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 # Falsectx.create_sink(None) # FakeSinkctx.source_path(param)ctx.sink_path(param)
sink = FakeSink()sink.add_feature(FakeFeature(fid=1))sink.feature_countlist(sink.features())sink.dissolve()
feature = FakeFeature(fid=0, attributes={"name": "a"}, geometry=FakeGeometry())feature["name"]feature.geometryfeature.clone()
geom = FakeGeometry(wkt="POINT(0 0)", area=1.0)geom.areageom.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_countlist(source.features(limit=5))source.fieldssource.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 Rejecteddlg.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() # FakeWebPageview.setHtml("<html>...</html>")view.setUrl("https://example.com")
page = FakeWebPage()page.runJavaScript("console.log('hi')", callback=lambda r: print(r))page.js_callspage.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 secondstransport.redirect("GET", "https://example.test/old", to="https://example.test/new")
transport.get("https://example.test/layers")transport.clock # 2.5 — nothing slepttransport.requests # request historytransport.calls # CallLogtransport.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 raisedtask.transitions # ["PENDING", "RUNNING", "SUCCESS"]task.progress_history
chain = manager.chain([first, then, finally_]) # each link gets the previous resultgroup = manager.group([a, b, c]) # independentmanager.cancel(task) # PENDING/RUNNING -> CANCELEDProgress 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 seesharness.emit("qgis", "task.progress", {"task_id": "task-3", "progress": 40.0}, request_id="req-7")harness.on_event(listener) # returns unsubscribeharness.calls # CallLog of every requestharness.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
| Fixture | Returns | Description |
|---|---|---|
fake_iface | FakeIface | Fake QgisInterface |
fake_action_factory | Callable | spec, callback → FakeAction |
fake_context | FakeContext | Fake processing context |
fake_dialog_factory | Callable | values → FakeDialog |
fake_dialog | FakeDialog | Empty dialog |
fake_webview_factory | Callable | html → FakeWebView |
fake_webview | FakeWebView | Empty webview |
fake_bridge | FakeBridge | Fake bridge |
fake_network_response | FakeNetworkResponse | Mock network response |
fake_network_manager | FakeNetworkManager | Fake QgsNetworkAccessManager |
fake_content_fetcher | FakeContentFetcher | Fake QgsNetworkContentFetcher |
fake_task | FakeTask | Fake QgsTask |
fake_task_manager | FakeTaskManager | Fake QgsTaskManager |
mock_features | Callable | count → List[FakeFeature] |
mock_source | Callable | feature_count → FakeSource |
mock_context | Callable | values → FakeContext |
fake_network_transport | FakeNetworkTransport | Scripted transport; unscripted routes raise |
manual_task_manager | FakeTaskManager(auto_run=False) | Runs nothing until run_next() |
bridge_harness_factory | type[BridgeHarness] | Build a harness from your manifests |
shared_calls | CallLog | An empty log to hand several fakes |
qgis_environment | QgisTestEnvironment | What this process can reach |
qgis_available, pure_python | bool | Session-scoped layer answers |
qt_app | QApplication | Offscreen Qt app, or a skip |
qgis_app | QgsApplication | The host’s live app, or a skip |
webengine_app | QApplication | Qt 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.qgisdef test_needs_qgis(): ...
@pytest.mark.webenginedef test_needs_webengine(): ...| Marker | Means | Skipped when |
|---|---|---|
qt | needs real PyQt5 widgets | PyQt5 is not importable |
qgis | needs qgis.core | PyQGIS is not importable |
webengine | needs PyQt5.QtWebEngineWidgets | QtWebEngine is not installed |
pure_python | asserts the no-QGIS fallback | QGIS is importable |
network, tasks | describes what the test is about | never — 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:
| Gate | Command | Runs |
|---|---|---|
| pure | bun --filter=qgis-sdk-py run test:pure | every test that demands no runtime (including the fakes and BridgeHarness) |
| qt | bun --filter=qgis-sdk-py run test:qt | tests marked qt, offscreen |
| qgis | bun --filter=qgis-sdk-py run test:qgis | tests marked qgis, against a real QgsApplication, serialized |
| webengine | bun --filter=qgis-sdk-py run test:webengine | tests marked webengine — optional, and not on the default path |
$ QGIS_TEST_LAYER=webengine python -m pytest testsERROR: the webengine gate was requested but this environment cannot reach thewebengine layer (reachable: pure, qgis, qt). A gate that skips is a gate thatproved 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
| Fixture | Layer | Gives you |
|---|---|---|
qgis_environment | — | the detected layers, as an immutable value |
qt_app | qt | one session QApplication, QT_QPA_PLATFORM=offscreen |
qgis_app | qgis | the host’s application, or one the qgis gate built |
qgis_runtime | qgis | a QgsApplication this fixture constructs and shuts down |
webengine_app | webengine | qt_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_bridgepytest # no QGIS needed