Mock the QGIS Interface in Plugin Tests
iface is the object that makes a plugin a plugin — the canvas, the menus, the message bar, the active layer — and it is also the reason plugin code is hard to test, because it only exists inside a running QGIS desktop. The instinct is to build an elaborate fake. The better move is to need one rarely, and to keep it small when you do.
This recipe belongs to Testing and CI for Plugins. It covers a minimal interface stub, fixtures for layers and projects, when a real object beats a mock, and the structure that leaves most of the plugin testable without any of this.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer.
pytestand a working test setup — see Unit Test a QGIS Plugin with pytest.- A plugin whose logic you want under test.
Structure so that most code needs no mock
# analysis.py — no iface, no widgets
def summarise_by_field(layer, field):
totals = {}
for feature in layer.getFeatures():
key = feature[field]
totals[key] = totals.get(key, 0.0) + feature.geometry().area()
return totals
# plugin.py — the only place that touches the interface
def run(self):
layer = self.iface.activeLayer()
if layer is None:
self.iface.messageBar().pushWarning("Parcel Tools", self.tr("Select a layer"))
return
totals = summarise_by_field(layer, self.field_box.currentField())
self.show_results(totals)
Breakdown: summarise_by_field() needs no mock at all: a test builds a memory layer, adds three features and asserts the totals. Everything interesting about the plugin is in that function, and it is tested with real objects rather than fakes, which means the test actually proves something. The adapter above it is four lines, and the only behaviour worth testing there is the empty-layer branch. This split — described more fully in QGIS Core, GUI and Analysis Modules Explained — is what makes plugin testing tractable, and no amount of mocking substitutes for it.
Build layers instead of mocking them
A memory layer is a real QgsVectorLayer with real behaviour, and constructing one takes four lines.
import pytest
from qgis.core import QgsVectorLayer, QgsFeature, QgsGeometry, QgsRectangle
@pytest.fixture
def parcels():
layer = QgsVectorLayer("Polygon?crs=EPSG:27700&field=ward:string&field=ref:string",
"parcels", "memory")
provider = layer.dataProvider()
features = []
for ward, ref, size in (("North", "A1", 10), ("North", "A2", 20), ("South", "B1", 30)):
feature = QgsFeature(layer.fields())
feature.setAttributes([ward, ref])
feature.setGeometry(QgsGeometry.fromRect(
QgsRectangle(0, 0, size, size)))
features.append(feature)
provider.addFeatures(features)
layer.updateExtents()
return layer
Breakdown: The memory provider's URI declares the geometry type, the coordinate system and the fields in one string, which is why no schema setup is needed. Constructing features from layer.fields() gives them the right attribute count — a QgsFeature() with no fields silently drops attributes set by index, which produces empty values in a test that looks correct. updateExtents() matters if anything under test uses the layer extent. A fixture like this is worth more than any mock: it exercises the same code paths a real layer would, including the ones that catch a mistaken assumption about geometry or field types.
A minimal fake interface
For the few functions that genuinely take iface, a small stub beats a mocking framework because it documents exactly what your plugin uses.
class FakeMessageBar:
def __init__(self):
self.messages = []
def pushMessage(self, title, text, level=0, duration=0):
self.messages.append((title, text, level))
def pushWarning(self, title, text):
self.messages.append((title, text, "warning"))
def pushInfo(self, title, text):
self.messages.append((title, text, "info"))
class FakeInterface:
def __init__(self, active_layer=None):
self._active_layer = active_layer
self._message_bar = FakeMessageBar()
def activeLayer(self):
return self._active_layer
def setActiveLayer(self, layer):
self._active_layer = layer
def messageBar(self):
return self._message_bar
def mainWindow(self):
return None
def addToolBarIcon(self, action):
pass
def removeToolBarIcon(self, action):
pass
Breakdown: Only the methods your plugin calls need to exist; anything else raises an AttributeError, which is useful — it tells you the plugin reached for something you had not accounted for. Recording pushed messages rather than discarding them turns the message bar into an assertion target: a test can check that the warning was shown, not merely that the function returned early. Keeping the fake in the test package rather than the plugin means it never ships. When the fake starts growing beyond thirty lines, that is a signal the plugin is using the interface too widely rather than a signal to write more fake.
Use it in a test
def test_run_warns_without_a_layer():
plugin = ParcelTools(FakeInterface(active_layer=None))
plugin.run()
assert plugin.iface.messageBar().messages
title, text, level = plugin.iface.messageBar().messages[0]
assert level == "warning"
def test_summarise_totals_by_ward(parcels):
totals = summarise_by_field(parcels, "ward")
assert set(totals) == {"North", "South"}
assert totals["North"] == pytest.approx(500.0)
Breakdown: The first test is about the adapter and uses the fake; the second is about the logic and uses a real layer. That ratio — a couple of interface tests, many logic tests — is what a well-structured plugin's test suite looks like. Note that the first test asserts on behaviour the user sees, which is that a warning appeared, rather than on an internal call count; a test coupled to how the code is written breaks on every refactor and proves nothing about whether the plugin works.
Where mocking is the right tool
Three cases justify a mock even in a well-structured plugin.
Slow or external dependencies. A web service, a database that is not part of the test environment, an email notification. Replace the client, not the layer, and assert that your code called it with the right arguments.
Failure paths you cannot easily produce. A disk that is full, a provider that rejects a commit, a network timeout. Patching the specific call to raise is far easier than arranging the condition.
Interfaces you do not own. Another plugin's API, a system dialog. Wrap it in a thin function of your own and patch that function, which also documents your dependency in one place.
Everything else — layers, features, geometry, projects, expressions, Processing algorithms — is available for real in a test environment and should be used for real. QGIS objects are cheap to construct and behave the way they behave in production, which is the entire value of the test.
QGIS version compatibility
| QGIS version | Python | Notes |
|---|---|---|
| 3.22 LTR | 3.9 | Memory layers and the interface methods shown are unchanged. |
| 3.28 LTR | 3.9 | Identical. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | Identical; iface gained methods, which only matters if your plugin uses them. |
The qgis.testing module ships get_qgis_app() and a QgisInterface stub used by QGIS's own test suite — worth reaching for when your fake is growing, though a hand-written one keeps the dependency surface visible.
Troubleshooting
AttributeErroron the fake during a test. The plugin calls a method the fake does not have. Add it — and consider whether the plugin should be calling it there at all.- Tests pass but the plugin fails in QGIS. The mock returned something the real object never would. Prefer real layers.
- A test needs a QGIS application. Initialise one once per session with a fixture; constructing layers needs the providers loaded.
- Memory layer features have empty attributes. The feature was created without fields. Construct with
QgsFeature(layer.fields()). - The test suite is slow. Something constructs an application per test. Make that fixture session-scoped.
- Patching
ifaceglobally breaks other tests. Inject the interface through the plugin's constructor instead of patching a module attribute.
Conclusion
Keep the interface at the edges so most of the plugin can be tested with real layers and no mock at all. Build memory-layer fixtures rather than mocking layers, since a real object can disagree with you and a mock cannot. When a fake interface is genuinely needed, write a small one that records what was pushed, and treat its growth as a signal about the plugin's structure rather than an invitation to write more of it.
Frequently Asked Questions
Should I use unittest.mock or a hand-written fake?
A hand-written fake for iface, because it documents exactly what you depend on. unittest.mock is convenient for patching one external call.
Do I need a running QGIS to run the tests?
You need an initialised QgsApplication, not a desktop. A session fixture with the offscreen platform is enough, and works in continuous integration.
How do I test a dialog?
Instantiate it without showing it, set widget values programmatically, and call the handler. pytest-qt helps when you genuinely need to simulate clicks.
Can I test a Processing algorithm without the toolbox?
Yes — construct the algorithm, call initAlgorithm(), and run it through processing.run() with a plain feedback object. See Write a Custom Processing Algorithm in PyQGIS.
What should I assert on the message bar? That a message was shown and roughly what it was about. Asserting exact translated text makes the test fail in another locale.
How much coverage is enough? Cover the logic thoroughly and the adapter lightly. A plugin where the analysis functions are well tested is in far better shape than one with high overall coverage achieved by exercising widget code.