[{"data":1,"prerenderedAt":1588},["ShallowReactive",2],{"doc:\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Fmock-qgis-interface-in-plugin-tests":3},{"id":4,"title":5,"body":6,"description":1577,"extension":1578,"meta":1579,"navigation":263,"path":1584,"seo":1585,"stem":1586,"__hash__":1587},"docs\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Fmock-qgis-interface-in-plugin-tests\u002Findex.md","Mock the QGIS Interface in Plugin Tests",{"type":7,"value":8,"toc":1564},"minimark",[9,13,21,30,120,125,150,154,377,392,396,403,625,642,646,652,970,979,1030,1034,1191,1201,1287,1291,1294,1300,1306,1312,1315,1319,1387,1402,1406,1452,1456,1459,1463,1479,1489,1499,1517,1523,1529,1533,1560],[10,11,5],"h1",{"id":12},"mock-the-qgis-interface-in-plugin-tests",[14,15,16,20],"p",{},[17,18,19],"code",{},"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.",[14,22,23,24,29],{},"This recipe belongs to ",[25,26,28],"a",{"href":27},"\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002F","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.",[14,31,32],{},[33,34,39,43,47,54,63,73,78,84,89,97,101,105,115],"svg",{"viewBox":35,"role":36,"ariaLabel":37,"xmlns":38},"0 0 760 272","img","Diagram dividing plugin code into a large core that needs no interface, a thin adapter that touches iface, and the small surface that therefore needs mocking","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[40,41,42],"title",{},"How much of a plugin actually needs a mock",[44,45,46],"desc",{},"Most 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.",[48,49],"rect",{"x":50,"y":50,"width":51,"height":52,"fill":53},"0","760","272","#f6f3ea",[55,56,62],"text",{"x":57,"y":58,"style":59,"fill":60,"textAnchor":61},"380","28","text-anchor:middle;font-size:14px;font-weight:bold;font-family:sans-serif","#17211d","middle","Shrink the part that needs a fake before faking it",[48,64],{"x":65,"y":66,"width":67,"height":68,"rx":69,"fill":70,"stroke":71,"style":72},"40","52","680","88","10","#eef7f4","#0f766e","stroke-width:2.5",[55,74,77],{"x":57,"y":75,"style":76,"fill":71,"textAnchor":61},"80","text-anchor:middle;font-size:12px;font-weight:bold;font-family:sans-serif","the core — takes layers, returns results",[55,79,83],{"x":57,"y":80,"style":81,"fill":82,"textAnchor":61},"104","text-anchor:middle;font-size:11px;font-family:sans-serif","#2f3b35","geometry, attributes, algorithms, file writing",[55,85,88],{"x":57,"y":86,"style":81,"fill":87,"textAnchor":61},"126","#15803d","tested with real layers and no interface at all",[48,90],{"x":91,"y":92,"width":93,"height":94,"rx":69,"fill":95,"stroke":96,"style":72},"160","156","440","60","#fdf2e2","#b45309",[55,98,100],{"x":57,"y":99,"style":76,"fill":96,"textAnchor":61},"180","the adapter — reads iface, shows results",[55,102,104],{"x":57,"y":103,"style":81,"fill":82,"textAnchor":61},"202","active layer in, message bar out",[48,106],{"x":107,"y":108,"width":109,"height":110,"rx":111,"fill":112,"stroke":113,"style":114},"256","228","248","34","8","#eff3ff","#2563eb","stroke-width:2",[55,116,119],{"x":57,"y":117,"style":118,"fill":113,"textAnchor":61},"250","text-anchor:middle;font-size:11px;font-weight:bold;font-family:sans-serif","only this needs a fake interface",[121,122,124],"h2",{"id":123},"prerequisites","Prerequisites",[126,127,128,136,147],"ul",{},[129,130,131,135],"li",{},[132,133,134],"strong",{},"QGIS 3.34 LTR"," (bundled Python 3.12) or newer.",[129,137,138,141,142,146],{},[17,139,140],{},"pytest"," and a working test setup — see ",[25,143,145],{"href":144},"\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Funit-test-qgis-plugin-with-pytest\u002F","Unit Test a QGIS Plugin with pytest",".",[129,148,149],{},"A plugin whose logic you want under test.",[121,151,153],{"id":152},"structure-so-that-most-code-needs-no-mock","Structure so that most code needs no mock",[155,156,161],"pre",{"className":157,"code":158,"language":159,"meta":160,"style":160},"language-python shiki shiki-themes github-dark","# analysis.py — no iface, no widgets\ndef summarise_by_field(layer, field):\n    totals = {}\n    for feature in layer.getFeatures():\n        key = feature[field]\n        totals[key] = totals.get(key, 0.0) + feature.geometry().area()\n    return totals\n\n\n# plugin.py — the only place that touches the interface\ndef run(self):\n    layer = self.iface.activeLayer()\n    if layer is None:\n        self.iface.messageBar().pushWarning(\"Parcel Tools\", self.tr(\"Select a layer\"))\n        return\n    totals = summarise_by_field(layer, self.field_box.currentField())\n    self.show_results(totals)\n","python","",[17,162,163,172,187,199,214,225,249,258,265,270,276,287,301,319,347,353,368],{"__ignoreMap":160},[164,165,168],"span",{"class":166,"line":167},"line",1,[164,169,171],{"class":170},"sjoCn","# analysis.py — no iface, no widgets\n",[164,173,175,179,183],{"class":166,"line":174},2,[164,176,178],{"class":177},"snl16","def",[164,180,182],{"class":181},"svObZ"," summarise_by_field",[164,184,186],{"class":185},"s95oV","(layer, field):\n",[164,188,190,193,196],{"class":166,"line":189},3,[164,191,192],{"class":185},"    totals ",[164,194,195],{"class":177},"=",[164,197,198],{"class":185}," {}\n",[164,200,202,205,208,211],{"class":166,"line":201},4,[164,203,204],{"class":177},"    for",[164,206,207],{"class":185}," feature ",[164,209,210],{"class":177},"in",[164,212,213],{"class":185}," layer.getFeatures():\n",[164,215,217,220,222],{"class":166,"line":216},5,[164,218,219],{"class":185},"        key ",[164,221,195],{"class":177},[164,223,224],{"class":185}," feature[field]\n",[164,226,228,231,233,236,240,243,246],{"class":166,"line":227},6,[164,229,230],{"class":185},"        totals[key] ",[164,232,195],{"class":177},[164,234,235],{"class":185}," totals.get(key, ",[164,237,239],{"class":238},"sDLfK","0.0",[164,241,242],{"class":185},") ",[164,244,245],{"class":177},"+",[164,247,248],{"class":185}," feature.geometry().area()\n",[164,250,252,255],{"class":166,"line":251},7,[164,253,254],{"class":177},"    return",[164,256,257],{"class":185}," totals\n",[164,259,261],{"class":166,"line":260},8,[164,262,264],{"emptyLinePlaceholder":263},true,"\n",[164,266,268],{"class":166,"line":267},9,[164,269,264],{"emptyLinePlaceholder":263},[164,271,273],{"class":166,"line":272},10,[164,274,275],{"class":170},"# plugin.py — the only place that touches the interface\n",[164,277,279,281,284],{"class":166,"line":278},11,[164,280,178],{"class":177},[164,282,283],{"class":181}," run",[164,285,286],{"class":185},"(self):\n",[164,288,290,293,295,298],{"class":166,"line":289},12,[164,291,292],{"class":185},"    layer ",[164,294,195],{"class":177},[164,296,297],{"class":238}," self",[164,299,300],{"class":185},".iface.activeLayer()\n",[164,302,304,307,310,313,316],{"class":166,"line":303},13,[164,305,306],{"class":177},"    if",[164,308,309],{"class":185}," layer ",[164,311,312],{"class":177},"is",[164,314,315],{"class":238}," None",[164,317,318],{"class":185},":\n",[164,320,322,325,328,332,335,338,341,344],{"class":166,"line":321},14,[164,323,324],{"class":238},"        self",[164,326,327],{"class":185},".iface.messageBar().pushWarning(",[164,329,331],{"class":330},"sU2Wk","\"Parcel Tools\"",[164,333,334],{"class":185},", ",[164,336,337],{"class":238},"self",[164,339,340],{"class":185},".tr(",[164,342,343],{"class":330},"\"Select a layer\"",[164,345,346],{"class":185},"))\n",[164,348,350],{"class":166,"line":349},15,[164,351,352],{"class":177},"        return\n",[164,354,356,358,360,363,365],{"class":166,"line":355},16,[164,357,192],{"class":185},[164,359,195],{"class":177},[164,361,362],{"class":185}," summarise_by_field(layer, ",[164,364,337],{"class":238},[164,366,367],{"class":185},".field_box.currentField())\n",[164,369,371,374],{"class":166,"line":370},17,[164,372,373],{"class":238},"    self",[164,375,376],{"class":185},".show_results(totals)\n",[14,378,379,382,383,386,387,391],{},[132,380,381],{},"Breakdown:"," ",[17,384,385],{},"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 ",[25,388,390],{"href":389},"\u002Fpyqgis-fundamentals-environment-setup\u002Fqgis-api-architecture\u002Fqgis-core-gui-analysis-modules-explained\u002F","QGIS Core, GUI and Analysis Modules Explained"," — is what makes plugin testing tractable, and no amount of mocking substitutes for it.",[121,393,395],{"id":394},"build-layers-instead-of-mocking-them","Build layers instead of mocking them",[14,397,398,399,402],{},"A memory layer is a real ",[17,400,401],{},"QgsVectorLayer"," with real behaviour, and constructing one takes four lines.",[155,404,406],{"className":157,"code":405,"language":159,"meta":160,"style":160},"import pytest\nfrom qgis.core import QgsVectorLayer, QgsFeature, QgsGeometry, QgsRectangle\n\n\n@pytest.fixture\ndef parcels():\n    layer = QgsVectorLayer(\"Polygon?crs=EPSG:27700&field=ward:string&field=ref:string\",\n                           \"parcels\", \"memory\")\n    provider = layer.dataProvider()\n\n    features = []\n    for ward, ref, size in ((\"North\", \"A1\", 10), (\"North\", \"A2\", 20), (\"South\", \"B1\", 30)):\n        feature = QgsFeature(layer.fields())\n        feature.setAttributes([ward, ref])\n        feature.setGeometry(QgsGeometry.fromRect(\n            QgsRectangle(0, 0, size, size)))\n        features.append(feature)\n\n    provider.addFeatures(features)\n    layer.updateExtents()\n    return layer\n",[17,407,408,416,429,433,437,442,452,467,480,490,494,504,561,571,576,581,595,600,605,611,617],{"__ignoreMap":160},[164,409,410,413],{"class":166,"line":167},[164,411,412],{"class":177},"import",[164,414,415],{"class":185}," pytest\n",[164,417,418,421,424,426],{"class":166,"line":174},[164,419,420],{"class":177},"from",[164,422,423],{"class":185}," qgis.core ",[164,425,412],{"class":177},[164,427,428],{"class":185}," QgsVectorLayer, QgsFeature, QgsGeometry, QgsRectangle\n",[164,430,431],{"class":166,"line":189},[164,432,264],{"emptyLinePlaceholder":263},[164,434,435],{"class":166,"line":201},[164,436,264],{"emptyLinePlaceholder":263},[164,438,439],{"class":166,"line":216},[164,440,441],{"class":181},"@pytest.fixture\n",[164,443,444,446,449],{"class":166,"line":227},[164,445,178],{"class":177},[164,447,448],{"class":181}," parcels",[164,450,451],{"class":185},"():\n",[164,453,454,456,458,461,464],{"class":166,"line":251},[164,455,292],{"class":185},[164,457,195],{"class":177},[164,459,460],{"class":185}," QgsVectorLayer(",[164,462,463],{"class":330},"\"Polygon?crs=EPSG:27700&field=ward:string&field=ref:string\"",[164,465,466],{"class":185},",\n",[164,468,469,472,474,477],{"class":166,"line":260},[164,470,471],{"class":330},"                           \"parcels\"",[164,473,334],{"class":185},[164,475,476],{"class":330},"\"memory\"",[164,478,479],{"class":185},")\n",[164,481,482,485,487],{"class":166,"line":267},[164,483,484],{"class":185},"    provider ",[164,486,195],{"class":177},[164,488,489],{"class":185}," layer.dataProvider()\n",[164,491,492],{"class":166,"line":272},[164,493,264],{"emptyLinePlaceholder":263},[164,495,496,499,501],{"class":166,"line":278},[164,497,498],{"class":185},"    features ",[164,500,195],{"class":177},[164,502,503],{"class":185}," []\n",[164,505,506,508,511,513,516,519,521,524,526,528,531,533,535,538,540,543,545,548,550,553,555,558],{"class":166,"line":289},[164,507,204],{"class":177},[164,509,510],{"class":185}," ward, ref, size ",[164,512,210],{"class":177},[164,514,515],{"class":185}," ((",[164,517,518],{"class":330},"\"North\"",[164,520,334],{"class":185},[164,522,523],{"class":330},"\"A1\"",[164,525,334],{"class":185},[164,527,69],{"class":238},[164,529,530],{"class":185},"), (",[164,532,518],{"class":330},[164,534,334],{"class":185},[164,536,537],{"class":330},"\"A2\"",[164,539,334],{"class":185},[164,541,542],{"class":238},"20",[164,544,530],{"class":185},[164,546,547],{"class":330},"\"South\"",[164,549,334],{"class":185},[164,551,552],{"class":330},"\"B1\"",[164,554,334],{"class":185},[164,556,557],{"class":238},"30",[164,559,560],{"class":185},")):\n",[164,562,563,566,568],{"class":166,"line":303},[164,564,565],{"class":185},"        feature ",[164,567,195],{"class":177},[164,569,570],{"class":185}," QgsFeature(layer.fields())\n",[164,572,573],{"class":166,"line":321},[164,574,575],{"class":185},"        feature.setAttributes([ward, ref])\n",[164,577,578],{"class":166,"line":349},[164,579,580],{"class":185},"        feature.setGeometry(QgsGeometry.fromRect(\n",[164,582,583,586,588,590,592],{"class":166,"line":355},[164,584,585],{"class":185},"            QgsRectangle(",[164,587,50],{"class":238},[164,589,334],{"class":185},[164,591,50],{"class":238},[164,593,594],{"class":185},", size, size)))\n",[164,596,597],{"class":166,"line":370},[164,598,599],{"class":185},"        features.append(feature)\n",[164,601,603],{"class":166,"line":602},18,[164,604,264],{"emptyLinePlaceholder":263},[164,606,608],{"class":166,"line":607},19,[164,609,610],{"class":185},"    provider.addFeatures(features)\n",[164,612,614],{"class":166,"line":613},20,[164,615,616],{"class":185},"    layer.updateExtents()\n",[164,618,620,622],{"class":166,"line":619},21,[164,621,254],{"class":177},[164,623,624],{"class":185}," layer\n",[14,626,627,629,630,633,634,637,638,641],{},[132,628,381],{}," 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 ",[17,631,632],{},"layer.fields()"," gives them the right attribute count — a ",[17,635,636],{},"QgsFeature()"," with no fields silently drops attributes set by index, which produces empty values in a test that looks correct. ",[17,639,640],{},"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.",[121,643,645],{"id":644},"a-minimal-fake-interface","A minimal fake interface",[14,647,648,649,651],{},"For the few functions that genuinely take ",[17,650,19],{},", a small stub beats a mocking framework because it documents exactly what your plugin uses.",[155,653,655],{"className":157,"code":654,"language":159,"meta":160,"style":160},"class FakeMessageBar:\n    def __init__(self):\n        self.messages = []\n\n    def pushMessage(self, title, text, level=0, duration=0):\n        self.messages.append((title, text, level))\n\n    def pushWarning(self, title, text):\n        self.messages.append((title, text, \"warning\"))\n\n    def pushInfo(self, title, text):\n        self.messages.append((title, text, \"info\"))\n\n\nclass FakeInterface:\n    def __init__(self, active_layer=None):\n        self._active_layer = active_layer\n        self._message_bar = FakeMessageBar()\n\n    def activeLayer(self):\n        return self._active_layer\n\n    def setActiveLayer(self, layer):\n        self._active_layer = layer\n\n    def messageBar(self):\n        return self._message_bar\n\n    def mainWindow(self):\n        return None\n\n    def addToolBarIcon(self, action):\n        pass\n\n    def removeToolBarIcon(self, action):\n        pass\n",[17,656,657,667,677,688,692,716,723,727,737,749,753,762,773,777,781,790,806,818,830,834,843,853,858,869,880,885,895,905,910,920,928,933,944,950,955,965],{"__ignoreMap":160},[164,658,659,662,665],{"class":166,"line":167},[164,660,661],{"class":177},"class",[164,663,664],{"class":181}," FakeMessageBar",[164,666,318],{"class":185},[164,668,669,672,675],{"class":166,"line":174},[164,670,671],{"class":177},"    def",[164,673,674],{"class":238}," __init__",[164,676,286],{"class":185},[164,678,679,681,684,686],{"class":166,"line":189},[164,680,324],{"class":238},[164,682,683],{"class":185},".messages ",[164,685,195],{"class":177},[164,687,503],{"class":185},[164,689,690],{"class":166,"line":201},[164,691,264],{"emptyLinePlaceholder":263},[164,693,694,696,699,702,704,706,709,711,713],{"class":166,"line":216},[164,695,671],{"class":177},[164,697,698],{"class":181}," pushMessage",[164,700,701],{"class":185},"(self, title, text, level",[164,703,195],{"class":177},[164,705,50],{"class":238},[164,707,708],{"class":185},", duration",[164,710,195],{"class":177},[164,712,50],{"class":238},[164,714,715],{"class":185},"):\n",[164,717,718,720],{"class":166,"line":227},[164,719,324],{"class":238},[164,721,722],{"class":185},".messages.append((title, text, level))\n",[164,724,725],{"class":166,"line":251},[164,726,264],{"emptyLinePlaceholder":263},[164,728,729,731,734],{"class":166,"line":260},[164,730,671],{"class":177},[164,732,733],{"class":181}," pushWarning",[164,735,736],{"class":185},"(self, title, text):\n",[164,738,739,741,744,747],{"class":166,"line":267},[164,740,324],{"class":238},[164,742,743],{"class":185},".messages.append((title, text, ",[164,745,746],{"class":330},"\"warning\"",[164,748,346],{"class":185},[164,750,751],{"class":166,"line":272},[164,752,264],{"emptyLinePlaceholder":263},[164,754,755,757,760],{"class":166,"line":278},[164,756,671],{"class":177},[164,758,759],{"class":181}," pushInfo",[164,761,736],{"class":185},[164,763,764,766,768,771],{"class":166,"line":289},[164,765,324],{"class":238},[164,767,743],{"class":185},[164,769,770],{"class":330},"\"info\"",[164,772,346],{"class":185},[164,774,775],{"class":166,"line":303},[164,776,264],{"emptyLinePlaceholder":263},[164,778,779],{"class":166,"line":321},[164,780,264],{"emptyLinePlaceholder":263},[164,782,783,785,788],{"class":166,"line":349},[164,784,661],{"class":177},[164,786,787],{"class":181}," FakeInterface",[164,789,318],{"class":185},[164,791,792,794,796,799,801,804],{"class":166,"line":355},[164,793,671],{"class":177},[164,795,674],{"class":238},[164,797,798],{"class":185},"(self, active_layer",[164,800,195],{"class":177},[164,802,803],{"class":238},"None",[164,805,715],{"class":185},[164,807,808,810,813,815],{"class":166,"line":370},[164,809,324],{"class":238},[164,811,812],{"class":185},"._active_layer ",[164,814,195],{"class":177},[164,816,817],{"class":185}," active_layer\n",[164,819,820,822,825,827],{"class":166,"line":602},[164,821,324],{"class":238},[164,823,824],{"class":185},"._message_bar ",[164,826,195],{"class":177},[164,828,829],{"class":185}," FakeMessageBar()\n",[164,831,832],{"class":166,"line":607},[164,833,264],{"emptyLinePlaceholder":263},[164,835,836,838,841],{"class":166,"line":613},[164,837,671],{"class":177},[164,839,840],{"class":181}," activeLayer",[164,842,286],{"class":185},[164,844,845,848,850],{"class":166,"line":619},[164,846,847],{"class":177},"        return",[164,849,297],{"class":238},[164,851,852],{"class":185},"._active_layer\n",[164,854,856],{"class":166,"line":855},22,[164,857,264],{"emptyLinePlaceholder":263},[164,859,861,863,866],{"class":166,"line":860},23,[164,862,671],{"class":177},[164,864,865],{"class":181}," setActiveLayer",[164,867,868],{"class":185},"(self, layer):\n",[164,870,872,874,876,878],{"class":166,"line":871},24,[164,873,324],{"class":238},[164,875,812],{"class":185},[164,877,195],{"class":177},[164,879,624],{"class":185},[164,881,883],{"class":166,"line":882},25,[164,884,264],{"emptyLinePlaceholder":263},[164,886,888,890,893],{"class":166,"line":887},26,[164,889,671],{"class":177},[164,891,892],{"class":181}," messageBar",[164,894,286],{"class":185},[164,896,898,900,902],{"class":166,"line":897},27,[164,899,847],{"class":177},[164,901,297],{"class":238},[164,903,904],{"class":185},"._message_bar\n",[164,906,908],{"class":166,"line":907},28,[164,909,264],{"emptyLinePlaceholder":263},[164,911,913,915,918],{"class":166,"line":912},29,[164,914,671],{"class":177},[164,916,917],{"class":181}," mainWindow",[164,919,286],{"class":185},[164,921,923,925],{"class":166,"line":922},30,[164,924,847],{"class":177},[164,926,927],{"class":238}," None\n",[164,929,931],{"class":166,"line":930},31,[164,932,264],{"emptyLinePlaceholder":263},[164,934,936,938,941],{"class":166,"line":935},32,[164,937,671],{"class":177},[164,939,940],{"class":181}," addToolBarIcon",[164,942,943],{"class":185},"(self, action):\n",[164,945,947],{"class":166,"line":946},33,[164,948,949],{"class":177},"        pass\n",[164,951,953],{"class":166,"line":952},34,[164,954,264],{"emptyLinePlaceholder":263},[164,956,958,960,963],{"class":166,"line":957},35,[164,959,671],{"class":177},[164,961,962],{"class":181}," removeToolBarIcon",[164,964,943],{"class":185},[164,966,968],{"class":166,"line":967},36,[164,969,949],{"class":177},[14,971,972,974,975,978],{},[132,973,381],{}," Only the methods your plugin calls need to exist; anything else raises an ",[17,976,977],{},"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.",[14,980,981],{},[33,982,985,988,991,993,996,999,1003,1007,1011,1015,1019,1023,1026],{"viewBox":983,"role":36,"ariaLabel":984,"xmlns":38},"0 0 760 248","Diagram of a test suite shaped as many logic tests using real layers, a few adapter tests using a fake interface, and a small number of end to end checks",[40,986,987],{},"The shape a plugin test suite should have",[44,989,990],{},"Most 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.",[48,992],{"x":50,"y":50,"width":51,"height":109,"fill":53},[55,994,995],{"x":57,"y":58,"style":59,"fill":60,"textAnchor":61},"Many fast tests, few slow ones",[48,997],{"x":75,"y":66,"width":998,"height":66,"rx":111,"fill":70,"stroke":71,"style":72},"600",[55,1000,1002],{"x":57,"y":1001,"style":76,"fill":71,"textAnchor":61},"74","logic tests — real memory layers, no interface",[55,1004,1006],{"x":57,"y":1005,"style":81,"fill":82,"textAnchor":61},"94","fast, meaningful, and where the bugs actually are",[48,1008],{"x":99,"y":1009,"width":1010,"height":66,"rx":111,"fill":112,"stroke":113,"style":114},"116","400",[55,1012,1014],{"x":57,"y":1013,"style":76,"fill":113,"textAnchor":61},"138","adapter tests — a small fake interface",[55,1016,1018],{"x":57,"y":1017,"style":81,"fill":82,"textAnchor":61},"158","did it warn when no layer was selected?",[48,1020],{"x":1021,"y":99,"width":1022,"height":66,"rx":111,"fill":95,"stroke":96,"style":114},"270","220",[55,1024,1025],{"x":57,"y":103,"style":76,"fill":96,"textAnchor":61},"end-to-end — a handful",[55,1027,1029],{"x":57,"y":1028,"style":81,"fill":82,"textAnchor":61},"222","slow and brittle by nature",[121,1031,1033],{"id":1032},"use-it-in-a-test","Use it in a test",[155,1035,1037],{"className":157,"code":1036,"language":159,"meta":160,"style":160},"def test_run_warns_without_a_layer():\n    plugin = ParcelTools(FakeInterface(active_layer=None))\n    plugin.run()\n\n    assert plugin.iface.messageBar().messages\n    title, text, level = plugin.iface.messageBar().messages[0]\n    assert level == \"warning\"\n\n\ndef test_summarise_totals_by_ward(parcels):\n    totals = summarise_by_field(parcels, \"ward\")\n    assert set(totals) == {\"North\", \"South\"}\n    assert totals[\"North\"] == pytest.approx(500.0)\n",[17,1038,1039,1048,1068,1073,1077,1085,1100,1113,1117,1121,1131,1145,1169],{"__ignoreMap":160},[164,1040,1041,1043,1046],{"class":166,"line":167},[164,1042,178],{"class":177},[164,1044,1045],{"class":181}," test_run_warns_without_a_layer",[164,1047,451],{"class":185},[164,1049,1050,1053,1055,1058,1062,1064,1066],{"class":166,"line":174},[164,1051,1052],{"class":185},"    plugin ",[164,1054,195],{"class":177},[164,1056,1057],{"class":185}," ParcelTools(FakeInterface(",[164,1059,1061],{"class":1060},"s9osk","active_layer",[164,1063,195],{"class":177},[164,1065,803],{"class":238},[164,1067,346],{"class":185},[164,1069,1070],{"class":166,"line":189},[164,1071,1072],{"class":185},"    plugin.run()\n",[164,1074,1075],{"class":166,"line":201},[164,1076,264],{"emptyLinePlaceholder":263},[164,1078,1079,1082],{"class":166,"line":216},[164,1080,1081],{"class":177},"    assert",[164,1083,1084],{"class":185}," plugin.iface.messageBar().messages\n",[164,1086,1087,1090,1092,1095,1097],{"class":166,"line":227},[164,1088,1089],{"class":185},"    title, text, level ",[164,1091,195],{"class":177},[164,1093,1094],{"class":185}," plugin.iface.messageBar().messages[",[164,1096,50],{"class":238},[164,1098,1099],{"class":185},"]\n",[164,1101,1102,1104,1107,1110],{"class":166,"line":251},[164,1103,1081],{"class":177},[164,1105,1106],{"class":185}," level ",[164,1108,1109],{"class":177},"==",[164,1111,1112],{"class":330}," \"warning\"\n",[164,1114,1115],{"class":166,"line":260},[164,1116,264],{"emptyLinePlaceholder":263},[164,1118,1119],{"class":166,"line":267},[164,1120,264],{"emptyLinePlaceholder":263},[164,1122,1123,1125,1128],{"class":166,"line":272},[164,1124,178],{"class":177},[164,1126,1127],{"class":181}," test_summarise_totals_by_ward",[164,1129,1130],{"class":185},"(parcels):\n",[164,1132,1133,1135,1137,1140,1143],{"class":166,"line":278},[164,1134,192],{"class":185},[164,1136,195],{"class":177},[164,1138,1139],{"class":185}," summarise_by_field(parcels, ",[164,1141,1142],{"class":330},"\"ward\"",[164,1144,479],{"class":185},[164,1146,1147,1149,1152,1155,1157,1160,1162,1164,1166],{"class":166,"line":289},[164,1148,1081],{"class":177},[164,1150,1151],{"class":238}," set",[164,1153,1154],{"class":185},"(totals) ",[164,1156,1109],{"class":177},[164,1158,1159],{"class":185}," {",[164,1161,518],{"class":330},[164,1163,334],{"class":185},[164,1165,547],{"class":330},[164,1167,1168],{"class":185},"}\n",[164,1170,1171,1173,1176,1178,1181,1183,1186,1189],{"class":166,"line":303},[164,1172,1081],{"class":177},[164,1174,1175],{"class":185}," totals[",[164,1177,518],{"class":330},[164,1179,1180],{"class":185},"] ",[164,1182,1109],{"class":177},[164,1184,1185],{"class":185}," pytest.approx(",[164,1187,1188],{"class":238},"500.0",[164,1190,479],{"class":185},[14,1192,1193,1195,1196,1200],{},[132,1194,381],{}," 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 ",[1197,1198,1199],"em",{},"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.",[14,1202,1203],{},[33,1204,1207,1210,1213,1216,1219,1225,1229,1238,1243,1245,1249,1252,1256,1260,1263,1267,1270,1274,1276,1279,1281,1284],{"viewBox":1205,"role":36,"ariaLabel":1206,"xmlns":38},"0 0 760 254","Comparison of testing with a real memory layer against mocking a layer object, showing that the mock passes even when the code misuses the API",[40,1208,1209],{},"Why a real layer beats a mocked one",[44,1211,1212],{},"A 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.",[48,1214],{"x":50,"y":50,"width":51,"height":1215,"fill":53},"254",[55,1217,1218],{"x":57,"y":58,"style":59,"fill":60,"textAnchor":61},"A mock cannot disagree with you",[48,1220],{"x":542,"y":1221,"width":1222,"height":1223,"rx":69,"fill":1224,"stroke":87,"style":72},"48","348","184","#edf8e9",[55,1226,1228],{"x":1227,"y":1001,"style":76,"fill":87,"textAnchor":61},"194","real memory layer",[48,1230],{"x":1231,"y":1232,"width":1233,"height":557,"rx":1234,"fill":1235,"stroke":1236,"style":1237},"44","90","300","6","#fffdf7","#59645f","stroke-width:1.5",[55,1239,1242],{"x":94,"y":1240,"style":1241,"fill":82},"110","font-size:11px;font-family:sans-serif","wrong field name — KeyError",[48,1244],{"x":1231,"y":86,"width":1233,"height":557,"rx":1234,"fill":1235,"stroke":1236,"style":1237},[55,1246,1248],{"x":94,"y":1247,"style":1241,"fill":82},"146","geometry misuse — real result",[48,1250],{"x":1231,"y":1251,"width":1233,"height":557,"rx":1234,"fill":1235,"stroke":1236,"style":1237},"162",[55,1253,1255],{"x":94,"y":1254,"style":1241,"fill":82},"182","provider behaviour included",[55,1257,1259],{"x":1227,"y":1258,"style":118,"fill":87,"textAnchor":61},"216","the test can fail, so it means something",[48,1261],{"x":1262,"y":1221,"width":1222,"height":1223,"rx":69,"fill":95,"stroke":96,"style":72},"392",[55,1264,1266],{"x":1265,"y":1001,"style":76,"fill":96,"textAnchor":61},"566","mocked layer",[48,1268],{"x":1269,"y":1232,"width":1233,"height":557,"rx":1234,"fill":1235,"stroke":1236,"style":1237},"416",[55,1271,1273],{"x":1272,"y":1240,"style":1241,"fill":82},"432","wrong field name — returns a mock",[48,1275],{"x":1269,"y":86,"width":1233,"height":557,"rx":1234,"fill":1235,"stroke":1236,"style":1237},[55,1277,1278],{"x":1272,"y":1247,"style":1241,"fill":82},"geometry misuse — unnoticed",[48,1280],{"x":1269,"y":1251,"width":1233,"height":557,"rx":1234,"fill":1235,"stroke":1236,"style":1237},[55,1282,1283],{"x":1272,"y":1254,"style":1241,"fill":82},"asserts your own assumptions",[55,1285,1286],{"x":1265,"y":1258,"style":118,"fill":96,"textAnchor":61},"green, and no evidence of anything",[121,1288,1290],{"id":1289},"where-mocking-is-the-right-tool","Where mocking is the right tool",[14,1292,1293],{},"Three cases justify a mock even in a well-structured plugin.",[14,1295,1296,1299],{},[132,1297,1298],{},"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.",[14,1301,1302,1305],{},[132,1303,1304],{},"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.",[14,1307,1308,1311],{},[132,1309,1310],{},"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.",[14,1313,1314],{},"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.",[121,1316,1318],{"id":1317},"qgis-version-compatibility","QGIS version compatibility",[1320,1321,1322,1338],"table",{},[1323,1324,1325],"thead",{},[1326,1327,1328,1332,1335],"tr",{},[1329,1330,1331],"th",{},"QGIS version",[1329,1333,1334],{},"Python",[1329,1336,1337],{},"Notes",[1339,1340,1341,1353,1363,1374],"tbody",{},[1326,1342,1343,1347,1350],{},[1344,1345,1346],"td",{},"3.22 LTR",[1344,1348,1349],{},"3.9",[1344,1351,1352],{},"Memory layers and the interface methods shown are unchanged.",[1326,1354,1355,1358,1360],{},[1344,1356,1357],{},"3.28 LTR",[1344,1359,1349],{},[1344,1361,1362],{},"Identical.",[1326,1364,1365,1368,1371],{},[1344,1366,1367],{},"3.34 LTR",[1344,1369,1370],{},"3.12",[1344,1372,1373],{},"Baseline for this page.",[1326,1375,1376,1379,1381],{},[1344,1377,1378],{},"3.40 \u002F 3.44",[1344,1380,1370],{},[1344,1382,1383,1384,1386],{},"Identical; ",[17,1385,19],{}," gained methods, which only matters if your plugin uses them.",[14,1388,1389,1390,1393,1394,1397,1398,1401],{},"The ",[17,1391,1392],{},"qgis.testing"," module ships ",[17,1395,1396],{},"get_qgis_app()"," and a ",[17,1399,1400],{},"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.",[121,1403,1405],{"id":1404},"troubleshooting","Troubleshooting",[126,1407,1408,1416,1422,1428,1437,1443],{},[129,1409,1410,1415],{},[132,1411,1412,1414],{},[17,1413,977],{}," 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.",[129,1417,1418,1421],{},[132,1419,1420],{},"Tests pass but the plugin fails in QGIS."," The mock returned something the real object never would. Prefer real layers.",[129,1423,1424,1427],{},[132,1425,1426],{},"A test needs a QGIS application."," Initialise one once per session with a fixture; constructing layers needs the providers loaded.",[129,1429,1430,1433,1434,146],{},[132,1431,1432],{},"Memory layer features have empty attributes."," The feature was created without fields. Construct with ",[17,1435,1436],{},"QgsFeature(layer.fields())",[129,1438,1439,1442],{},[132,1440,1441],{},"The test suite is slow."," Something constructs an application per test. Make that fixture session-scoped.",[129,1444,1445,1451],{},[132,1446,1447,1448,1450],{},"Patching ",[17,1449,19],{}," globally breaks other tests."," Inject the interface through the plugin's constructor instead of patching a module attribute.",[121,1453,1455],{"id":1454},"conclusion","Conclusion",[14,1457,1458],{},"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.",[121,1460,1462],{"id":1461},"frequently-asked-questions","Frequently Asked Questions",[14,1464,1465,1472,1473,1475,1476,1478],{},[132,1466,1467,1468,1471],{},"Should I use ",[17,1469,1470],{},"unittest.mock"," or a hand-written fake?","\nA hand-written fake for ",[17,1474,19],{},", because it documents exactly what you depend on. ",[17,1477,1470],{}," is convenient for patching one external call.",[14,1480,1481,1484,1485,1488],{},[132,1482,1483],{},"Do I need a running QGIS to run the tests?","\nYou need an initialised ",[17,1486,1487],{},"QgsApplication",", not a desktop. A session fixture with the offscreen platform is enough, and works in continuous integration.",[14,1490,1491,1494,1495,1498],{},[132,1492,1493],{},"How do I test a dialog?","\nInstantiate it without showing it, set widget values programmatically, and call the handler. ",[17,1496,1497],{},"pytest-qt"," helps when you genuinely need to simulate clicks.",[14,1500,1501,1504,1505,1508,1509,1512,1513,146],{},[132,1502,1503],{},"Can I test a Processing algorithm without the toolbox?","\nYes — construct the algorithm, call ",[17,1506,1507],{},"initAlgorithm()",", and run it through ",[17,1510,1511],{},"processing.run()"," with a plain feedback object. See ",[25,1514,1516],{"href":1515},"\u002Fqgis-plugin-development\u002Fprocessing-provider-plugins\u002Fwrite-custom-processing-algorithm-pyqgis\u002F","Write a Custom Processing Algorithm in PyQGIS",[14,1518,1519,1522],{},[132,1520,1521],{},"What should I assert on the message bar?","\nThat a message was shown and roughly what it was about. Asserting exact translated text makes the test fail in another locale.",[14,1524,1525,1528],{},[132,1526,1527],{},"How much coverage is enough?","\nCover 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.",[121,1530,1532],{"id":1531},"related","Related",[126,1534,1535,1540,1544,1550,1554],{},[129,1536,1537,1539],{},[25,1538,28],{"href":27}," — the guide this recipe belongs to",[129,1541,1542],{},[25,1543,145],{"href":144},[129,1545,1546],{},[25,1547,1549],{"href":1548},"\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Frun-qgis-plugin-tests-in-github-actions\u002F","Run QGIS Plugin Tests in GitHub Actions",[129,1551,1552],{},[25,1553,390],{"href":389},[129,1555,1556],{},[25,1557,1559],{"href":1558},"\u002Fqgis-plugin-development\u002Fplugin-boilerplate-structure\u002F","Plugin Boilerplate and Structure",[1561,1562,1563],"style",{},"html pre.shiki code .sjoCn, html code.shiki .sjoCn{--shiki-default:#9AA79F}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":160,"searchDepth":174,"depth":174,"links":1565},[1566,1567,1568,1569,1570,1571,1572,1573,1574,1575,1576],{"id":123,"depth":174,"text":124},{"id":152,"depth":174,"text":153},{"id":394,"depth":174,"text":395},{"id":644,"depth":174,"text":645},{"id":1032,"depth":174,"text":1033},{"id":1289,"depth":174,"text":1290},{"id":1317,"depth":174,"text":1318},{"id":1404,"depth":174,"text":1405},{"id":1454,"depth":174,"text":1455},{"id":1461,"depth":174,"text":1462},{"id":1531,"depth":174,"text":1532},"Test plugin code that touches iface — build a minimal fake interface, use fixtures for layers and projects, and structure the plugin so most of it needs no mocking at all.","md",{"slug":1580,"type":1581,"breadcrumb":1582,"datePublished":1583,"dateModified":1583},"mock-qgis-interface-in-plugin-tests","article","Mock the Interface","2026-08-15","\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Fmock-qgis-interface-in-plugin-tests",{"title":5,"description":1577},"qgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Fmock-qgis-interface-in-plugin-tests\u002Findex","hEHF2PKfH-tuJptphRikP0RStEuxUuneH9Mnbwt5buA",1786789584662]