[{"data":1,"prerenderedAt":2359},["ShallowReactive",2],{"doc:\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Funit-test-qgis-plugin-with-pytest":3},{"id":4,"title":5,"body":6,"description":2352,"extension":2353,"meta":2354,"navigation":266,"path":2355,"seo":2356,"stem":2357,"__hash__":2358},"docs\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Funit-test-qgis-plugin-with-pytest\u002Findex.md","Unit Test a QGIS Plugin with pytest",{"type":7,"value":8,"toc":2338},"minimark",[9,13,55,62,67,93,100,149,156,160,172,530,552,556,559,746,759,763,769,901,1141,1157,1161,1171,1295,1535,1570,1574,1577,1705,1952,1965,1969,1975,1996,1999,2036,2073,2077,2083,2145,2148,2152,2163,2177,2190,2203,2232,2236,2254,2258,2271,2280,2301,2314,2318,2334],[10,11,5],"h1",{"id":12},"unit-test-a-qgis-plugin-with-pytest",[14,15,16,17,21,22,27,28,31,32,35,36,39,40,43,44,47,48,50,51,54],"p",{},"A QGIS plugin is just Python, but it imports modules that only function inside a running QGIS application. That single fact is why so many plugin authors never write tests — ",[18,19,20],"code",{},"import qgis.core"," from a plain interpreter fails, and it is not obvious how to get past it. This task page, part of ",[23,24,26],"a",{"href":25},"\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002F","Testing & CI for QGIS Plugins",", walks through a complete ",[18,29,30],{},"pytest"," setup: a ",[18,33,34],{},"conftest.py"," that initializes QGIS with ",[18,37,38],{},"qgis.testing.start_app()",", fixtures that build memory ",[18,41,42],{},"QgsVectorLayer"," objects, the practice of testing pure logic apart from ",[18,45,46],{},"iface",", mocking ",[18,49,46],{}," with ",[18,52,53],{},"unittest.mock",", and running it all headlessly.",[14,56,57,58,61],{},"By the end you will have a ",[18,59,60],{},"tests\u002F"," directory you can run with one command and wire into the CI matrix described in the parent cluster.",[63,64,66],"h2",{"id":65},"prerequisites","Prerequisites",[68,69,70,74,81,86],"ul",{},[71,72,73],"li",{},"A plugin whose spatial logic is reasonably separated from GUI code.",[71,75,76,80],{},[77,78,79],"strong",{},"QGIS 3.34 LTR"," installed (bundles Python 3.12) — its Python is the interpreter you run pytest with.",[71,82,83,85],{},[18,84,30],{}," installed into that interpreter.",[71,87,88,89,92],{},"On Linux, ",[18,90,91],{},"xvfb"," for headless runs.",[14,94,95,96,99],{},"Confirm you are using the right Python before anything else. The interpreter that runs your tests must be the one QGIS ships, because that is where the ",[18,97,98],{},"qgis"," package lives.",[101,102,107],"pre",{"className":103,"code":104,"language":105,"meta":106,"style":106},"language-bash shiki shiki-themes github-dark","# Linux: QGIS typically uses the system python3 with qgis on its path\npython3 -c \"import qgis.core; print(qgis.core.Qgis.QGIS_VERSION)\"\n# Windows: use the OSGeo4W shell, then\npython -c \"import qgis.core; print(qgis.core.Qgis.QGIS_VERSION)\"\n","bash","",[18,108,109,118,133,139],{"__ignoreMap":106},[110,111,114],"span",{"class":112,"line":113},"line",1,[110,115,117],{"class":116},"sAwPA","# Linux: QGIS typically uses the system python3 with qgis on its path\n",[110,119,121,125,129],{"class":112,"line":120},2,[110,122,124],{"class":123},"svObZ","python3",[110,126,128],{"class":127},"sDLfK"," -c",[110,130,132],{"class":131},"sU2Wk"," \"import qgis.core; print(qgis.core.Qgis.QGIS_VERSION)\"\n",[110,134,136],{"class":112,"line":135},3,[110,137,138],{"class":116},"# Windows: use the OSGeo4W shell, then\n",[110,140,142,145,147],{"class":112,"line":141},4,[110,143,144],{"class":123},"python",[110,146,128],{"class":127},[110,148,132],{"class":131},[14,150,151,152,155],{},"If that import succeeds, ",[18,153,154],{},"pip install pytest"," into the same environment and proceed.",[63,157,159],{"id":158},"recipe-a-conftestpy-that-boots-qgis","Recipe: A conftest.py That Boots QGIS",[14,161,162,164,165,167,168,171],{},[18,163,34],{}," is loaded by pytest before your test modules, which makes it the right place to initialize QGIS exactly once. ",[18,166,38],{}," creates the ",[18,169,170],{},"QgsApplication",", loads the provider registry, and is safe to call repeatedly.",[101,173,176],{"className":174,"code":175,"language":144,"meta":106,"style":106},"language-python shiki shiki-themes github-dark","# tests\u002Fconftest.py\nimport pytest\nfrom qgis.testing import start_app\nfrom qgis.core import (\n    QgsVectorLayer,\n    QgsFeature,\n    QgsGeometry,\n    QgsField,\n)\nfrom qgis.PyQt.QtCore import QVariant\n\n# Boot QGIS once for the whole session, before any fixture runs.\nQGIS_APP = start_app()\n\n\n@pytest.fixture\ndef point_layer():\n    \"\"\"Memory point layer with three cities and a population field.\"\"\"\n    layer = QgsVectorLayer(\n        \"Point?crs=EPSG:4326&field=name:string&field=pop:integer\",\n        \"cities\",\n        \"memory\",\n    )\n    provider = layer.dataProvider()\n    rows = [\n        (\"Lisbon\", 545000, \"POINT(-9.139 38.722)\"),\n        (\"Porto\", 237000, \"POINT(-8.611 41.150)\"),\n        (\"Faro\", 64000, \"POINT(-7.930 37.019)\"),\n    ]\n    features = []\n    for name, pop, wkt in rows:\n        feat = QgsFeature(layer.fields())\n        feat.setAttributes([name, pop])\n        feat.setGeometry(QgsGeometry.fromWkt(wkt))\n        features.append(feat)\n    provider.addFeatures(features)\n    layer.updateExtents()\n    return layer\n",[18,177,178,183,193,206,218,224,230,236,242,248,261,268,274,286,291,296,302,314,320,332,341,349,357,363,374,385,408,428,448,454,465,480,491,497,503,509,515,521],{"__ignoreMap":106},[110,179,180],{"class":112,"line":113},[110,181,182],{"class":116},"# tests\u002Fconftest.py\n",[110,184,185,189],{"class":112,"line":120},[110,186,188],{"class":187},"snl16","import",[110,190,192],{"class":191},"s95oV"," pytest\n",[110,194,195,198,201,203],{"class":112,"line":135},[110,196,197],{"class":187},"from",[110,199,200],{"class":191}," qgis.testing ",[110,202,188],{"class":187},[110,204,205],{"class":191}," start_app\n",[110,207,208,210,213,215],{"class":112,"line":141},[110,209,197],{"class":187},[110,211,212],{"class":191}," qgis.core ",[110,214,188],{"class":187},[110,216,217],{"class":191}," (\n",[110,219,221],{"class":112,"line":220},5,[110,222,223],{"class":191},"    QgsVectorLayer,\n",[110,225,227],{"class":112,"line":226},6,[110,228,229],{"class":191},"    QgsFeature,\n",[110,231,233],{"class":112,"line":232},7,[110,234,235],{"class":191},"    QgsGeometry,\n",[110,237,239],{"class":112,"line":238},8,[110,240,241],{"class":191},"    QgsField,\n",[110,243,245],{"class":112,"line":244},9,[110,246,247],{"class":191},")\n",[110,249,251,253,256,258],{"class":112,"line":250},10,[110,252,197],{"class":187},[110,254,255],{"class":191}," qgis.PyQt.QtCore ",[110,257,188],{"class":187},[110,259,260],{"class":191}," QVariant\n",[110,262,264],{"class":112,"line":263},11,[110,265,267],{"emptyLinePlaceholder":266},true,"\n",[110,269,271],{"class":112,"line":270},12,[110,272,273],{"class":116},"# Boot QGIS once for the whole session, before any fixture runs.\n",[110,275,277,280,283],{"class":112,"line":276},13,[110,278,279],{"class":127},"QGIS_APP",[110,281,282],{"class":187}," =",[110,284,285],{"class":191}," start_app()\n",[110,287,289],{"class":112,"line":288},14,[110,290,267],{"emptyLinePlaceholder":266},[110,292,294],{"class":112,"line":293},15,[110,295,267],{"emptyLinePlaceholder":266},[110,297,299],{"class":112,"line":298},16,[110,300,301],{"class":123},"@pytest.fixture\n",[110,303,305,308,311],{"class":112,"line":304},17,[110,306,307],{"class":187},"def",[110,309,310],{"class":123}," point_layer",[110,312,313],{"class":191},"():\n",[110,315,317],{"class":112,"line":316},18,[110,318,319],{"class":131},"    \"\"\"Memory point layer with three cities and a population field.\"\"\"\n",[110,321,323,326,329],{"class":112,"line":322},19,[110,324,325],{"class":191},"    layer ",[110,327,328],{"class":187},"=",[110,330,331],{"class":191}," QgsVectorLayer(\n",[110,333,335,338],{"class":112,"line":334},20,[110,336,337],{"class":131},"        \"Point?crs=EPSG:4326&field=name:string&field=pop:integer\"",[110,339,340],{"class":191},",\n",[110,342,344,347],{"class":112,"line":343},21,[110,345,346],{"class":131},"        \"cities\"",[110,348,340],{"class":191},[110,350,352,355],{"class":112,"line":351},22,[110,353,354],{"class":131},"        \"memory\"",[110,356,340],{"class":191},[110,358,360],{"class":112,"line":359},23,[110,361,362],{"class":191},"    )\n",[110,364,366,369,371],{"class":112,"line":365},24,[110,367,368],{"class":191},"    provider ",[110,370,328],{"class":187},[110,372,373],{"class":191}," layer.dataProvider()\n",[110,375,377,380,382],{"class":112,"line":376},25,[110,378,379],{"class":191},"    rows ",[110,381,328],{"class":187},[110,383,384],{"class":191}," [\n",[110,386,388,391,394,397,400,402,405],{"class":112,"line":387},26,[110,389,390],{"class":191},"        (",[110,392,393],{"class":131},"\"Lisbon\"",[110,395,396],{"class":191},", ",[110,398,399],{"class":127},"545000",[110,401,396],{"class":191},[110,403,404],{"class":131},"\"POINT(-9.139 38.722)\"",[110,406,407],{"class":191},"),\n",[110,409,411,413,416,418,421,423,426],{"class":112,"line":410},27,[110,412,390],{"class":191},[110,414,415],{"class":131},"\"Porto\"",[110,417,396],{"class":191},[110,419,420],{"class":127},"237000",[110,422,396],{"class":191},[110,424,425],{"class":131},"\"POINT(-8.611 41.150)\"",[110,427,407],{"class":191},[110,429,431,433,436,438,441,443,446],{"class":112,"line":430},28,[110,432,390],{"class":191},[110,434,435],{"class":131},"\"Faro\"",[110,437,396],{"class":191},[110,439,440],{"class":127},"64000",[110,442,396],{"class":191},[110,444,445],{"class":131},"\"POINT(-7.930 37.019)\"",[110,447,407],{"class":191},[110,449,451],{"class":112,"line":450},29,[110,452,453],{"class":191},"    ]\n",[110,455,457,460,462],{"class":112,"line":456},30,[110,458,459],{"class":191},"    features ",[110,461,328],{"class":187},[110,463,464],{"class":191}," []\n",[110,466,468,471,474,477],{"class":112,"line":467},31,[110,469,470],{"class":187},"    for",[110,472,473],{"class":191}," name, pop, wkt ",[110,475,476],{"class":187},"in",[110,478,479],{"class":191}," rows:\n",[110,481,483,486,488],{"class":112,"line":482},32,[110,484,485],{"class":191},"        feat ",[110,487,328],{"class":187},[110,489,490],{"class":191}," QgsFeature(layer.fields())\n",[110,492,494],{"class":112,"line":493},33,[110,495,496],{"class":191},"        feat.setAttributes([name, pop])\n",[110,498,500],{"class":112,"line":499},34,[110,501,502],{"class":191},"        feat.setGeometry(QgsGeometry.fromWkt(wkt))\n",[110,504,506],{"class":112,"line":505},35,[110,507,508],{"class":191},"        features.append(feat)\n",[110,510,512],{"class":112,"line":511},36,[110,513,514],{"class":191},"    provider.addFeatures(features)\n",[110,516,518],{"class":112,"line":517},37,[110,519,520],{"class":191},"    layer.updateExtents()\n",[110,522,524,527],{"class":112,"line":523},38,[110,525,526],{"class":187},"    return",[110,528,529],{"class":191}," layer\n",[14,531,532,535,536,539,540,543,544,547,548,551],{},[77,533,534],{},"Breakdown:"," Calling ",[18,537,538],{},"start_app()"," at module level guarantees QGIS is ready before pytest collects any fixture. The layer URI declares two fields inline, so ",[18,541,542],{},"QgsFeature(layer.fields())"," produces features with the correct attribute schema. ",[18,545,546],{},"setAttributes"," matches that schema positionally, and ",[18,549,550],{},"fromWkt"," parses well-known text into geometry. Because nothing touches disk, the fixture is fast and isolated — each test gets a fresh layer.",[63,553,555],{"id":554},"recipe-a-reusable-empty-layer-fixture","Recipe: A Reusable Empty-Layer Fixture",[14,557,558],{},"For tests that build their own features, a parametrizable empty-layer factory is more flexible than a fixed dataset.",[101,560,562],{"className":174,"code":561,"language":144,"meta":106,"style":106},"# add to tests\u002Fconftest.py\n@pytest.fixture\ndef memory_layer_factory():\n    \"\"\"Return a factory that builds an empty memory layer of a given type.\"\"\"\n    created = []\n\n    def _make(geometry=\"Polygon\", crs=\"EPSG:3857\", fields=\"field=id:integer\"):\n        uri = f\"{geometry}?crs={crs}&{fields}\"\n        layer = QgsVectorLayer(uri, \"scratch\", \"memory\")\n        assert layer.isValid(), f\"invalid memory layer URI: {uri}\"\n        created.append(layer)\n        return layer\n\n    return _make\n",[18,563,564,569,573,582,587,596,600,635,680,700,723,728,735,739],{"__ignoreMap":106},[110,565,566],{"class":112,"line":113},[110,567,568],{"class":116},"# add to tests\u002Fconftest.py\n",[110,570,571],{"class":112,"line":120},[110,572,301],{"class":123},[110,574,575,577,580],{"class":112,"line":135},[110,576,307],{"class":187},[110,578,579],{"class":123}," memory_layer_factory",[110,581,313],{"class":191},[110,583,584],{"class":112,"line":141},[110,585,586],{"class":131},"    \"\"\"Return a factory that builds an empty memory layer of a given type.\"\"\"\n",[110,588,589,592,594],{"class":112,"line":220},[110,590,591],{"class":191},"    created ",[110,593,328],{"class":187},[110,595,464],{"class":191},[110,597,598],{"class":112,"line":226},[110,599,267],{"emptyLinePlaceholder":266},[110,601,602,605,608,611,613,616,619,621,624,627,629,632],{"class":112,"line":232},[110,603,604],{"class":187},"    def",[110,606,607],{"class":123}," _make",[110,609,610],{"class":191},"(geometry",[110,612,328],{"class":187},[110,614,615],{"class":131},"\"Polygon\"",[110,617,618],{"class":191},", crs",[110,620,328],{"class":187},[110,622,623],{"class":131},"\"EPSG:3857\"",[110,625,626],{"class":191},", fields",[110,628,328],{"class":187},[110,630,631],{"class":131},"\"field=id:integer\"",[110,633,634],{"class":191},"):\n",[110,636,637,640,642,645,648,651,654,657,660,662,665,667,670,672,675,677],{"class":112,"line":238},[110,638,639],{"class":191},"        uri ",[110,641,328],{"class":187},[110,643,644],{"class":187}," f",[110,646,647],{"class":131},"\"",[110,649,650],{"class":127},"{",[110,652,653],{"class":191},"geometry",[110,655,656],{"class":127},"}",[110,658,659],{"class":131},"?crs=",[110,661,650],{"class":127},[110,663,664],{"class":191},"crs",[110,666,656],{"class":127},[110,668,669],{"class":131},"&",[110,671,650],{"class":127},[110,673,674],{"class":191},"fields",[110,676,656],{"class":127},[110,678,679],{"class":131},"\"\n",[110,681,682,685,687,690,693,695,698],{"class":112,"line":244},[110,683,684],{"class":191},"        layer ",[110,686,328],{"class":187},[110,688,689],{"class":191}," QgsVectorLayer(uri, ",[110,691,692],{"class":131},"\"scratch\"",[110,694,396],{"class":191},[110,696,697],{"class":131},"\"memory\"",[110,699,247],{"class":191},[110,701,702,705,708,711,714,716,719,721],{"class":112,"line":250},[110,703,704],{"class":187},"        assert",[110,706,707],{"class":191}," layer.isValid(), ",[110,709,710],{"class":187},"f",[110,712,713],{"class":131},"\"invalid memory layer URI: ",[110,715,650],{"class":127},[110,717,718],{"class":191},"uri",[110,720,656],{"class":127},[110,722,679],{"class":131},[110,724,725],{"class":112,"line":263},[110,726,727],{"class":191},"        created.append(layer)\n",[110,729,730,733],{"class":112,"line":270},[110,731,732],{"class":187},"        return",[110,734,529],{"class":191},[110,736,737],{"class":112,"line":276},[110,738,267],{"emptyLinePlaceholder":266},[110,740,741,743],{"class":112,"line":288},[110,742,526],{"class":187},[110,744,745],{"class":191}," _make\n",[14,747,748,750,751,754,755,758],{},[77,749,534],{}," The inner ",[18,752,753],{},"_make"," function lets each test request precisely the layer it needs — a polygon layer in EPSG:3857, a line layer in EPSG:4326, and so on — without duplicating boilerplate. The ",[18,756,757],{},"assert layer.isValid()"," fails loudly if a URI is malformed, turning a silent empty layer into an obvious test error. Returning a callable from a fixture is the standard pytest pattern for parameterized resource creation.",[63,760,762],{"id":761},"recipe-testing-pure-logic","Recipe: Testing Pure Logic",[14,764,765,766,768],{},"The most valuable tests target plain functions that take QGIS objects and return results, with no ",[18,767,46],{}," involved. Suppose your plugin filters features by an attribute threshold:",[101,770,772],{"className":174,"code":771,"language":144,"meta":106,"style":106},"# selection.py — production logic, no iface\nfrom qgis.core import QgsVectorLayer\n\n\ndef names_above_population(layer: QgsVectorLayer, minimum: int) -> list[str]:\n    \"\"\"Return names of features whose 'pop' attribute meets the minimum.\"\"\"\n    return sorted(\n        feat[\"name\"]\n        for feat in layer.getFeatures()\n        if feat[\"pop\"] is not None and feat[\"pop\"] >= minimum\n    )\n",[18,773,774,779,790,794,798,820,825,835,846,859,897],{"__ignoreMap":106},[110,775,776],{"class":112,"line":113},[110,777,778],{"class":116},"# selection.py — production logic, no iface\n",[110,780,781,783,785,787],{"class":112,"line":120},[110,782,197],{"class":187},[110,784,212],{"class":191},[110,786,188],{"class":187},[110,788,789],{"class":191}," QgsVectorLayer\n",[110,791,792],{"class":112,"line":135},[110,793,267],{"emptyLinePlaceholder":266},[110,795,796],{"class":112,"line":141},[110,797,267],{"emptyLinePlaceholder":266},[110,799,800,802,805,808,811,814,817],{"class":112,"line":220},[110,801,307],{"class":187},[110,803,804],{"class":123}," names_above_population",[110,806,807],{"class":191},"(layer: QgsVectorLayer, minimum: ",[110,809,810],{"class":127},"int",[110,812,813],{"class":191},") -> list[",[110,815,816],{"class":127},"str",[110,818,819],{"class":191},"]:\n",[110,821,822],{"class":112,"line":226},[110,823,824],{"class":131},"    \"\"\"Return names of features whose 'pop' attribute meets the minimum.\"\"\"\n",[110,826,827,829,832],{"class":112,"line":232},[110,828,526],{"class":187},[110,830,831],{"class":127}," sorted",[110,833,834],{"class":191},"(\n",[110,836,837,840,843],{"class":112,"line":238},[110,838,839],{"class":191},"        feat[",[110,841,842],{"class":131},"\"name\"",[110,844,845],{"class":191},"]\n",[110,847,848,851,854,856],{"class":112,"line":244},[110,849,850],{"class":187},"        for",[110,852,853],{"class":191}," feat ",[110,855,476],{"class":187},[110,857,858],{"class":191}," layer.getFeatures()\n",[110,860,861,864,867,870,873,876,879,882,885,887,889,891,894],{"class":112,"line":250},[110,862,863],{"class":187},"        if",[110,865,866],{"class":191}," feat[",[110,868,869],{"class":131},"\"pop\"",[110,871,872],{"class":191},"] ",[110,874,875],{"class":187},"is",[110,877,878],{"class":187}," not",[110,880,881],{"class":127}," None",[110,883,884],{"class":187}," and",[110,886,866],{"class":191},[110,888,869],{"class":131},[110,890,872],{"class":191},[110,892,893],{"class":187},">=",[110,895,896],{"class":191}," minimum\n",[110,898,899],{"class":112,"line":263},[110,900,362],{"class":191},[101,902,904],{"className":174,"code":903,"language":144,"meta":106,"style":106},"# tests\u002Ftest_selection.py\nfrom selection import names_above_population\n\n\ndef test_filters_by_population(point_layer):\n    assert names_above_population(point_layer, 100000) == [\"Lisbon\", \"Porto\"]\n\n\ndef test_inclusive_threshold(point_layer):\n    # Porto has exactly 237000 — boundary must be included\n    assert \"Porto\" in names_above_population(point_layer, 237000)\n\n\ndef test_none_population_is_skipped(memory_layer_factory):\n    from qgis.core import QgsFeature, QgsGeometry\n    layer = memory_layer_factory(\n        geometry=\"Point\", crs=\"EPSG:4326\",\n        fields=\"field=name:string&field=pop:integer\",\n    )\n    feat = QgsFeature(layer.fields())\n    feat.setAttributes([\"Unknown\", None])\n    feat.setGeometry(QgsGeometry.fromWkt(\"POINT(0 0)\"))\n    layer.dataProvider().addFeatures([feat])\n    assert names_above_population(layer, 1) == []\n",[18,905,906,911,923,927,931,941,969,973,977,986,991,1007,1011,1015,1025,1037,1046,1068,1080,1084,1093,1109,1120,1125],{"__ignoreMap":106},[110,907,908],{"class":112,"line":113},[110,909,910],{"class":116},"# tests\u002Ftest_selection.py\n",[110,912,913,915,918,920],{"class":112,"line":120},[110,914,197],{"class":187},[110,916,917],{"class":191}," selection ",[110,919,188],{"class":187},[110,921,922],{"class":191}," names_above_population\n",[110,924,925],{"class":112,"line":135},[110,926,267],{"emptyLinePlaceholder":266},[110,928,929],{"class":112,"line":141},[110,930,267],{"emptyLinePlaceholder":266},[110,932,933,935,938],{"class":112,"line":220},[110,934,307],{"class":187},[110,936,937],{"class":123}," test_filters_by_population",[110,939,940],{"class":191},"(point_layer):\n",[110,942,943,946,949,952,955,958,961,963,965,967],{"class":112,"line":226},[110,944,945],{"class":187},"    assert",[110,947,948],{"class":191}," names_above_population(point_layer, ",[110,950,951],{"class":127},"100000",[110,953,954],{"class":191},") ",[110,956,957],{"class":187},"==",[110,959,960],{"class":191}," [",[110,962,393],{"class":131},[110,964,396],{"class":191},[110,966,415],{"class":131},[110,968,845],{"class":191},[110,970,971],{"class":112,"line":232},[110,972,267],{"emptyLinePlaceholder":266},[110,974,975],{"class":112,"line":238},[110,976,267],{"emptyLinePlaceholder":266},[110,978,979,981,984],{"class":112,"line":244},[110,980,307],{"class":187},[110,982,983],{"class":123}," test_inclusive_threshold",[110,985,940],{"class":191},[110,987,988],{"class":112,"line":250},[110,989,990],{"class":116},"    # Porto has exactly 237000 — boundary must be included\n",[110,992,993,995,998,1001,1003,1005],{"class":112,"line":263},[110,994,945],{"class":187},[110,996,997],{"class":131}," \"Porto\"",[110,999,1000],{"class":187}," in",[110,1002,948],{"class":191},[110,1004,420],{"class":127},[110,1006,247],{"class":191},[110,1008,1009],{"class":112,"line":270},[110,1010,267],{"emptyLinePlaceholder":266},[110,1012,1013],{"class":112,"line":276},[110,1014,267],{"emptyLinePlaceholder":266},[110,1016,1017,1019,1022],{"class":112,"line":288},[110,1018,307],{"class":187},[110,1020,1021],{"class":123}," test_none_population_is_skipped",[110,1023,1024],{"class":191},"(memory_layer_factory):\n",[110,1026,1027,1030,1032,1034],{"class":112,"line":293},[110,1028,1029],{"class":187},"    from",[110,1031,212],{"class":191},[110,1033,188],{"class":187},[110,1035,1036],{"class":191}," QgsFeature, QgsGeometry\n",[110,1038,1039,1041,1043],{"class":112,"line":298},[110,1040,325],{"class":191},[110,1042,328],{"class":187},[110,1044,1045],{"class":191}," memory_layer_factory(\n",[110,1047,1048,1052,1054,1057,1059,1061,1063,1066],{"class":112,"line":304},[110,1049,1051],{"class":1050},"s9osk","        geometry",[110,1053,328],{"class":187},[110,1055,1056],{"class":131},"\"Point\"",[110,1058,396],{"class":191},[110,1060,664],{"class":1050},[110,1062,328],{"class":187},[110,1064,1065],{"class":131},"\"EPSG:4326\"",[110,1067,340],{"class":191},[110,1069,1070,1073,1075,1078],{"class":112,"line":316},[110,1071,1072],{"class":1050},"        fields",[110,1074,328],{"class":187},[110,1076,1077],{"class":131},"\"field=name:string&field=pop:integer\"",[110,1079,340],{"class":191},[110,1081,1082],{"class":112,"line":322},[110,1083,362],{"class":191},[110,1085,1086,1089,1091],{"class":112,"line":334},[110,1087,1088],{"class":191},"    feat ",[110,1090,328],{"class":187},[110,1092,490],{"class":191},[110,1094,1095,1098,1101,1103,1106],{"class":112,"line":343},[110,1096,1097],{"class":191},"    feat.setAttributes([",[110,1099,1100],{"class":131},"\"Unknown\"",[110,1102,396],{"class":191},[110,1104,1105],{"class":127},"None",[110,1107,1108],{"class":191},"])\n",[110,1110,1111,1114,1117],{"class":112,"line":351},[110,1112,1113],{"class":191},"    feat.setGeometry(QgsGeometry.fromWkt(",[110,1115,1116],{"class":131},"\"POINT(0 0)\"",[110,1118,1119],{"class":191},"))\n",[110,1121,1122],{"class":112,"line":359},[110,1123,1124],{"class":191},"    layer.dataProvider().addFeatures([feat])\n",[110,1126,1127,1129,1132,1135,1137,1139],{"class":112,"line":365},[110,1128,945],{"class":187},[110,1130,1131],{"class":191}," names_above_population(layer, ",[110,1133,1134],{"class":127},"1",[110,1136,954],{"class":191},[110,1138,957],{"class":187},[110,1140,464],{"class":191},[14,1142,1143,1145,1146,1148,1149,1152,1153,1156],{},[77,1144,534],{}," The three tests cover a typical filter, the inclusive boundary (",[18,1147,893],{},"), and the null-handling path — the cases that actually break in production. Using the shared ",[18,1150,1151],{},"point_layer"," fixture for the first two keeps them terse, while the third uses ",[18,1154,1155],{},"memory_layer_factory"," to construct an edge-case dataset with a NULL attribute. Sorting the output makes assertions order-independent and stable.",[63,1158,1160],{"id":1159},"recipe-mocking-iface","Recipe: Mocking iface",[14,1162,1163,1164,1166,1167,1170],{},"Code that reads the active layer or pushes messages needs ",[18,1165,46],{},", which does not exist in tests. Replace it with a ",[18,1168,1169],{},"MagicMock",".",[101,1172,1174],{"className":174,"code":1173,"language":144,"meta":106,"style":106},"# notify.py — production code that touches iface\nfrom qgis.core import Qgis\n\n\ndef export_active_layer(iface, exporter):\n    \"\"\"Export the active layer; warn via the message bar if none is selected.\"\"\"\n    layer = iface.activeLayer()\n    if layer is None:\n        iface.messageBar().pushMessage(\n            \"Export\", \"No active layer to export.\",\n            level=Qgis.Warning, duration=4,\n        )\n        return None\n    return exporter(layer)\n",[18,1175,1176,1181,1192,1196,1200,1210,1215,1224,1239,1244,1256,1276,1281,1288],{"__ignoreMap":106},[110,1177,1178],{"class":112,"line":113},[110,1179,1180],{"class":116},"# notify.py — production code that touches iface\n",[110,1182,1183,1185,1187,1189],{"class":112,"line":120},[110,1184,197],{"class":187},[110,1186,212],{"class":191},[110,1188,188],{"class":187},[110,1190,1191],{"class":191}," Qgis\n",[110,1193,1194],{"class":112,"line":135},[110,1195,267],{"emptyLinePlaceholder":266},[110,1197,1198],{"class":112,"line":141},[110,1199,267],{"emptyLinePlaceholder":266},[110,1201,1202,1204,1207],{"class":112,"line":220},[110,1203,307],{"class":187},[110,1205,1206],{"class":123}," export_active_layer",[110,1208,1209],{"class":191},"(iface, exporter):\n",[110,1211,1212],{"class":112,"line":226},[110,1213,1214],{"class":131},"    \"\"\"Export the active layer; warn via the message bar if none is selected.\"\"\"\n",[110,1216,1217,1219,1221],{"class":112,"line":232},[110,1218,325],{"class":191},[110,1220,328],{"class":187},[110,1222,1223],{"class":191}," iface.activeLayer()\n",[110,1225,1226,1229,1232,1234,1236],{"class":112,"line":238},[110,1227,1228],{"class":187},"    if",[110,1230,1231],{"class":191}," layer ",[110,1233,875],{"class":187},[110,1235,881],{"class":127},[110,1237,1238],{"class":191},":\n",[110,1240,1241],{"class":112,"line":244},[110,1242,1243],{"class":191},"        iface.messageBar().pushMessage(\n",[110,1245,1246,1249,1251,1254],{"class":112,"line":250},[110,1247,1248],{"class":131},"            \"Export\"",[110,1250,396],{"class":191},[110,1252,1253],{"class":131},"\"No active layer to export.\"",[110,1255,340],{"class":191},[110,1257,1258,1261,1263,1266,1269,1271,1274],{"class":112,"line":263},[110,1259,1260],{"class":1050},"            level",[110,1262,328],{"class":187},[110,1264,1265],{"class":191},"Qgis.Warning, ",[110,1267,1268],{"class":1050},"duration",[110,1270,328],{"class":187},[110,1272,1273],{"class":127},"4",[110,1275,340],{"class":191},[110,1277,1278],{"class":112,"line":270},[110,1279,1280],{"class":191},"        )\n",[110,1282,1283,1285],{"class":112,"line":276},[110,1284,732],{"class":187},[110,1286,1287],{"class":127}," None\n",[110,1289,1290,1292],{"class":112,"line":288},[110,1291,526],{"class":187},[110,1293,1294],{"class":191}," exporter(layer)\n",[101,1296,1298],{"className":174,"code":1297,"language":144,"meta":106,"style":106},"# tests\u002Ftest_notify.py\nfrom unittest.mock import MagicMock\nfrom qgis.core import Qgis\nfrom notify import export_active_layer\n\n\ndef test_warns_when_no_active_layer():\n    iface = MagicMock()\n    iface.activeLayer.return_value = None\n    exporter = MagicMock()\n\n    result = export_active_layer(iface, exporter)\n\n    assert result is None\n    exporter.assert_not_called()\n    iface.messageBar().pushMessage.assert_called_once()\n    _, kwargs = iface.messageBar().pushMessage.call_args\n    assert kwargs[\"level\"] == Qgis.Warning\n\n\ndef test_exports_when_layer_present(point_layer):\n    iface = MagicMock()\n    iface.activeLayer.return_value = point_layer\n    exporter = MagicMock(return_value=\"\u002Ftmp\u002Fout.gpkg\")\n\n    result = export_active_layer(iface, exporter)\n\n    assert result == \"\u002Ftmp\u002Fout.gpkg\"\n    exporter.assert_called_once_with(point_layer)\n",[18,1299,1300,1305,1317,1327,1339,1343,1347,1356,1366,1375,1384,1388,1398,1402,1413,1418,1423,1433,1450,1454,1458,1467,1475,1484,1503,1507,1515,1519,1530],{"__ignoreMap":106},[110,1301,1302],{"class":112,"line":113},[110,1303,1304],{"class":116},"# tests\u002Ftest_notify.py\n",[110,1306,1307,1309,1312,1314],{"class":112,"line":120},[110,1308,197],{"class":187},[110,1310,1311],{"class":191}," unittest.mock ",[110,1313,188],{"class":187},[110,1315,1316],{"class":191}," MagicMock\n",[110,1318,1319,1321,1323,1325],{"class":112,"line":135},[110,1320,197],{"class":187},[110,1322,212],{"class":191},[110,1324,188],{"class":187},[110,1326,1191],{"class":191},[110,1328,1329,1331,1334,1336],{"class":112,"line":141},[110,1330,197],{"class":187},[110,1332,1333],{"class":191}," notify ",[110,1335,188],{"class":187},[110,1337,1338],{"class":191}," export_active_layer\n",[110,1340,1341],{"class":112,"line":220},[110,1342,267],{"emptyLinePlaceholder":266},[110,1344,1345],{"class":112,"line":226},[110,1346,267],{"emptyLinePlaceholder":266},[110,1348,1349,1351,1354],{"class":112,"line":232},[110,1350,307],{"class":187},[110,1352,1353],{"class":123}," test_warns_when_no_active_layer",[110,1355,313],{"class":191},[110,1357,1358,1361,1363],{"class":112,"line":238},[110,1359,1360],{"class":191},"    iface ",[110,1362,328],{"class":187},[110,1364,1365],{"class":191}," MagicMock()\n",[110,1367,1368,1371,1373],{"class":112,"line":244},[110,1369,1370],{"class":191},"    iface.activeLayer.return_value ",[110,1372,328],{"class":187},[110,1374,1287],{"class":127},[110,1376,1377,1380,1382],{"class":112,"line":250},[110,1378,1379],{"class":191},"    exporter ",[110,1381,328],{"class":187},[110,1383,1365],{"class":191},[110,1385,1386],{"class":112,"line":263},[110,1387,267],{"emptyLinePlaceholder":266},[110,1389,1390,1393,1395],{"class":112,"line":270},[110,1391,1392],{"class":191},"    result ",[110,1394,328],{"class":187},[110,1396,1397],{"class":191}," export_active_layer(iface, exporter)\n",[110,1399,1400],{"class":112,"line":276},[110,1401,267],{"emptyLinePlaceholder":266},[110,1403,1404,1406,1409,1411],{"class":112,"line":288},[110,1405,945],{"class":187},[110,1407,1408],{"class":191}," result ",[110,1410,875],{"class":187},[110,1412,1287],{"class":127},[110,1414,1415],{"class":112,"line":293},[110,1416,1417],{"class":191},"    exporter.assert_not_called()\n",[110,1419,1420],{"class":112,"line":298},[110,1421,1422],{"class":191},"    iface.messageBar().pushMessage.assert_called_once()\n",[110,1424,1425,1428,1430],{"class":112,"line":304},[110,1426,1427],{"class":191},"    _, kwargs ",[110,1429,328],{"class":187},[110,1431,1432],{"class":191}," iface.messageBar().pushMessage.call_args\n",[110,1434,1435,1437,1440,1443,1445,1447],{"class":112,"line":316},[110,1436,945],{"class":187},[110,1438,1439],{"class":191}," kwargs[",[110,1441,1442],{"class":131},"\"level\"",[110,1444,872],{"class":191},[110,1446,957],{"class":187},[110,1448,1449],{"class":191}," Qgis.Warning\n",[110,1451,1452],{"class":112,"line":322},[110,1453,267],{"emptyLinePlaceholder":266},[110,1455,1456],{"class":112,"line":334},[110,1457,267],{"emptyLinePlaceholder":266},[110,1459,1460,1462,1465],{"class":112,"line":343},[110,1461,307],{"class":187},[110,1463,1464],{"class":123}," test_exports_when_layer_present",[110,1466,940],{"class":191},[110,1468,1469,1471,1473],{"class":112,"line":351},[110,1470,1360],{"class":191},[110,1472,328],{"class":187},[110,1474,1365],{"class":191},[110,1476,1477,1479,1481],{"class":112,"line":359},[110,1478,1370],{"class":191},[110,1480,328],{"class":187},[110,1482,1483],{"class":191}," point_layer\n",[110,1485,1486,1488,1490,1493,1496,1498,1501],{"class":112,"line":365},[110,1487,1379],{"class":191},[110,1489,328],{"class":187},[110,1491,1492],{"class":191}," MagicMock(",[110,1494,1495],{"class":1050},"return_value",[110,1497,328],{"class":187},[110,1499,1500],{"class":131},"\"\u002Ftmp\u002Fout.gpkg\"",[110,1502,247],{"class":191},[110,1504,1505],{"class":112,"line":376},[110,1506,267],{"emptyLinePlaceholder":266},[110,1508,1509,1511,1513],{"class":112,"line":387},[110,1510,1392],{"class":191},[110,1512,328],{"class":187},[110,1514,1397],{"class":191},[110,1516,1517],{"class":112,"line":410},[110,1518,267],{"emptyLinePlaceholder":266},[110,1520,1521,1523,1525,1527],{"class":112,"line":430},[110,1522,945],{"class":187},[110,1524,1408],{"class":191},[110,1526,957],{"class":187},[110,1528,1529],{"class":131}," \"\u002Ftmp\u002Fout.gpkg\"\n",[110,1531,1532],{"class":112,"line":450},[110,1533,1534],{"class":191},"    exporter.assert_called_once_with(point_layer)\n",[14,1536,1537,1539,1540,1542,1543,1546,1547,1549,1550,1553,1554,1557,1558,1561,1562,1564,1565,1567,1568,1170],{},[77,1538,534],{}," Injecting ",[18,1541,46],{}," and ",[18,1544,1545],{},"exporter"," as parameters (dependency injection) is what makes this testable — the function never reaches for a global. ",[18,1548,1169],{}," fabricates ",[18,1551,1552],{},"iface.messageBar().pushMessage"," on demand, and ",[18,1555,1556],{},"assert_called_once","\u002F",[18,1559,1560],{},"assert_not_called"," verify the branching. The second test wires a real ",[18,1563,1151],{}," fixture into the mocked ",[18,1566,46],{},", proving the happy path forwards the actual layer to the exporter. This is the same mocking pattern introduced in ",[23,1569,26],{"href":25},[63,1571,1573],{"id":1572},"recipe-testing-code-that-edits-a-layer","Recipe: Testing Code That Edits a Layer",[14,1575,1576],{},"Plenty of plugin logic mutates a layer — adding a field, recomputing attributes, fixing geometries. Memory layers fully support editing, so you can test the round trip end to end without writing files.",[101,1578,1580],{"className":174,"code":1579,"language":144,"meta":106,"style":106},"# attribution.py — production logic that adds and fills a field\nfrom qgis.core import QgsVectorLayer, QgsField, edit\nfrom qgis.PyQt.QtCore import QVariant\n\n\ndef add_area_field(layer: QgsVectorLayer, field_name: str = \"area_m2\") -> None:\n    \"\"\"Add a double field and populate it with each feature's geometry area.\"\"\"\n    if layer.fields().indexOf(field_name) == -1:\n        layer.dataProvider().addAttributes([QgsField(field_name, QVariant.Double)])\n        layer.updateFields()\n    with edit(layer):\n        for feat in layer.getFeatures():\n            feat[field_name] = feat.geometry().area()\n            layer.updateFeature(feat)\n",[18,1581,1582,1587,1598,1608,1612,1616,1640,1645,1661,1666,1671,1679,1690,1700],{"__ignoreMap":106},[110,1583,1584],{"class":112,"line":113},[110,1585,1586],{"class":116},"# attribution.py — production logic that adds and fills a field\n",[110,1588,1589,1591,1593,1595],{"class":112,"line":120},[110,1590,197],{"class":187},[110,1592,212],{"class":191},[110,1594,188],{"class":187},[110,1596,1597],{"class":191}," QgsVectorLayer, QgsField, edit\n",[110,1599,1600,1602,1604,1606],{"class":112,"line":135},[110,1601,197],{"class":187},[110,1603,255],{"class":191},[110,1605,188],{"class":187},[110,1607,260],{"class":191},[110,1609,1610],{"class":112,"line":141},[110,1611,267],{"emptyLinePlaceholder":266},[110,1613,1614],{"class":112,"line":220},[110,1615,267],{"emptyLinePlaceholder":266},[110,1617,1618,1620,1623,1626,1628,1630,1633,1636,1638],{"class":112,"line":226},[110,1619,307],{"class":187},[110,1621,1622],{"class":123}," add_area_field",[110,1624,1625],{"class":191},"(layer: QgsVectorLayer, field_name: ",[110,1627,816],{"class":127},[110,1629,282],{"class":187},[110,1631,1632],{"class":131}," \"area_m2\"",[110,1634,1635],{"class":191},") -> ",[110,1637,1105],{"class":127},[110,1639,1238],{"class":191},[110,1641,1642],{"class":112,"line":232},[110,1643,1644],{"class":131},"    \"\"\"Add a double field and populate it with each feature's geometry area.\"\"\"\n",[110,1646,1647,1649,1652,1654,1657,1659],{"class":112,"line":238},[110,1648,1228],{"class":187},[110,1650,1651],{"class":191}," layer.fields().indexOf(field_name) ",[110,1653,957],{"class":187},[110,1655,1656],{"class":187}," -",[110,1658,1134],{"class":127},[110,1660,1238],{"class":191},[110,1662,1663],{"class":112,"line":244},[110,1664,1665],{"class":191},"        layer.dataProvider().addAttributes([QgsField(field_name, QVariant.Double)])\n",[110,1667,1668],{"class":112,"line":250},[110,1669,1670],{"class":191},"        layer.updateFields()\n",[110,1672,1673,1676],{"class":112,"line":263},[110,1674,1675],{"class":187},"    with",[110,1677,1678],{"class":191}," edit(layer):\n",[110,1680,1681,1683,1685,1687],{"class":112,"line":270},[110,1682,850],{"class":187},[110,1684,853],{"class":191},[110,1686,476],{"class":187},[110,1688,1689],{"class":191}," layer.getFeatures():\n",[110,1691,1692,1695,1697],{"class":112,"line":276},[110,1693,1694],{"class":191},"            feat[field_name] ",[110,1696,328],{"class":187},[110,1698,1699],{"class":191}," feat.geometry().area()\n",[110,1701,1702],{"class":112,"line":288},[110,1703,1704],{"class":191},"            layer.updateFeature(feat)\n",[101,1706,1708],{"className":174,"code":1707,"language":144,"meta":106,"style":106},"# tests\u002Ftest_attribution.py\nfrom attribution import add_area_field\nfrom qgis.core import QgsFeature, QgsGeometry\n\n\ndef test_adds_and_populates_area(memory_layer_factory):\n    layer = memory_layer_factory(geometry=\"Polygon\", crs=\"EPSG:3857\")\n    feat = QgsFeature(layer.fields())\n    feat.setGeometry(QgsGeometry.fromWkt(\"POLYGON((0 0,0 20,20 20,20 0,0 0))\"))\n    layer.dataProvider().addFeatures([feat])\n\n    add_area_field(layer)\n\n    assert layer.fields().indexOf(\"area_m2\") != -1\n    stored = next(layer.getFeatures())[\"area_m2\"]\n    assert stored == 400.0\n\n\ndef test_is_idempotent(memory_layer_factory):\n    layer = memory_layer_factory(geometry=\"Polygon\", crs=\"EPSG:3857\")\n    add_area_field(layer)\n    add_area_field(layer)  # second call must not duplicate the field\n    names = [f.name() for f in layer.fields()]\n    assert names.count(\"area_m2\") == 1\n",[18,1709,1710,1715,1727,1737,1741,1745,1754,1779,1787,1796,1800,1804,1809,1813,1833,1850,1862,1866,1870,1879,1903,1907,1915,1936],{"__ignoreMap":106},[110,1711,1712],{"class":112,"line":113},[110,1713,1714],{"class":116},"# tests\u002Ftest_attribution.py\n",[110,1716,1717,1719,1722,1724],{"class":112,"line":120},[110,1718,197],{"class":187},[110,1720,1721],{"class":191}," attribution ",[110,1723,188],{"class":187},[110,1725,1726],{"class":191}," add_area_field\n",[110,1728,1729,1731,1733,1735],{"class":112,"line":135},[110,1730,197],{"class":187},[110,1732,212],{"class":191},[110,1734,188],{"class":187},[110,1736,1036],{"class":191},[110,1738,1739],{"class":112,"line":141},[110,1740,267],{"emptyLinePlaceholder":266},[110,1742,1743],{"class":112,"line":220},[110,1744,267],{"emptyLinePlaceholder":266},[110,1746,1747,1749,1752],{"class":112,"line":226},[110,1748,307],{"class":187},[110,1750,1751],{"class":123}," test_adds_and_populates_area",[110,1753,1024],{"class":191},[110,1755,1756,1758,1760,1763,1765,1767,1769,1771,1773,1775,1777],{"class":112,"line":232},[110,1757,325],{"class":191},[110,1759,328],{"class":187},[110,1761,1762],{"class":191}," memory_layer_factory(",[110,1764,653],{"class":1050},[110,1766,328],{"class":187},[110,1768,615],{"class":131},[110,1770,396],{"class":191},[110,1772,664],{"class":1050},[110,1774,328],{"class":187},[110,1776,623],{"class":131},[110,1778,247],{"class":191},[110,1780,1781,1783,1785],{"class":112,"line":238},[110,1782,1088],{"class":191},[110,1784,328],{"class":187},[110,1786,490],{"class":191},[110,1788,1789,1791,1794],{"class":112,"line":244},[110,1790,1113],{"class":191},[110,1792,1793],{"class":131},"\"POLYGON((0 0,0 20,20 20,20 0,0 0))\"",[110,1795,1119],{"class":191},[110,1797,1798],{"class":112,"line":250},[110,1799,1124],{"class":191},[110,1801,1802],{"class":112,"line":263},[110,1803,267],{"emptyLinePlaceholder":266},[110,1805,1806],{"class":112,"line":270},[110,1807,1808],{"class":191},"    add_area_field(layer)\n",[110,1810,1811],{"class":112,"line":276},[110,1812,267],{"emptyLinePlaceholder":266},[110,1814,1815,1817,1820,1823,1825,1828,1830],{"class":112,"line":288},[110,1816,945],{"class":187},[110,1818,1819],{"class":191}," layer.fields().indexOf(",[110,1821,1822],{"class":131},"\"area_m2\"",[110,1824,954],{"class":191},[110,1826,1827],{"class":187},"!=",[110,1829,1656],{"class":187},[110,1831,1832],{"class":127},"1\n",[110,1834,1835,1838,1840,1843,1846,1848],{"class":112,"line":293},[110,1836,1837],{"class":191},"    stored ",[110,1839,328],{"class":187},[110,1841,1842],{"class":127}," next",[110,1844,1845],{"class":191},"(layer.getFeatures())[",[110,1847,1822],{"class":131},[110,1849,845],{"class":191},[110,1851,1852,1854,1857,1859],{"class":112,"line":298},[110,1853,945],{"class":187},[110,1855,1856],{"class":191}," stored ",[110,1858,957],{"class":187},[110,1860,1861],{"class":127}," 400.0\n",[110,1863,1864],{"class":112,"line":304},[110,1865,267],{"emptyLinePlaceholder":266},[110,1867,1868],{"class":112,"line":316},[110,1869,267],{"emptyLinePlaceholder":266},[110,1871,1872,1874,1877],{"class":112,"line":322},[110,1873,307],{"class":187},[110,1875,1876],{"class":123}," test_is_idempotent",[110,1878,1024],{"class":191},[110,1880,1881,1883,1885,1887,1889,1891,1893,1895,1897,1899,1901],{"class":112,"line":334},[110,1882,325],{"class":191},[110,1884,328],{"class":187},[110,1886,1762],{"class":191},[110,1888,653],{"class":1050},[110,1890,328],{"class":187},[110,1892,615],{"class":131},[110,1894,396],{"class":191},[110,1896,664],{"class":1050},[110,1898,328],{"class":187},[110,1900,623],{"class":131},[110,1902,247],{"class":191},[110,1904,1905],{"class":112,"line":343},[110,1906,1808],{"class":191},[110,1908,1909,1912],{"class":112,"line":351},[110,1910,1911],{"class":191},"    add_area_field(layer)  ",[110,1913,1914],{"class":116},"# second call must not duplicate the field\n",[110,1916,1917,1920,1922,1925,1928,1931,1933],{"class":112,"line":359},[110,1918,1919],{"class":191},"    names ",[110,1921,328],{"class":187},[110,1923,1924],{"class":191}," [f.name() ",[110,1926,1927],{"class":187},"for",[110,1929,1930],{"class":191}," f ",[110,1932,476],{"class":187},[110,1934,1935],{"class":191}," layer.fields()]\n",[110,1937,1938,1940,1943,1945,1947,1949],{"class":112,"line":365},[110,1939,945],{"class":187},[110,1941,1942],{"class":191}," names.count(",[110,1944,1822],{"class":131},[110,1946,954],{"class":191},[110,1948,957],{"class":187},[110,1950,1951],{"class":127}," 1\n",[14,1953,1954,1956,1957,1960,1961,1964],{},[77,1955,534],{}," The ",[18,1958,1959],{},"edit()"," context manager opens and commits an edit session, so the test verifies committed state, not pending edits. The first test asserts both that the field was created and that its value equals the known polygon area (a 20×20 square is 400 square units). The second test calls the function twice to prove the ",[18,1962,1963],{},"indexOf(...) == -1"," guard makes it idempotent — a property that breaks plugins which re-run on the same layer. Memory layers make this destructive test safe because nothing persists between test functions.",[63,1966,1968],{"id":1967},"recipe-running-headless","Recipe: Running Headless",[14,1970,1971,1972,1974],{},"With the suite written, run it. On a desktop with a display, plain ",[18,1973,30],{}," works:",[101,1976,1978],{"className":103,"code":1977,"language":105,"meta":106,"style":106},"python -m pytest tests\u002F -v\n",[18,1979,1980],{"__ignoreMap":106},[110,1981,1982,1984,1987,1990,1993],{"class":112,"line":113},[110,1983,144],{"class":123},[110,1985,1986],{"class":127}," -m",[110,1988,1989],{"class":131}," pytest",[110,1991,1992],{"class":131}," tests\u002F",[110,1994,1995],{"class":127}," -v\n",[14,1997,1998],{},"On a headless server or CI runner there is no X display, so wrap the command:",[101,2000,2002],{"className":103,"code":2001,"language":105,"meta":106,"style":106},"export QT_QPA_PLATFORM=offscreen\nxvfb-run -a python -m pytest tests\u002F -v\n",[18,2003,2004,2017],{"__ignoreMap":106},[110,2005,2006,2009,2012,2014],{"class":112,"line":113},[110,2007,2008],{"class":187},"export",[110,2010,2011],{"class":191}," QT_QPA_PLATFORM",[110,2013,328],{"class":187},[110,2015,2016],{"class":191},"offscreen\n",[110,2018,2019,2022,2025,2028,2030,2032,2034],{"class":112,"line":120},[110,2020,2021],{"class":123},"xvfb-run",[110,2023,2024],{"class":127}," -a",[110,2026,2027],{"class":131}," python",[110,2029,1986],{"class":127},[110,2031,1989],{"class":131},[110,2033,1992],{"class":131},[110,2035,1995],{"class":127},[14,2037,2038,2040,2041,2044,2045,2048,2049,2052,2053,2056,2057,2059,2060,2063,2064,2068,2069,2072],{},[77,2039,534],{}," ",[18,2042,2043],{},"QT_QPA_PLATFORM=offscreen"," tells Qt to use its offscreen rendering backend, and ",[18,2046,2047],{},"xvfb-run -a"," supplies a throwaway virtual display for any widget that still needs one. The ",[18,2050,2051],{},"-a"," flag auto-selects a free display number. Run via ",[18,2054,2055],{},"python -m pytest"," rather than the bare ",[18,2058,30],{}," script to guarantee the QGIS-bundled interpreter is used. The same ",[18,2061,2062],{},"metadata.txt"," you validate before release — see ",[23,2065,2067],{"href":2066},"\u002Fqgis-plugin-development\u002Fpublishing-to-the-qgis-plugin-repository\u002Fwrite-metadata-txt-qgis-plugin\u002F","Write metadata.txt for a QGIS Plugin"," — can also be checked in a test with ",[18,2070,2071],{},"configparser",", keeping packaging honest alongside logic.",[63,2074,2076],{"id":2075},"qgis-version-compatibility","QGIS Version Compatibility",[14,2078,2079,2080,2082],{},"The 3.34 LTR baseline (Python 3.12) is what these examples target. ",[18,2081,38],{}," and the mocking approach are unchanged across the 3.x line, so the same suite runs everywhere.",[2084,2085,2086,2102],"table",{},[2087,2088,2089],"thead",{},[2090,2091,2092,2096,2099],"tr",{},[2093,2094,2095],"th",{},"QGIS line",[2093,2097,2098],{},"Bundled Python",[2093,2100,2101],{},"Notes for testing",[2103,2104,2105,2124,2135],"tbody",{},[2090,2106,2107,2111,2114],{},[2108,2109,2110],"td",{},"3.28 LTR",[2108,2112,2113],{},"3.9",[2108,2115,2116,2119,2120,2123],{},[18,2117,2118],{},"list[str]"," return annotations are fine; avoid ",[18,2121,2122],{},"match"," statements and 3.10+ syntax in tested code",[2090,2125,2126,2129,2132],{},[2108,2127,2128],{},"3.34 LTR",[2108,2130,2131],{},"3.12",[2108,2133,2134],{},"Baseline; all examples here run as written",[2090,2136,2137,2140,2142],{},[2108,2138,2139],{},"3.40 \u002F 3.44",[2108,2141,2131],{},[2108,2143,2144],{},"Identical test API; use as the \"latest\" entry to catch deprecations early",[14,2146,2147],{},"If you support 3.28, run the suite under its Docker image too; the only differences you will encounter are in the plugin's own logic, never in the testing scaffolding.",[63,2149,2151],{"id":2150},"troubleshooting","Troubleshooting",[14,2153,2154,2159,2160,2162],{},[77,2155,2156,1170],{},[18,2157,2158],{},"ModuleNotFoundError: No module named 'qgis'"," You are running a non-QGIS Python. Use the interpreter that QGIS ships (the OSGeo4W shell on Windows, the system ",[18,2161,124],{}," with QGIS on its path on Linux).",[14,2164,2165,2040,2171,2173,2174,2176],{},[77,2166,2167,2170],{},[18,2168,2169],{},"Application path not initialized"," or empty provider list.",[18,2172,538],{}," was not called before a fixture created QGIS objects. Ensure the call is at module level in ",[18,2175,34],{},", not inside a fixture.",[14,2178,2179,2184,2185,2187,2188,1170],{},[77,2180,2181,1170],{},[18,2182,2183],{},"qt.qpa.xcb: could not connect to display"," No X display in a headless environment. Run under ",[18,2186,2047],{}," and set ",[18,2189,2043],{},[14,2191,2192,2199,2200,1170],{},[77,2193,2194,2195,2198],{},"Layer is invalid (",[18,2196,2197],{},"isValid()"," is False)."," The memory URI is malformed — check field declarations and that the geometry type and CRS are spelled correctly, e.g. ",[18,2201,2202],{},"Polygon?crs=EPSG:3857&field=id:integer",[14,2204,2205,2212,2213,2216,2217,2221,2222,2224,2225,2228,2229,1170],{},[77,2206,2207,2208,2211],{},"Mocked ",[18,2209,2210],{},"pushMessage"," assertion fails unexpectedly."," Remember ",[18,2214,2215],{},"iface.messageBar()"," returns a ",[2218,2219,2220],"em",{},"new"," mock each call unless you bind it. Assert against ",[18,2223,1552],{}," consistently, or capture ",[18,2226,2227],{},"bar = iface.messageBar()"," once and assert on ",[18,2230,2231],{},"bar.pushMessage",[63,2233,2235],{"id":2234},"conclusion","Conclusion",[14,2237,2238,2239,2241,2242,2244,2245,2247,2248,2250,2251,2253],{},"The hard part of testing a QGIS plugin is the bootstrap, and ",[18,2240,38],{}," solves it in one line. Once QGIS is initialized in ",[18,2243,34],{},", memory-layer fixtures give you fast deterministic data, dependency-injected ",[18,2246,46],{}," becomes a trivial ",[18,2249,1169],{},", and ",[18,2252,91],{}," makes the whole suite headless-friendly. Write logic as pure functions, test those exhaustively, mock the thin GUI layer, and you have a suite ready to drop into the CI matrix from the parent cluster.",[63,2255,2257],{"id":2256},"frequently-asked-questions","Frequently Asked Questions",[14,2259,2260,2266,2267,2270],{},[77,2261,2262,2263,2265],{},"Where exactly should ",[18,2264,538],{}," be called?","\nAt module level in ",[18,2268,2269],{},"tests\u002Fconftest.py",", so it runs once when pytest imports the file, before any fixture or test executes. Calling it per-test is wasteful but harmless since it is idempotent.",[14,2272,2273,2276,2277,2279],{},[77,2274,2275],{},"Do memory layers support editing and attribute changes?","\nYes. The memory provider supports adding fields and features, ",[18,2278,1959],{}," transactions, and attribute updates, which makes it ideal for testing logic that modifies layers without writing files.",[14,2281,2282,2289,2290,2292,2293,2296,2297,2300],{},[77,2283,2284,2285,2288],{},"How do I test a function that calls ",[18,2286,2287],{},"processing.run()","?","\nAfter ",[18,2291,538],{},", also call ",[18,2294,2295],{},"Processing.initialize()"," so the algorithm registry loads, then call ",[18,2298,2299],{},"processing.run(\"native:buffer\", params)"," inside the test and assert on the returned output layer just as you would in production.",[14,2302,2303,2306,2307,2310,2311,2313],{},[77,2304,2305],{},"Can I parametrize a test across several layers?","\nYes — use ",[18,2308,2309],{},"@pytest.mark.parametrize"," for inline data, or return a factory from a fixture (like ",[18,2312,1155],{},") so each test builds the exact layer it needs.",[63,2315,2317],{"id":2316},"related","Related",[68,2319,2320,2324,2330],{},[71,2321,2322],{},[23,2323,26],{"href":25},[71,2325,2326],{},[23,2327,2329],{"href":2328},"\u002Fqgis-plugin-development\u002Fplugin-boilerplate-structure\u002Fcreate-qgis-plugin-with-plugin-builder\u002F","Create a QGIS Plugin with Plugin Builder",[71,2331,2332],{},[23,2333,2067],{"href":2066},[2335,2336,2337],"style",{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}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 .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":106,"searchDepth":120,"depth":120,"links":2339},[2340,2341,2342,2343,2344,2345,2346,2347,2348,2349,2350,2351],{"id":65,"depth":120,"text":66},{"id":158,"depth":120,"text":159},{"id":554,"depth":120,"text":555},{"id":761,"depth":120,"text":762},{"id":1159,"depth":120,"text":1160},{"id":1572,"depth":120,"text":1573},{"id":1967,"depth":120,"text":1968},{"id":2075,"depth":120,"text":2076},{"id":2150,"depth":120,"text":2151},{"id":2234,"depth":120,"text":2235},{"id":2256,"depth":120,"text":2257},{"id":2316,"depth":120,"text":2317},"Set up pytest for a QGIS plugin — a conftest.py that boots QGIS with start_app, memory-layer fixtures, mocking iface, and running the suite headlessly.","md",{},"\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Funit-test-qgis-plugin-with-pytest",{"title":5,"description":2352},"qgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Funit-test-qgis-plugin-with-pytest\u002Findex","p-KL2fPqa8GD13Yxpb6umkDzVOAj6C46jjPm0slfGhs",1781792483475]