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.

How much of a plugin actually needs a mockMost of a plugin is analysis and data handling that takes layers as arguments and returns results, which tests exercise directly with real layer objects. A thin adapter layer reads the active layer and pushes messages, and only that layer needs a fake interface. Structuring the plugin this way shrinks the surface that requires mocking to a few functions.Shrink the part that needs a fake before faking itthe core — takes layers, returns resultsgeometry, attributes, algorithms, file writingtested with real layers and no interface at allthe adapter — reads iface, shows resultsactive layer in, message bar outonly this needs a fake interface

Prerequisites

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.

The shape a plugin test suite should haveMost tests exercise the analysis functions with real memory layers and no interface at all, and they are fast and meaningful. A smaller number test the adapter with a fake interface, checking behaviour such as warning when no layer is selected. A very small number check the whole plugin end to end, because those are slow and brittle.Many fast tests, few slow oneslogic tests — real memory layers, no interfacefast, meaningful, and where the bugs actually areadapter tests — a small fake interfacedid it warn when no layer was selected?end-to-end — a handfulslow and brittle by nature

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.

Why a real layer beats a mocked oneA test using a real memory layer exercises the actual provider, so a wrong field name or a geometry misuse fails the test. A test using a mocked layer returns whatever the mock was told to return, so the same mistakes pass unnoticed and the test proves only that the code called the methods the test author expected.A mock cannot disagree with youreal memory layerwrong field name — KeyErrorgeometry misuse — real resultprovider behaviour includedthe test can fail, so it means somethingmocked layerwrong field name — returns a mockgeometry misuse — unnoticedasserts your own assumptionsgreen, and no evidence of anything

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 versionPythonNotes
3.22 LTR3.9Memory layers and the interface methods shown are unchanged.
3.28 LTR3.9Identical.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12Identical; 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

  • AttributeError on 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 iface globally 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.