Testing Fixtures
Testing Fixtures
Test QGIS plugin logic without QGIS or Qt installed — fast, offline, CI-friendly.
Overview
qgis_sdk.testing provides:
- Fake QGIS interface:
FakeIface,FakeAction - Fake processing:
FakeContext,FakeSink,FakeFeature,FakeGeometry,FakeFields,mock_features(),mock_source(),mock_context() - Fake dialogs:
FakeDialog,FakeDialogWidget - Fake web:
FakeWebView,FakeWebPage,FakeWebChannel,FakeBridge - Fake network:
FakeNetworkResponse,FakeNetworkManager,FakeContentFetcher - Fake tasks:
FakeTask,FakeTaskManager - Pytest fixtures:
fake_iface,fake_action_factory,fake_context,fake_dialog_factory,fake_webview_factory,fake_bridge,fake_network_manager,fake_task_manager,mock_features,mock_source,mock_context - Entry point:
pytest11→qgis_sdk.testingauto-discovers fixtures
Quick Start
conftest.py
pytest_plugins = ["qgis_sdk.testing"]This registers all fixtures globally — no need to import in each test.
Test Plugin Lifecycle
def test_toolbar(fake_iface, fake_action_factory): from my_plugin import MyPlugin
# Inject fake action factory (avoids Qt) MyPlugin.action_factory = staticmethod(fake_action_factory)
plugin = MyPlugin(fake_iface) plugin.init_gui()
assert len(fake_iface.toolbar_icons) == 1 assert fake_iface.toolbar_icons[0].tooltip == "Run my_plugin"
# Trigger toolbar button fake_iface.toolbar_icons[0].trigger() assert "Hello from my_plugin!" in fake_iface.messages
plugin.unload() assert len(fake_iface.toolbar_icons) == 0Test Dialogs
from my_plugin.dialogs.main_dialog import make_declarative_dialog
def test_dialog_defaults(): if make_declarative_dialog is None: return dlg = make_declarative_dialog() assert dlg.get("threshold") == 0.5 assert dlg.title == "Main Dialog"
def test_dialog_exec(): dlg = make_declarative_dialog() result = dlg.exec() assert result == 1 # Accepted
def test_dialog_with_fake_factory(fake_dialog_factory): # FakeDialog stores values, no Qt needed dlg = fake_dialog_factory({"name": "test", "threshold": 0.5}) assert dlg.exec() == 1 dlg.reject() assert dlg.exec() == 0Test Bridge
def test_bridge(fake_bridge): assert fake_bridge.get_layer() == {"name": "test_layer", "count": 42} assert fake_bridge.get_extent()["xmin"] == -180 assert "logged" in fake_bridge.log("hi")
def test_bridge_process(fake_bridge): import json result = fake_bridge.process(json.dumps({"action": "buffer"})) assert result["action"] == "buffer" assert result["status"] == "ok"Test Algorithms
from qgis_sdk.testing import mock_features, mock_source, mock_context
def test_algorithm(): from my_plugin.algorithms.buffer import BufferAlgorithm
algo = BufferAlgorithm()
ctx = mock_context({ "input_layer": mock_source(mock_features(10, geometry_type="Point")), "distance": 5.0, "dissolve": False, })
result = algo.process(ctx) output = result["output_layer"] assert output.feature_count == 10
def test_algorithm_with_fake_context(fake_context): fake_context.values = {"input": "test.gpkg", "threshold": 0.5} assert fake_context.get("input") == "test.gpkg" fake_context.set_progress(0.5) assert fake_context.progress == [0.5]Test Web Views
def test_webview(fake_webview_factory): view = fake_webview_factory("<html>test</html>") assert "test" in view.html
page = view.page() page.runJavaScript("console.log('hi')") assert len(page.js_calls) == 1
def test_web_channel(): from qgis_sdk.testing import FakeWebChannel, FakeBridge
channel = FakeWebChannel() bridge = FakeBridge() channel.registerObject("bridge", bridge) assert "bridge" in channel.objectsTest Network
def test_network(fake_network_manager): resp = fake_network_manager.get("https://example.com/api") assert resp.ok assert resp.json()["mock"] is True assert len(fake_network_manager.requests) == 1
def test_network_download(fake_network_manager, tmp_path): dest = tmp_path / "file.zip" fake_network_manager.download("https://example.com/file.zip", dest) assert dest.exists()
def test_content_fetcher(fake_content_fetcher): assert fake_content_fetcher.content_as_string() == '{"mock": true}' called = [] fake_content_fetcher.finished.connect(lambda: called.append(True)) assert calledfake_network_manager answers anything with {"mock": true}, which is handy
but lets a test pass against a URL it never meant to call. When the point of
the test is the network, use fake_network_transport: it answers only what
you scripted, and raises NoScriptedReply for anything else.
from qgis_sdk.testing import FakeResponse, NoScriptedReply
def test_retry_gives_up_after_the_second_failure(fake_network_transport): fake_network_transport.reply_sequence("GET", "https://example.test/flaky", [ FakeResponse(status_code=503), FakeResponse(status_code=200, json_data={"ok": True}), ]) assert fetch_with_retry("https://example.test/flaky") == {"ok": True}
def test_a_typo_in_the_url_is_not_silently_a_200(fake_network_transport): fake_network_transport.reply("GET", "https://example.test/layers", json_data=[]) with pytest.raises(NoScriptedReply): fake_network_transport.get("https://example.test/laeyrs")
def test_a_timeout_does_not_take_a_timeout(fake_network_transport): fake_network_transport.fail("GET", "https://example.test/slow", raises=TimeoutError("timed out")) fake_network_transport.delay("GET", "https://example.test/slow", 30.0) with pytest.raises(TimeoutError): fake_network_transport.get("https://example.test/slow") assert fake_network_transport.clock == 30.0 # virtual seconds; nothing sleptfail(...) also takes error= / status_code= for an error response,
redirect(method, url, to=...) records hops in response.history, and
transport.requests keeps every request with both url and original_url.
Test Tasks
def test_task_manager(fake_task_manager): def work(task): task.set_progress(50) return 42
t = fake_task_manager.add_task(work, description="Test") assert t.is_finished() assert t.progress() == 50 assert t.result() == 42
def test_task_decorator(fake_task_manager): from qgis_sdk.tasks import task
@task("My task") def my_task(task, value=10): return value * 2
t = fake_task_manager.add_task(my_task, value=10) assert t.is_finished()
def test_task_cancel(): from qgis_sdk.testing import FakeTask
def work(task): for i in range(100): if task.is_canceled(): return None task.set_progress(i) return 42
t = FakeTask("Test", work) t.cancel() assert t.is_canceled()fake_task_manager runs work the moment you submit it, which is fine for
“does this function work” but cannot express when something happened. For
that, manual_task_manager runs nothing until you say so:
def test_the_button_is_disabled_while_the_task_runs(manual_task_manager, fake_iface): panel = Panel(fake_iface, tasks=manual_task_manager) panel.start_export() assert panel.button.enabled is False # still PENDING — nothing has run manual_task_manager.run_next() assert panel.button.enabled is True
def test_a_failed_step_cancels_the_rest_of_the_chain(manual_task_manager): chain = manual_task_manager.chain([prepare, explode, publish]) manual_task_manager.run_all() assert [t.state for t in chain] == ["SUCCESS", "FAILURE", "CANCELED"]States are PENDING, RUNNING, SUCCESS, FAILURE, CANCELED; each task
keeps transitions and progress_history, progress is monotonic, and an
illegal move raises IllegalTransition. canonical_state() translates to and
from celery’s STARTED/REVOKED names the older fakes use.
Test the bridge protocol
fake_bridge is a stand-in object with a few canned methods. BridgeHarness
is the protocol itself — envelopes, permissions, argument schemas and the
error kinds a real host answers:
def test_the_ui_handles_a_denied_permission(bridge_harness_factory): harness = bridge_harness_factory( descriptions={"qgis": manifest}, handlers={("qgis", "layers.list"): lambda args: [{"id": "l1"}]}, session_id="sess-1", permissions=[], # grant nothing ) answer = harness.invoke({ "bridge_version": 1, "request_id": "req-1", "target": "qgis", "method": "layers.list", "args": {}, }) assert answer["error"]["kind"] == "permission_denied"
harness.grant("layer.read") assert harness.invoke({...})["ok"] is TrueChecks run in the host’s order — envelope, version, target, method,
permissions, arguments — and the error kind is always one of
invalid_request, unknown_target, unknown_method, invalid_arguments,
permission_denied, unknown_object, host_unavailable, internal_error.
A handler raises HostError(kind, message) to choose a kind; anything else it
raises is reported as internal_error. Events go the other way with
harness.emit(target, event, payload) and harness.on_event(listener).
Assert across fakes with one call log
def test_the_layer_is_added_before_the_message(shared_calls, fake_iface): plugin = MyPlugin(fake_iface, calls=shared_calls) plugin.import_file("roads.gpkg") assert shared_calls.paths() == ["qgis.layers.add", "iface.message.push"] shared_calls.assert_called_once("iface", "message.push", level="success")Every fake records Call(target, method, args); passing calls= to several
fakes (or using the shared_calls fixture) puts them in one ordered log.
Property-based tests
from hypothesis import givenfrom qgis_sdk.testing import strategies as qst
@given(qst.extents())def test_the_extent_survives_a_round_trip(extent): assert parse_extent(format_extent(extent)) == extentStrategies cover extents, zoom ranges, CRS auth ids, plugin names, field specs, object handles, request and response envelopes, network responses and task transitions — pure data only, no live QGIS objects.
Fake Classes Reference
FakeIface
Minimal QgisInterface implementation:
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.mainWindow() # None by default, set _main_window if needediface.activeLayer() # None by default
# Inspectioniface.toolbar_icons # List[FakeAction]iface.menu_entries # List[Tuple[str, FakeAction]]iface.messages # List[str]FakeAction
from qgis_sdk.testing import FakeAction, fake_action_factoryfrom qgis_sdk.plugin import ActionSpec
spec = ActionSpec(func_name="run", tooltip="Run", toolbar="Test", menu=())
def callback(): print("triggered")
action = FakeAction(spec, callback)action = fake_action_factory(spec, callback) # same
action.trigger() # calls callback, increments triggeredaction.triggered # countaction.tooltipaction.objectNameaction.setObjectName("my_action")FakeContext, FakeSink, FakeFeature
from qgis_sdk.testing import FakeContext, FakeSink, FakeFeature, FakeGeometry
ctx = FakeContext(values={"input": "test.gpkg"})ctx.get("input") # "test.gpkg"ctx.get("missing", "default")ctx.set_progress(0.5)ctx.is_canceledctx.create_sink(None) # FakeSinkctx.source_path(param) # "/tmp/<name>.gpkg"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, geometry_type="Polygon")source.feature_countlist(source.features(bbox=None, 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", "threshold": 0.5})dlg.get("name") # tries widget text, then valuesdlg.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.setChecked(True)widget.currentText()widget.currentLayer()FakeWebView, FakeWebPage, FakeWebChannel, FakeBridge
from qgis_sdk.testing import FakeWebView, FakeWebPage, FakeWebChannel, FakeBridge
view = FakeWebView(parent=None)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_calls # List[str]page.setWebChannel(channel)
channel = FakeWebChannel()channel.registerObject("bridge", FakeBridge())channel.objects
bridge = FakeBridge()bridge.get_layer() # {"name": "test_layer", "count": 42}bridge.get_extent() # {"xmin": -180, ...}bridge.log("msg") # "logged: msg"bridge.process('{"action": "buffer"}') # {"status": "ok", "action": "buffer"}FakeNetworkResponse, FakeNetworkManager, FakeContentFetcher
from qgis_sdk.testing import FakeNetworkResponse, FakeNetworkManager, FakeContentFetcher
resp = FakeNetworkResponse(url="https://example.com", status_code=200, content=b'{"ok": true}')resp.okresp.textresp.json()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.requestsmgr.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"))FakeTask, FakeTaskManager
from qgis_sdk.testing import FakeTask, FakeTaskManager
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()task.is_canceled()task.cancel()task._execute()task.is_finished()task.result()
mgr = FakeTaskManager()t = mgr.add_task(work, description="Test")mgr.count()mgr.added_tasksmgr.tasks()mgr.cancel_all()Pytest Fixtures
All fixtures are available after pytest_plugins = ["qgis_sdk.testing"]:
| Fixture | Type | Description |
|---|---|---|
fake_iface | FakeIface | Fake QgisInterface |
fake_action_factory | Callable | Factory spec, callback → FakeAction |
fake_context | FakeContext | Fake processing context |
fake_dialog_factory | Callable | Factory values → FakeDialog |
fake_dialog | FakeDialog | Empty FakeDialog |
fake_webview_factory | Callable | Factory html → FakeWebView |
fake_webview | FakeWebView | Empty FakeWebView |
fake_bridge | FakeBridge | Fake bridge with get_layer, etc. |
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 own manifests |
shared_calls | CallLog | One empty log to hand several fakes |
qgis_environment | QgisTestEnvironment | Which layers this process can reach |
qgis_available, pure_python | bool | Session-scoped answers from the same probe |
qt_app | QApplication | Offscreen Qt application, or a skip |
qgis_app | QgsApplication | The host’s live application, or a skip |
webengine_app | QApplication | Qt application with QtWebEngine, or a skip |
Additional aliases: mock_features_fixture, mock_source_fixture, mock_context_fixture, fake_action_factory_fixture, etc. for backwards compatibility.
Markers
@pytest.mark.qgisdef test_needs_qgis(): pass
@pytest.mark.webenginedef test_needs_webengine(): pass
@pytest.mark.networkdef test_needs_network(): pass
@pytest.mark.tasksdef test_needs_tasks(): pass
@pytest.mark.qtdef test_needs_a_qt_binding(): pass
@pytest.mark.pure_pythondef test_must_not_have_qgis_imported(): passRegistered via pytest_configure. A test marked for a layer this process
cannot reach is skipped at collection time, before its body runs — it is
never quietly handed a fake instead, and the skip reason names the missing
layer.
Running one layer at a time
Skipping is the right default and the wrong way to prove a layer works. When you need the stronger claim, run a gate: it narrows the suite to one layer and fails if that layer is missing.
# Everything that needs no runtime at all — the fakes, the bridge harnessbun --filter=qgis-sdk-py run test:pure
# Real widgets, offscreenbun --filter=qgis-sdk-py run test:qt
# A real QgsApplication, constructed and shut down by the suite, serializedbun --filter=qgis-sdk-py run test:qgis
# Optional, and deliberately not on the default pathbun --filter=qgis-sdk-py run test:webengine$ QGIS_TEST_LAYER=qgis python -m pytest tests -q4 passed, 394 deselectedThe plain test verb still runs everything and skips what it cannot reach —
that is the one CI fans out. Use a gate when “it was skipped” would be the
wrong answer.
Scaffold Integration
qgis-plugin new now scaffolds tests with fixtures:
qgis-plugin new my_plugin --web --framework react# Creates:# my_plugin/tests/conftest.py -> pytest_plugins = ["qgis_sdk.testing"]# my_plugin/tests/test_dialog.py -> uses fake_iface, fake_bridgeGenerated test_dialog.py:
from my_plugin.dialogs.main_dialog import make_declarative_dialog
def test_dialog_defaults(): if make_declarative_dialog is None: return dlg = make_declarative_dialog() assert dlg.get("threshold") == 0.5
def test_fake_iface(fake_iface, fake_action_factory): from my_plugin import MyPluginPlugin MyPluginPlugin.action_factory = staticmethod(fake_action_factory) plugin = MyPluginPlugin(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"Run:
pip install qgis-sdk pytestpytest # no QGIS neededWithout pytest
Fakes work without pytest — import directly:
from qgis_sdk.testing import FakeIface, fake_action_factory, FakeBridge
iface = FakeIface()bridge = FakeBridge()Entry Point
qgis-sdk registers as pytest plugin via pyproject.toml:
[project.entry-points.pytest11]qgis_sdk = "qgis_sdk.testing"So even without conftest.py, pytest will auto-discover fixtures if qgis-sdk is installed.
Next Steps
- Typed Bridge Guide — test JS ↔ Python bridge with FakeBridge
- Web Frameworks Guide — React/Vue/WebComponents testing
- Plugin Development — full workflow