[{"data":1,"prerenderedAt":2375},["ShallowReactive",2],{"doc:\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins":3},{"id":4,"title":5,"body":6,"description":2368,"extension":2369,"meta":2370,"navigation":328,"path":2371,"seo":2372,"stem":2373,"__hash__":2374},"docs\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Findex.md","Testing & CI for QGIS Plugins",{"type":7,"value":8,"toc":2351},"minimark",[9,13,40,43,48,78,82,85,248,252,275,418,438,469,473,486,605,624,649,653,666,839,947,962,966,979,1129,1244,1269,1273,1287,1483,1511,1515,1532,1579,1597,1618,1622,1629,1858,1894,1898,1935,1991,2007,2011,2021,2072,2110,2114,2188,2198,2202,2242,2246,2261,2271,2290,2304,2321,2325,2347],[10,11,5],"h1",{"id":12},"testing-ci-for-qgis-plugins",[14,15,16,17,22,23,27,28,31,32,35,36,39],"p",{},"Plugins that load cleanly on your machine routinely break on a colleague's older QGIS, on a different operating system, or after an API deprecation you never noticed. Automated tests and continuous integration are how serious plugin authors catch those failures before users do. This cluster, part of ",[18,19,21],"a",{"href":20},"\u002Fqgis-plugin-development\u002F","QGIS Plugin Development",", covers the full quality pipeline: structuring code so it is testable, writing ",[24,25,26],"code",{},"pytest","\u002F",[24,29,30],{},"unittest"," cases against real PyQGIS objects, initializing a headless QGIS application, mocking ",[24,33,34],{},"iface",", running everything under ",[24,37,38],{},"xvfb"," in CI, and building a GitHub Actions matrix that exercises your plugin across multiple QGIS releases.",[14,41,42],{},"The central challenge is that PyQGIS is not a normal importable library — it only works inside an initialized QGIS application. Once you solve that initialization problem, the rest of the testing stack is conventional Python, and your plugin gains the same safety net any well-engineered codebase enjoys.",[44,45,47],"h2",{"id":46},"prerequisites","Prerequisites",[49,50,51,60,67,72,75],"ul",{},[52,53,54,55,59],"li",{},"A plugin with logic worth testing, structured per ",[18,56,58],{"href":57},"\u002Fqgis-plugin-development\u002Fplugin-boilerplate-structure\u002F","Plugin Boilerplate & Structure",".",[52,61,62,66],{},[63,64,65],"strong",{},"QGIS 3.34 LTR"," installed (bundles Python 3.12), used as the interpreter for tests.",[52,68,69,71],{},[24,70,26],{}," available to the QGIS Python environment.",[52,73,74],{},"Familiarity with running scripts against QGIS's bundled Python.",[52,76,77],{},"A Git repository hosted on GitHub (for the Actions section).",[44,79,81],{"id":80},"the-ci-pipeline-at-a-glance","The CI Pipeline at a Glance",[14,83,84],{},"A plugin CI run moves through linting, a headless test stage spanning several QGIS versions, and an optional build\u002Frelease step. The matrix is the heart of it: the same test suite runs against each supported QGIS image in parallel.",[86,87,92,93,92,97,92,101,92,108,92,204,92,233],"svg",{"viewBox":88,"role":89,"ariaLabel":90,"xmlns":91},"0 0 760 280","img","Continuous integration pipeline for a QGIS plugin","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[94,95,96],"title",{},"QGIS plugin CI pipeline",[98,99,100],"desc",{},"A push triggers linting, then a parallel test matrix running headless QGIS 3.28, 3.34, and 3.40 under xvfb, which feeds a build step that produces a packaged ZIP artifact.",[102,103],"rect",{"x":104,"y":104,"width":105,"height":106,"fill":107},"0","760","280","#f6f3ea",[109,110,113,114,113,124,113,132,113,137,113,141,113,145,113,148,113,154,113,159,113,163,113,165,113,167,113,169,113,172,113,176,113,179,113,183,113,188,113,192,113,196,113,200,92],"g",{"fill":111,"style":112},"#2f3b35","font-family:sans-serif;font-size:13px","\n    ",[102,115],{"x":116,"y":117,"width":118,"height":119,"rx":120,"fill":121,"stroke":122,"style":123},"14","118","120","56","8","#fffdf7","#2563eb","stroke-width:2.5",[125,126,131],"text",{"x":127,"y":128,"fill":129,"style":130},"74","142","#17211d","text-anchor:middle;font-weight:bold","git push",[125,133,136],{"x":127,"y":134,"style":135},"160","text-anchor:middle","\u002F PR",[102,138],{"x":139,"y":117,"width":118,"height":119,"rx":120,"fill":121,"stroke":140,"style":123},"166","#0f766e",[125,142,144],{"x":143,"y":128,"fill":129,"style":130},"226","Lint",[125,146,147],{"x":143,"y":134,"style":135},"flake8 \u002F black",[102,149],{"x":150,"y":151,"width":134,"height":152,"rx":120,"fill":121,"stroke":153,"style":123},"330","22","54","#b45309",[125,155,158],{"x":156,"y":157,"fill":129,"style":130},"410","46","QGIS 3.28",[125,160,162],{"x":156,"y":161,"style":135},"64","pytest + xvfb",[102,164],{"x":150,"y":117,"width":134,"height":152,"rx":120,"fill":121,"stroke":153,"style":123},[125,166,65],{"x":156,"y":128,"fill":129,"style":130},[125,168,162],{"x":156,"y":134,"style":135},[102,170],{"x":150,"y":171,"width":134,"height":152,"rx":120,"fill":121,"stroke":153,"style":123},"214",[125,173,175],{"x":156,"y":174,"fill":129,"style":130},"238","QGIS 3.40",[125,177,162],{"x":156,"y":178,"style":135},"256",[102,180],{"x":181,"y":182,"width":118,"height":119,"rx":120,"fill":121,"stroke":140,"style":123},"556","90",[125,184,187],{"x":185,"y":186,"fill":129,"style":130},"616","114","Build",[125,189,191],{"x":185,"y":190,"style":135},"132","pb_tool zip",[102,193],{"x":181,"y":194,"width":118,"height":119,"rx":120,"fill":121,"stroke":195,"style":123},"178","#15803d",[125,197,199],{"x":185,"y":198,"fill":129,"style":130},"202","Artifact",[125,201,203],{"x":185,"y":202,"style":135},"220","plugin.zip",[109,205,113,207,113,212,113,215,113,218,113,221,113,224,113,227,113,230,92],{"stroke":111,"fill":206,"style":123},"none",[208,209],"path",{"d":210,"style":211},"M134 146 H166","marker-end:url(#a)",[208,213],{"d":214,"style":211},"M286 146 H310 V49 H330",[208,216],{"d":217,"style":211},"M286 146 H330",[208,219],{"d":220,"style":211},"M286 146 H310 V241 H330",[208,222],{"d":223,"style":211},"M490 49 H520 V118 H556",[208,225],{"d":226,"style":211},"M490 146 H520 V118 H556",[208,228],{"d":229,"style":211},"M490 241 H520 V146 H556",[208,231],{"d":232,"style":211},"M616 146 V178",[234,235,113,236,92],"defs",{},[237,238,244,245,113],"marker",{"id":18,"viewBox":239,"refX":240,"refY":241,"markerWidth":242,"markerHeight":242,"orient":243},"0 0 10 10","9","5","7","auto-start-reverse","\n      ",[208,246],{"d":247,"fill":111},"M0 0 L10 5 L0 10 z",[44,249,251],{"id":250},"bootstrapping-a-headless-qgis-for-tests","Bootstrapping a Headless QGIS for Tests",[14,253,254,255,258,259,262,263,266,267,270,271,274],{},"PyQGIS classes such as ",[24,256,257],{},"QgsVectorLayer"," and ",[24,260,261],{},"QgsCoordinateTransform"," need an initialized ",[24,264,265],{},"QgsApplication"," with its provider registry and PROJ\u002FGEOS bindings loaded. The ",[24,268,269],{},"qgis.testing"," module exists precisely for this. Its ",[24,272,273],{},"start_app()"," helper creates a single application instance, initializes processing providers, and returns a handle you can reuse across the whole test session.",[276,277,282],"pre",{"className":278,"code":279,"language":280,"meta":281,"style":281},"language-python shiki shiki-themes github-dark","# A standalone smoke test that proves the QGIS app boots headlessly\nfrom qgis.testing import start_app\nfrom qgis.core import QgsVectorLayer, QgsApplication\n\nQGISAPP = start_app()\n\nlayer = QgsVectorLayer(\"Point?crs=EPSG:4326&field=id:integer\", \"pts\", \"memory\")\nassert layer.isValid(), \"memory provider not available\"\nprint(\"QGIS version:\", QgsApplication.libraryVersion())\nprint(\"Provider works:\", layer.isValid())\n","python","",[24,283,284,293,310,323,330,343,348,378,390,405],{"__ignoreMap":281},[285,286,289],"span",{"class":287,"line":288},"line",1,[285,290,292],{"class":291},"sAwPA","# A standalone smoke test that proves the QGIS app boots headlessly\n",[285,294,296,300,304,307],{"class":287,"line":295},2,[285,297,299],{"class":298},"snl16","from",[285,301,303],{"class":302},"s95oV"," qgis.testing ",[285,305,306],{"class":298},"import",[285,308,309],{"class":302}," start_app\n",[285,311,313,315,318,320],{"class":287,"line":312},3,[285,314,299],{"class":298},[285,316,317],{"class":302}," qgis.core ",[285,319,306],{"class":298},[285,321,322],{"class":302}," QgsVectorLayer, QgsApplication\n",[285,324,326],{"class":287,"line":325},4,[285,327,329],{"emptyLinePlaceholder":328},true,"\n",[285,331,333,337,340],{"class":287,"line":332},5,[285,334,336],{"class":335},"sDLfK","QGISAPP",[285,338,339],{"class":298}," =",[285,341,342],{"class":302}," start_app()\n",[285,344,346],{"class":287,"line":345},6,[285,347,329],{"emptyLinePlaceholder":328},[285,349,351,354,357,360,364,367,370,372,375],{"class":287,"line":350},7,[285,352,353],{"class":302},"layer ",[285,355,356],{"class":298},"=",[285,358,359],{"class":302}," QgsVectorLayer(",[285,361,363],{"class":362},"sU2Wk","\"Point?crs=EPSG:4326&field=id:integer\"",[285,365,366],{"class":302},", ",[285,368,369],{"class":362},"\"pts\"",[285,371,366],{"class":302},[285,373,374],{"class":362},"\"memory\"",[285,376,377],{"class":302},")\n",[285,379,381,384,387],{"class":287,"line":380},8,[285,382,383],{"class":298},"assert",[285,385,386],{"class":302}," layer.isValid(), ",[285,388,389],{"class":362},"\"memory provider not available\"\n",[285,391,393,396,399,402],{"class":287,"line":392},9,[285,394,395],{"class":335},"print",[285,397,398],{"class":302},"(",[285,400,401],{"class":362},"\"QGIS version:\"",[285,403,404],{"class":302},", QgsApplication.libraryVersion())\n",[285,406,408,410,412,415],{"class":287,"line":407},10,[285,409,395],{"class":335},[285,411,398],{"class":302},[285,413,414],{"class":362},"\"Provider works:\"",[285,416,417],{"class":302},", layer.isValid())\n",[14,419,420,423,424,426,427,429,430,433,434,437],{},[63,421,422],{},"Breakdown:"," ",[24,425,273],{}," is idempotent — calling it again returns the existing application rather than crashing on a duplicate ",[24,428,265],{},". The memory-provider URI builds a layer with no file on disk, which is the workhorse fixture for fast tests. If ",[24,431,432],{},"isValid()"," is ",[24,435,436],{},"False",", your provider registry never initialized, almost always because the script ran under a plain Python interpreter instead of the QGIS one.",[14,439,440,441,444,445,447,448,366,450,453,454,457,458,461,462,464,465,468],{},"There are two distinct things a test environment can lack. The first is the ",[24,442,443],{},"qgis"," package itself — solved by using the QGIS-bundled Python. The second is a running application context, which is what ",[24,446,273],{}," provides. Both must be in place before any ",[24,449,257],{},[24,451,452],{},"QgsCoordinateReferenceSystem",", or ",[24,455,456],{},"processing.run()"," call. A common mistake is importing ",[24,459,460],{},"qgis.core"," successfully and assuming everything works, only to find layers come back invalid because no application booted. Treat ",[24,463,273],{}," as a hard prerequisite, established once in ",[24,466,467],{},"conftest.py",", never per test body.",[44,470,472],{"id":471},"separating-logic-from-iface","Separating Logic From iface",[14,474,475,476,478,479,481,482,485],{},"The biggest determinant of how testable a plugin is comes down to one design decision: keep your spatial logic in plain functions that take and return QGIS objects, and confine ",[24,477,34],{}," to a thin GUI layer. ",[24,480,34],{}," (the ",[24,483,484],{},"QgisInterface",") only exists inside running QGIS Desktop, so any logic entangled with it cannot run in CI.",[276,487,489],{"className":278,"code":488,"language":280,"meta":281,"style":281},"# logic.py — pure, testable, no iface anywhere\nfrom qgis.core import QgsVectorLayer, QgsFeatureRequest\n\n\ndef count_features_over_area(layer: QgsVectorLayer, threshold: float) -> int:\n    \"\"\"Return how many polygon features exceed the given area threshold.\"\"\"\n    request = QgsFeatureRequest().setSubsetOfAttributes([])\n    return sum(\n        1\n        for feat in layer.getFeatures(request)\n        if feat.geometry().area() > threshold\n    )\n",[24,490,491,496,507,511,515,539,544,554,565,570,584,599],{"__ignoreMap":281},[285,492,493],{"class":287,"line":288},[285,494,495],{"class":291},"# logic.py — pure, testable, no iface anywhere\n",[285,497,498,500,502,504],{"class":287,"line":295},[285,499,299],{"class":298},[285,501,317],{"class":302},[285,503,306],{"class":298},[285,505,506],{"class":302}," QgsVectorLayer, QgsFeatureRequest\n",[285,508,509],{"class":287,"line":312},[285,510,329],{"emptyLinePlaceholder":328},[285,512,513],{"class":287,"line":325},[285,514,329],{"emptyLinePlaceholder":328},[285,516,517,520,524,527,530,533,536],{"class":287,"line":332},[285,518,519],{"class":298},"def",[285,521,523],{"class":522},"svObZ"," count_features_over_area",[285,525,526],{"class":302},"(layer: QgsVectorLayer, threshold: ",[285,528,529],{"class":335},"float",[285,531,532],{"class":302},") -> ",[285,534,535],{"class":335},"int",[285,537,538],{"class":302},":\n",[285,540,541],{"class":287,"line":345},[285,542,543],{"class":362},"    \"\"\"Return how many polygon features exceed the given area threshold.\"\"\"\n",[285,545,546,549,551],{"class":287,"line":350},[285,547,548],{"class":302},"    request ",[285,550,356],{"class":298},[285,552,553],{"class":302}," QgsFeatureRequest().setSubsetOfAttributes([])\n",[285,555,556,559,562],{"class":287,"line":380},[285,557,558],{"class":298},"    return",[285,560,561],{"class":335}," sum",[285,563,564],{"class":302},"(\n",[285,566,567],{"class":287,"line":392},[285,568,569],{"class":335},"        1\n",[285,571,572,575,578,581],{"class":287,"line":407},[285,573,574],{"class":298},"        for",[285,576,577],{"class":302}," feat ",[285,579,580],{"class":298},"in",[285,582,583],{"class":302}," layer.getFeatures(request)\n",[285,585,587,590,593,596],{"class":287,"line":586},11,[285,588,589],{"class":298},"        if",[285,591,592],{"class":302}," feat.geometry().area() ",[285,594,595],{"class":298},">",[285,597,598],{"class":302}," threshold\n",[285,600,602],{"class":287,"line":601},12,[285,603,604],{"class":302},"    )\n",[14,606,607,609,610,612,613,615,616,619,620,623],{},[63,608,422],{}," This function receives a ",[24,611,257],{}," and returns a number — no ",[24,614,34],{},", no message bar, no dialog. That makes it trivially unit-testable with a memory layer. The ",[24,617,618],{},"setSubsetOfAttributes([])"," request skips loading attribute values you do not need, a small performance win when iterating geometry only. Your GUI code then calls ",[24,621,622],{},"count_features_over_area(self.iface.activeLayer(), 1000.0)"," and handles presentation separately.",[14,625,626,627,630,631,634,635,637,638,641,642,644,645,648],{},"A useful rule of thumb is the three-layer split: a ",[63,628,629],{},"logic layer"," of pure functions over QGIS objects, a ",[63,632,633],{},"controller"," that pulls inputs from ",[24,636,34],{}," and pushes results back to the GUI, and the ",[63,639,640],{},"UI"," itself (dialogs, dock widgets). The logic layer carries the bulk of your test coverage because it is cheap to test and most likely to contain bugs. The controller gets a handful of mock-",[24,643,34],{}," tests. The UI is exercised lightly, mostly to confirm signals are wired. This proportion — many logic tests, few GUI tests — is what keeps the suite fast and the failures meaningful. Code that mixes a topology calculation with a ",[24,646,647],{},"pushMessage"," call in the same function defeats the whole arrangement, so refactor those apart before writing tests.",[44,650,652],{"id":651},"writing-the-test-suite-with-pytest","Writing the Test Suite with pytest",[14,654,655,656,658,659,661,662,59],{},"With logic isolated, tests are ordinary ",[24,657,26],{},". A ",[24,660,467],{}," boots QGIS once per session and provides reusable fixtures; individual test files assert on your logic functions. This is the foundation expanded in detail in ",[18,663,665],{"href":664},"\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Funit-test-qgis-plugin-with-pytest\u002F","Unit Test a QGIS Plugin with pytest",[276,667,669],{"className":278,"code":668,"language":280,"meta":281,"style":281},"# tests\u002Fconftest.py\nimport pytest\nfrom qgis.testing import start_app\nfrom qgis.core import QgsVectorLayer, QgsFeature, QgsGeometry\n\nstart_app()\n\n\n@pytest.fixture\ndef square_layer():\n    \"\"\"A memory polygon layer with two squares of different sizes.\"\"\"\n    layer = QgsVectorLayer(\"Polygon?crs=EPSG:3857\", \"squares\", \"memory\")\n    provider = layer.dataProvider()\n    small = QgsFeature()\n    small.setGeometry(QgsGeometry.fromWkt(\"POLYGON((0 0,0 10,10 10,10 0,0 0))\"))\n    big = QgsFeature()\n    big.setGeometry(QgsGeometry.fromWkt(\"POLYGON((0 0,0 50,50 50,50 0,0 0))\"))\n    provider.addFeatures([small, big])\n    layer.updateExtents()\n    return layer\n",[24,670,671,676,683,693,704,708,713,717,721,726,736,741,764,775,786,798,808,819,825,831],{"__ignoreMap":281},[285,672,673],{"class":287,"line":288},[285,674,675],{"class":291},"# tests\u002Fconftest.py\n",[285,677,678,680],{"class":287,"line":295},[285,679,306],{"class":298},[285,681,682],{"class":302}," pytest\n",[285,684,685,687,689,691],{"class":287,"line":312},[285,686,299],{"class":298},[285,688,303],{"class":302},[285,690,306],{"class":298},[285,692,309],{"class":302},[285,694,695,697,699,701],{"class":287,"line":325},[285,696,299],{"class":298},[285,698,317],{"class":302},[285,700,306],{"class":298},[285,702,703],{"class":302}," QgsVectorLayer, QgsFeature, QgsGeometry\n",[285,705,706],{"class":287,"line":332},[285,707,329],{"emptyLinePlaceholder":328},[285,709,710],{"class":287,"line":345},[285,711,712],{"class":302},"start_app()\n",[285,714,715],{"class":287,"line":350},[285,716,329],{"emptyLinePlaceholder":328},[285,718,719],{"class":287,"line":380},[285,720,329],{"emptyLinePlaceholder":328},[285,722,723],{"class":287,"line":392},[285,724,725],{"class":522},"@pytest.fixture\n",[285,727,728,730,733],{"class":287,"line":407},[285,729,519],{"class":298},[285,731,732],{"class":522}," square_layer",[285,734,735],{"class":302},"():\n",[285,737,738],{"class":287,"line":586},[285,739,740],{"class":362},"    \"\"\"A memory polygon layer with two squares of different sizes.\"\"\"\n",[285,742,743,746,748,750,753,755,758,760,762],{"class":287,"line":601},[285,744,745],{"class":302},"    layer ",[285,747,356],{"class":298},[285,749,359],{"class":302},[285,751,752],{"class":362},"\"Polygon?crs=EPSG:3857\"",[285,754,366],{"class":302},[285,756,757],{"class":362},"\"squares\"",[285,759,366],{"class":302},[285,761,374],{"class":362},[285,763,377],{"class":302},[285,765,767,770,772],{"class":287,"line":766},13,[285,768,769],{"class":302},"    provider ",[285,771,356],{"class":298},[285,773,774],{"class":302}," layer.dataProvider()\n",[285,776,778,781,783],{"class":287,"line":777},14,[285,779,780],{"class":302},"    small ",[285,782,356],{"class":298},[285,784,785],{"class":302}," QgsFeature()\n",[285,787,789,792,795],{"class":287,"line":788},15,[285,790,791],{"class":302},"    small.setGeometry(QgsGeometry.fromWkt(",[285,793,794],{"class":362},"\"POLYGON((0 0,0 10,10 10,10 0,0 0))\"",[285,796,797],{"class":302},"))\n",[285,799,801,804,806],{"class":287,"line":800},16,[285,802,803],{"class":302},"    big ",[285,805,356],{"class":298},[285,807,785],{"class":302},[285,809,811,814,817],{"class":287,"line":810},17,[285,812,813],{"class":302},"    big.setGeometry(QgsGeometry.fromWkt(",[285,815,816],{"class":362},"\"POLYGON((0 0,0 50,50 50,50 0,0 0))\"",[285,818,797],{"class":302},[285,820,822],{"class":287,"line":821},18,[285,823,824],{"class":302},"    provider.addFeatures([small, big])\n",[285,826,828],{"class":287,"line":827},19,[285,829,830],{"class":302},"    layer.updateExtents()\n",[285,832,834,836],{"class":287,"line":833},20,[285,835,558],{"class":298},[285,837,838],{"class":302}," layer\n",[276,840,842],{"className":278,"code":841,"language":280,"meta":281,"style":281},"# tests\u002Ftest_logic.py\nfrom logic import count_features_over_area\n\n\ndef test_counts_only_large_polygons(square_layer):\n    # small square area = 100, big square area = 2500\n    assert count_features_over_area(square_layer, threshold=500) == 1\n\n\ndef test_threshold_below_all(square_layer):\n    assert count_features_over_area(square_layer, threshold=50) == 2\n",[24,843,844,849,861,865,869,879,884,910,914,918,927],{"__ignoreMap":281},[285,845,846],{"class":287,"line":288},[285,847,848],{"class":291},"# tests\u002Ftest_logic.py\n",[285,850,851,853,856,858],{"class":287,"line":295},[285,852,299],{"class":298},[285,854,855],{"class":302}," logic ",[285,857,306],{"class":298},[285,859,860],{"class":302}," count_features_over_area\n",[285,862,863],{"class":287,"line":312},[285,864,329],{"emptyLinePlaceholder":328},[285,866,867],{"class":287,"line":325},[285,868,329],{"emptyLinePlaceholder":328},[285,870,871,873,876],{"class":287,"line":332},[285,872,519],{"class":298},[285,874,875],{"class":522}," test_counts_only_large_polygons",[285,877,878],{"class":302},"(square_layer):\n",[285,880,881],{"class":287,"line":345},[285,882,883],{"class":291},"    # small square area = 100, big square area = 2500\n",[285,885,886,889,892,896,898,901,904,907],{"class":287,"line":350},[285,887,888],{"class":298},"    assert",[285,890,891],{"class":302}," count_features_over_area(square_layer, ",[285,893,895],{"class":894},"s9osk","threshold",[285,897,356],{"class":298},[285,899,900],{"class":335},"500",[285,902,903],{"class":302},") ",[285,905,906],{"class":298},"==",[285,908,909],{"class":335}," 1\n",[285,911,912],{"class":287,"line":380},[285,913,329],{"emptyLinePlaceholder":328},[285,915,916],{"class":287,"line":392},[285,917,329],{"emptyLinePlaceholder":328},[285,919,920,922,925],{"class":287,"line":407},[285,921,519],{"class":298},[285,923,924],{"class":522}," test_threshold_below_all",[285,926,878],{"class":302},[285,928,929,931,933,935,937,940,942,944],{"class":287,"line":586},[285,930,888],{"class":298},[285,932,891],{"class":302},[285,934,895],{"class":894},[285,936,356],{"class":298},[285,938,939],{"class":335},"50",[285,941,903],{"class":302},[285,943,906],{"class":298},[285,945,946],{"class":335}," 2\n",[14,948,949,951,952,955,956,958,959,961],{},[63,950,422],{}," The ",[24,953,954],{},"square_layer"," fixture builds deterministic geometry with known areas (100 and 2500 in projected units), so assertions are exact rather than approximate. Calling ",[24,957,273],{}," at module import in ",[24,960,467],{}," guarantees QGIS is ready before any fixture runs. Each test states its expectation in a comment so a future maintainer understands the intent without recomputing areas.",[44,963,965],{"id":964},"mocking-iface-for-the-gui-layer","Mocking iface for the GUI Layer",[14,967,968,969,971,972,975,976,978],{},"Some code legitimately touches ",[24,970,34],{}," — pushing messages, reading the active layer, adding a toolbar action. You test these paths by substituting a mock. ",[24,973,974],{},"unittest.mock.MagicMock"," impersonates ",[24,977,34],{}," and records the calls your code makes, letting you assert on behavior without a running GUI.",[276,980,982],{"className":278,"code":981,"language":280,"meta":281,"style":281},"# tests\u002Ftest_gui.py\nfrom unittest.mock import MagicMock\nfrom qgis.core import Qgis\nfrom plugin_actions import warn_if_no_layer\n\n\ndef test_warns_when_no_active_layer():\n    iface = MagicMock()\n    iface.activeLayer.return_value = None\n\n    handled = warn_if_no_layer(iface)\n\n    assert handled is False\n    iface.messageBar().pushMessage.assert_called_once()\n    # confirm the warning level was used\n    _, kwargs = iface.messageBar().pushMessage.call_args\n    assert kwargs.get(\"level\") == Qgis.Warning\n",[24,983,984,989,1001,1012,1024,1028,1032,1041,1051,1061,1065,1075,1079,1092,1097,1102,1112],{"__ignoreMap":281},[285,985,986],{"class":287,"line":288},[285,987,988],{"class":291},"# tests\u002Ftest_gui.py\n",[285,990,991,993,996,998],{"class":287,"line":295},[285,992,299],{"class":298},[285,994,995],{"class":302}," unittest.mock ",[285,997,306],{"class":298},[285,999,1000],{"class":302}," MagicMock\n",[285,1002,1003,1005,1007,1009],{"class":287,"line":312},[285,1004,299],{"class":298},[285,1006,317],{"class":302},[285,1008,306],{"class":298},[285,1010,1011],{"class":302}," Qgis\n",[285,1013,1014,1016,1019,1021],{"class":287,"line":325},[285,1015,299],{"class":298},[285,1017,1018],{"class":302}," plugin_actions ",[285,1020,306],{"class":298},[285,1022,1023],{"class":302}," warn_if_no_layer\n",[285,1025,1026],{"class":287,"line":332},[285,1027,329],{"emptyLinePlaceholder":328},[285,1029,1030],{"class":287,"line":345},[285,1031,329],{"emptyLinePlaceholder":328},[285,1033,1034,1036,1039],{"class":287,"line":350},[285,1035,519],{"class":298},[285,1037,1038],{"class":522}," test_warns_when_no_active_layer",[285,1040,735],{"class":302},[285,1042,1043,1046,1048],{"class":287,"line":380},[285,1044,1045],{"class":302},"    iface ",[285,1047,356],{"class":298},[285,1049,1050],{"class":302}," MagicMock()\n",[285,1052,1053,1056,1058],{"class":287,"line":392},[285,1054,1055],{"class":302},"    iface.activeLayer.return_value ",[285,1057,356],{"class":298},[285,1059,1060],{"class":335}," None\n",[285,1062,1063],{"class":287,"line":407},[285,1064,329],{"emptyLinePlaceholder":328},[285,1066,1067,1070,1072],{"class":287,"line":586},[285,1068,1069],{"class":302},"    handled ",[285,1071,356],{"class":298},[285,1073,1074],{"class":302}," warn_if_no_layer(iface)\n",[285,1076,1077],{"class":287,"line":601},[285,1078,329],{"emptyLinePlaceholder":328},[285,1080,1081,1083,1086,1089],{"class":287,"line":766},[285,1082,888],{"class":298},[285,1084,1085],{"class":302}," handled ",[285,1087,1088],{"class":298},"is",[285,1090,1091],{"class":335}," False\n",[285,1093,1094],{"class":287,"line":777},[285,1095,1096],{"class":302},"    iface.messageBar().pushMessage.assert_called_once()\n",[285,1098,1099],{"class":287,"line":788},[285,1100,1101],{"class":291},"    # confirm the warning level was used\n",[285,1103,1104,1107,1109],{"class":287,"line":800},[285,1105,1106],{"class":302},"    _, kwargs ",[285,1108,356],{"class":298},[285,1110,1111],{"class":302}," iface.messageBar().pushMessage.call_args\n",[285,1113,1114,1116,1119,1122,1124,1126],{"class":287,"line":810},[285,1115,888],{"class":298},[285,1117,1118],{"class":302}," kwargs.get(",[285,1120,1121],{"class":362},"\"level\"",[285,1123,903],{"class":302},[285,1125,906],{"class":298},[285,1127,1128],{"class":302}," Qgis.Warning\n",[276,1130,1132],{"className":278,"code":1131,"language":280,"meta":281,"style":281},"# plugin_actions.py — the code under test\nfrom qgis.core import Qgis\n\n\ndef warn_if_no_layer(iface):\n    \"\"\"Push a warning and return False if there is no active layer.\"\"\"\n    if iface.activeLayer() is None:\n        iface.messageBar().pushMessage(\n            \"No layer\", \"Select a layer first.\",\n            level=Qgis.Warning, duration=3,\n        )\n        return False\n    return True\n",[24,1133,1134,1139,1149,1153,1157,1167,1172,1187,1192,1205,1225,1230,1237],{"__ignoreMap":281},[285,1135,1136],{"class":287,"line":288},[285,1137,1138],{"class":291},"# plugin_actions.py — the code under test\n",[285,1140,1141,1143,1145,1147],{"class":287,"line":295},[285,1142,299],{"class":298},[285,1144,317],{"class":302},[285,1146,306],{"class":298},[285,1148,1011],{"class":302},[285,1150,1151],{"class":287,"line":312},[285,1152,329],{"emptyLinePlaceholder":328},[285,1154,1155],{"class":287,"line":325},[285,1156,329],{"emptyLinePlaceholder":328},[285,1158,1159,1161,1164],{"class":287,"line":332},[285,1160,519],{"class":298},[285,1162,1163],{"class":522}," warn_if_no_layer",[285,1165,1166],{"class":302},"(iface):\n",[285,1168,1169],{"class":287,"line":345},[285,1170,1171],{"class":362},"    \"\"\"Push a warning and return False if there is no active layer.\"\"\"\n",[285,1173,1174,1177,1180,1182,1185],{"class":287,"line":350},[285,1175,1176],{"class":298},"    if",[285,1178,1179],{"class":302}," iface.activeLayer() ",[285,1181,1088],{"class":298},[285,1183,1184],{"class":335}," None",[285,1186,538],{"class":302},[285,1188,1189],{"class":287,"line":380},[285,1190,1191],{"class":302},"        iface.messageBar().pushMessage(\n",[285,1193,1194,1197,1199,1202],{"class":287,"line":392},[285,1195,1196],{"class":362},"            \"No layer\"",[285,1198,366],{"class":302},[285,1200,1201],{"class":362},"\"Select a layer first.\"",[285,1203,1204],{"class":302},",\n",[285,1206,1207,1210,1212,1215,1218,1220,1223],{"class":287,"line":407},[285,1208,1209],{"class":894},"            level",[285,1211,356],{"class":298},[285,1213,1214],{"class":302},"Qgis.Warning, ",[285,1216,1217],{"class":894},"duration",[285,1219,356],{"class":298},[285,1221,1222],{"class":335},"3",[285,1224,1204],{"class":302},[285,1226,1227],{"class":287,"line":586},[285,1228,1229],{"class":302},"        )\n",[285,1231,1232,1235],{"class":287,"line":601},[285,1233,1234],{"class":298},"        return",[285,1236,1091],{"class":335},[285,1238,1239,1241],{"class":287,"line":766},[285,1240,558],{"class":298},[285,1242,1243],{"class":335}," True\n",[14,1245,1246,423,1248,1251,1252,1255,1256,1259,1260,1263,1264,1268],{},[63,1247,422],{},[24,1249,1250],{},"MagicMock"," auto-creates any attribute you access, so ",[24,1253,1254],{},"iface.messageBar().pushMessage"," is callable without defining it. ",[24,1257,1258],{},"assert_called_once()"," verifies the warning fired exactly once, and ",[24,1261,1262],{},"call_args"," inspects the keyword arguments to confirm the severity. This tests the ",[1265,1266,1267],"em",{},"decision"," the function makes without ever opening a window — exactly what you want in headless CI.",[44,1270,1272],{"id":1271},"testing-code-that-runs-processing-algorithms","Testing Code That Runs Processing Algorithms",[14,1274,1275,1276,1278,1279,1282,1283,1286],{},"Many plugins delegate the heavy lifting to the Processing framework rather than hand-rolling geometry loops. That code is testable too, but it needs one extra initialization step: ",[24,1277,273],{}," loads the native provider registry, yet the Processing plugin's own algorithms require ",[24,1280,1281],{},"Processing.initialize()"," before ",[24,1284,1285],{},"processing.run(...)"," will resolve an algorithm id.",[276,1288,1290],{"className":278,"code":1289,"language":280,"meta":281,"style":281},"# tests\u002Ftest_processing.py\nimport processing\nfrom processing.core.Processing import Processing\nfrom qgis.core import QgsVectorLayer\n\n\ndef setup_module():\n    Processing.initialize()\n\n\ndef test_buffer_produces_polygons(point_layer):\n    result = processing.run(\n        \"native:buffer\",\n        {\n            \"INPUT\": point_layer,\n            \"DISTANCE\": 0.01,\n            \"SEGMENTS\": 8,\n            \"OUTPUT\": \"memory:\",\n        },\n    )\n    out = result[\"OUTPUT\"]\n    assert isinstance(out, QgsVectorLayer)\n    assert out.featureCount() == point_layer.featureCount()\n",[24,1291,1292,1297,1304,1316,1327,1331,1335,1344,1349,1353,1357,1367,1377,1384,1389,1397,1410,1421,1433,1438,1442,1459,1470],{"__ignoreMap":281},[285,1293,1294],{"class":287,"line":288},[285,1295,1296],{"class":291},"# tests\u002Ftest_processing.py\n",[285,1298,1299,1301],{"class":287,"line":295},[285,1300,306],{"class":298},[285,1302,1303],{"class":302}," processing\n",[285,1305,1306,1308,1311,1313],{"class":287,"line":312},[285,1307,299],{"class":298},[285,1309,1310],{"class":302}," processing.core.Processing ",[285,1312,306],{"class":298},[285,1314,1315],{"class":302}," Processing\n",[285,1317,1318,1320,1322,1324],{"class":287,"line":325},[285,1319,299],{"class":298},[285,1321,317],{"class":302},[285,1323,306],{"class":298},[285,1325,1326],{"class":302}," QgsVectorLayer\n",[285,1328,1329],{"class":287,"line":332},[285,1330,329],{"emptyLinePlaceholder":328},[285,1332,1333],{"class":287,"line":345},[285,1334,329],{"emptyLinePlaceholder":328},[285,1336,1337,1339,1342],{"class":287,"line":350},[285,1338,519],{"class":298},[285,1340,1341],{"class":522}," setup_module",[285,1343,735],{"class":302},[285,1345,1346],{"class":287,"line":380},[285,1347,1348],{"class":302},"    Processing.initialize()\n",[285,1350,1351],{"class":287,"line":392},[285,1352,329],{"emptyLinePlaceholder":328},[285,1354,1355],{"class":287,"line":407},[285,1356,329],{"emptyLinePlaceholder":328},[285,1358,1359,1361,1364],{"class":287,"line":586},[285,1360,519],{"class":298},[285,1362,1363],{"class":522}," test_buffer_produces_polygons",[285,1365,1366],{"class":302},"(point_layer):\n",[285,1368,1369,1372,1374],{"class":287,"line":601},[285,1370,1371],{"class":302},"    result ",[285,1373,356],{"class":298},[285,1375,1376],{"class":302}," processing.run(\n",[285,1378,1379,1382],{"class":287,"line":766},[285,1380,1381],{"class":362},"        \"native:buffer\"",[285,1383,1204],{"class":302},[285,1385,1386],{"class":287,"line":777},[285,1387,1388],{"class":302},"        {\n",[285,1390,1391,1394],{"class":287,"line":788},[285,1392,1393],{"class":362},"            \"INPUT\"",[285,1395,1396],{"class":302},": point_layer,\n",[285,1398,1399,1402,1405,1408],{"class":287,"line":800},[285,1400,1401],{"class":362},"            \"DISTANCE\"",[285,1403,1404],{"class":302},": ",[285,1406,1407],{"class":335},"0.01",[285,1409,1204],{"class":302},[285,1411,1412,1415,1417,1419],{"class":287,"line":810},[285,1413,1414],{"class":362},"            \"SEGMENTS\"",[285,1416,1404],{"class":302},[285,1418,120],{"class":335},[285,1420,1204],{"class":302},[285,1422,1423,1426,1428,1431],{"class":287,"line":821},[285,1424,1425],{"class":362},"            \"OUTPUT\"",[285,1427,1404],{"class":302},[285,1429,1430],{"class":362},"\"memory:\"",[285,1432,1204],{"class":302},[285,1434,1435],{"class":287,"line":827},[285,1436,1437],{"class":302},"        },\n",[285,1439,1440],{"class":287,"line":833},[285,1441,604],{"class":302},[285,1443,1445,1448,1450,1453,1456],{"class":287,"line":1444},21,[285,1446,1447],{"class":302},"    out ",[285,1449,356],{"class":298},[285,1451,1452],{"class":302}," result[",[285,1454,1455],{"class":362},"\"OUTPUT\"",[285,1457,1458],{"class":302},"]\n",[285,1460,1462,1464,1467],{"class":287,"line":1461},22,[285,1463,888],{"class":298},[285,1465,1466],{"class":335}," isinstance",[285,1468,1469],{"class":302},"(out, QgsVectorLayer)\n",[285,1471,1473,1475,1478,1480],{"class":287,"line":1472},23,[285,1474,888],{"class":298},[285,1476,1477],{"class":302}," out.featureCount() ",[285,1479,906],{"class":298},[285,1481,1482],{"class":302}," point_layer.featureCount()\n",[14,1484,1485,423,1487,1490,1491,1493,1494,1497,1498,1501,1502,1505,1506,1510],{},[63,1486,422],{},[24,1488,1489],{},"setup_module()"," runs once before the module's tests and calls ",[24,1492,1281],{},", registering the bundled algorithms so ",[24,1495,1496],{},"native:buffer"," resolves. The ",[24,1499,1500],{},"\"OUTPUT\": \"memory:\""," sink keeps the result in RAM, avoiding temp-file cleanup. Asserting ",[24,1503,1504],{},"featureCount()"," round-trips — one buffer per input point — verifies the algorithm actually executed rather than silently returning an empty layer. This pattern lets you test a plugin built on the Processing framework, including the custom algorithms covered in ",[18,1507,1509],{"href":1508},"\u002Fqgis-plugin-development\u002Fprocessing-provider-plugins\u002F","Processing Provider Plugins",", with the same speed and determinism as pure-logic tests.",[44,1512,1514],{"id":1513},"running-headless-under-xvfb","Running Headless Under xvfb",[14,1516,1517,1518,1520,1521,1524,1525,1527,1528,1531],{},"Even initialized via ",[24,1519,273],{},", parts of Qt expect an X display. On a headless CI runner there is none, so Qt aborts with ",[24,1522,1523],{},"could not connect to display",". The fix is ",[24,1526,38],{},", a virtual framebuffer that supplies a fake display. ",[24,1529,1530],{},"xvfb-run"," wraps your test command transparently.",[276,1533,1537],{"className":1534,"code":1535,"language":1536,"meta":281,"style":281},"language-bash shiki shiki-themes github-dark","# Run the suite against the QGIS-bundled Python on a headless machine\nexport QT_QPA_PLATFORM=offscreen\nxvfb-run -a python -m pytest tests\u002F -v\n","bash",[24,1538,1539,1544,1557],{"__ignoreMap":281},[285,1540,1541],{"class":287,"line":288},[285,1542,1543],{"class":291},"# Run the suite against the QGIS-bundled Python on a headless machine\n",[285,1545,1546,1549,1552,1554],{"class":287,"line":295},[285,1547,1548],{"class":298},"export",[285,1550,1551],{"class":302}," QT_QPA_PLATFORM",[285,1553,356],{"class":298},[285,1555,1556],{"class":302},"offscreen\n",[285,1558,1559,1561,1564,1567,1570,1573,1576],{"class":287,"line":312},[285,1560,1530],{"class":522},[285,1562,1563],{"class":335}," -a",[285,1565,1566],{"class":362}," python",[285,1568,1569],{"class":335}," -m",[285,1571,1572],{"class":362}," pytest",[285,1574,1575],{"class":362}," tests\u002F",[285,1577,1578],{"class":335}," -v\n",[14,1580,1581,423,1583,1586,1587,1590,1591,1593,1594,1596],{},[63,1582,422],{},[24,1584,1585],{},"xvfb-run -a"," allocates a free display number automatically and tears it down when pytest exits. Setting ",[24,1588,1589],{},"QT_QPA_PLATFORM=offscreen"," is a belt-and-braces measure that tells Qt to render to an offscreen buffer; on many setups it alone is enough, but combined with ",[24,1592,38],{}," it covers widgets that still poke at the X server. Always invoke the QGIS Python, not your system ",[24,1595,280],{},", or the imports fail.",[14,1598,1599,1600,258,1603,1605,1606,1608,1609,1611,1612,1614,1615,1617],{},"The distinction between ",[24,1601,1602],{},"offscreen",[24,1604,38],{}," matters when something goes wrong. The ",[24,1607,1602],{}," platform plugin is a pure Qt feature: it renders without any windowing system at all and is the lightest option. ",[24,1610,38],{}," instead provides a genuine (virtual) X11 server, which a few widgets and OpenGL-backed canvases still require. If a test that instantiates a real widget passes locally but crashes in CI with an X error under ",[24,1613,1602],{}," alone, wrapping the command in ",[24,1616,1530],{}," usually resolves it. Running both together costs almost nothing and eliminates an entire class of flaky, environment-dependent failures.",[44,1619,1621],{"id":1620},"a-github-actions-matrix-across-qgis-versions","A GitHub Actions Matrix Across QGIS Versions",[14,1623,1624,1625,1628],{},"The official ",[24,1626,1627],{},"qgis\u002Fqgis"," Docker images ship a complete QGIS plus its Python, which makes containerized CI dramatically simpler than installing QGIS on a bare runner. A matrix strategy runs the same job once per QGIS tag.",[276,1630,1634],{"className":1631,"code":1632,"language":1633,"meta":281,"style":281},"language-yaml shiki shiki-themes github-dark","# .github\u002Fworkflows\u002Ftests.yml\nname: tests\non: [push, pull_request]\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        qgis: [\"release-3_28\", \"release-3_34\", \"latest\"]\n    container:\n      image: qgis\u002Fqgis:${{ matrix.qgis }}\n    steps:\n      - uses: actions\u002Fcheckout@v4\n      - name: Install test deps\n        run: pip install pytest pytest-cov flake8\n      - name: Lint\n        run: flake8 . --max-line-length=100 --exclude=resources_rc.py\n      - name: Run tests\n        env:\n          QT_QPA_PLATFORM: offscreen\n        run: xvfb-run -a python -m pytest tests\u002F --cov=. -v\n","yaml",[24,1635,1636,1641,1652,1670,1674,1681,1688,1698,1705,1715,1722,1744,1751,1761,1768,1781,1792,1802,1813,1822,1833,1840,1849],{"__ignoreMap":281},[285,1637,1638],{"class":287,"line":288},[285,1639,1640],{"class":291},"# .github\u002Fworkflows\u002Ftests.yml\n",[285,1642,1643,1647,1649],{"class":287,"line":295},[285,1644,1646],{"class":1645},"s4JwU","name",[285,1648,1404],{"class":302},[285,1650,1651],{"class":362},"tests\n",[285,1653,1654,1657,1660,1663,1665,1668],{"class":287,"line":312},[285,1655,1656],{"class":335},"on",[285,1658,1659],{"class":302},": [",[285,1661,1662],{"class":362},"push",[285,1664,366],{"class":302},[285,1666,1667],{"class":362},"pull_request",[285,1669,1458],{"class":302},[285,1671,1672],{"class":287,"line":325},[285,1673,329],{"emptyLinePlaceholder":328},[285,1675,1676,1679],{"class":287,"line":332},[285,1677,1678],{"class":1645},"jobs",[285,1680,538],{"class":302},[285,1682,1683,1686],{"class":287,"line":345},[285,1684,1685],{"class":1645},"  test",[285,1687,538],{"class":302},[285,1689,1690,1693,1695],{"class":287,"line":350},[285,1691,1692],{"class":1645},"    runs-on",[285,1694,1404],{"class":302},[285,1696,1697],{"class":362},"ubuntu-latest\n",[285,1699,1700,1703],{"class":287,"line":380},[285,1701,1702],{"class":1645},"    strategy",[285,1704,538],{"class":302},[285,1706,1707,1710,1712],{"class":287,"line":392},[285,1708,1709],{"class":1645},"      fail-fast",[285,1711,1404],{"class":302},[285,1713,1714],{"class":335},"false\n",[285,1716,1717,1720],{"class":287,"line":407},[285,1718,1719],{"class":1645},"      matrix",[285,1721,538],{"class":302},[285,1723,1724,1727,1729,1732,1734,1737,1739,1742],{"class":287,"line":586},[285,1725,1726],{"class":1645},"        qgis",[285,1728,1659],{"class":302},[285,1730,1731],{"class":362},"\"release-3_28\"",[285,1733,366],{"class":302},[285,1735,1736],{"class":362},"\"release-3_34\"",[285,1738,366],{"class":302},[285,1740,1741],{"class":362},"\"latest\"",[285,1743,1458],{"class":302},[285,1745,1746,1749],{"class":287,"line":601},[285,1747,1748],{"class":1645},"    container",[285,1750,538],{"class":302},[285,1752,1753,1756,1758],{"class":287,"line":766},[285,1754,1755],{"class":1645},"      image",[285,1757,1404],{"class":302},[285,1759,1760],{"class":362},"qgis\u002Fqgis:${{ matrix.qgis }}\n",[285,1762,1763,1766],{"class":287,"line":777},[285,1764,1765],{"class":1645},"    steps",[285,1767,538],{"class":302},[285,1769,1770,1773,1776,1778],{"class":287,"line":788},[285,1771,1772],{"class":302},"      - ",[285,1774,1775],{"class":1645},"uses",[285,1777,1404],{"class":302},[285,1779,1780],{"class":362},"actions\u002Fcheckout@v4\n",[285,1782,1783,1785,1787,1789],{"class":287,"line":800},[285,1784,1772],{"class":302},[285,1786,1646],{"class":1645},[285,1788,1404],{"class":302},[285,1790,1791],{"class":362},"Install test deps\n",[285,1793,1794,1797,1799],{"class":287,"line":810},[285,1795,1796],{"class":1645},"        run",[285,1798,1404],{"class":302},[285,1800,1801],{"class":362},"pip install pytest pytest-cov flake8\n",[285,1803,1804,1806,1808,1810],{"class":287,"line":821},[285,1805,1772],{"class":302},[285,1807,1646],{"class":1645},[285,1809,1404],{"class":302},[285,1811,1812],{"class":362},"Lint\n",[285,1814,1815,1817,1819],{"class":287,"line":827},[285,1816,1796],{"class":1645},[285,1818,1404],{"class":302},[285,1820,1821],{"class":362},"flake8 . --max-line-length=100 --exclude=resources_rc.py\n",[285,1823,1824,1826,1828,1830],{"class":287,"line":833},[285,1825,1772],{"class":302},[285,1827,1646],{"class":1645},[285,1829,1404],{"class":302},[285,1831,1832],{"class":362},"Run tests\n",[285,1834,1835,1838],{"class":287,"line":1444},[285,1836,1837],{"class":1645},"        env",[285,1839,538],{"class":302},[285,1841,1842,1845,1847],{"class":287,"line":1461},[285,1843,1844],{"class":1645},"          QT_QPA_PLATFORM",[285,1846,1404],{"class":302},[285,1848,1556],{"class":362},[285,1850,1851,1853,1855],{"class":287,"line":1472},[285,1852,1796],{"class":1645},[285,1854,1404],{"class":302},[285,1856,1857],{"class":362},"xvfb-run -a python -m pytest tests\u002F --cov=. -v\n",[14,1859,1860,423,1862,1865,1866,1869,1870,1873,1874,1877,1878,1881,1882,1885,1886,1889,1890,1893],{},[63,1861,422],{},[24,1863,1864],{},"fail-fast: false"," lets every QGIS version finish even if one fails, so you see the full compatibility picture in one run. The ",[24,1867,1868],{},"container.image"," pins each job to a QGIS Docker tag — ",[24,1871,1872],{},"release-3_34"," is the LTR baseline, ",[24,1875,1876],{},"release-3_28"," guards your declared minimum, and ",[24,1879,1880],{},"latest"," warns you about upcoming deprecations early. ",[24,1883,1884],{},"flake8"," excludes the generated ",[24,1887,1888],{},"resources_rc.py"," so machine output does not pollute lint results. Coverage via ",[24,1891,1892],{},"pytest-cov"," quantifies how much of your logic the suite actually exercises.",[44,1895,1897],{"id":1896},"build-automation-with-pb_tool-and-paver","Build Automation with pb_tool and paver",[14,1899,1900,1901,1904,1905,1908,1909,366,1912,366,1915,1918,1919,1922,1923,1926,1927,1930,1931,59],{},"CI should also prove the plugin still packages. Two community tools cover this. ",[24,1902,1903],{},"pb_tool"," reads a ",[24,1906,1907],{},"pb_tool.cfg"," manifest and offers ",[24,1910,1911],{},"compile",[24,1913,1914],{},"deploy",[24,1916,1917],{},"zip",", and ",[24,1920,1921],{},"test"," subcommands; ",[24,1924,1925],{},"paver"," is an older convention driven by a ",[24,1928,1929],{},"pavement.py"," script. Either turns a release into one command, the same archive you submit when ",[18,1932,1934],{"href":1933},"\u002Fqgis-plugin-development\u002Fpublishing-to-the-qgis-plugin-repository\u002F","Publishing to the QGIS Plugin Repository",[276,1936,1938],{"className":1534,"code":1937,"language":1536,"meta":281,"style":281},"pip install pb_tool\npb_tool compile          # build resources\u002FUI\npb_tool zip              # produce repository-ready archive\nunzip -l zip_build\u002F*.zip # confirm no __pycache__ or tests leaked in\n",[24,1939,1940,1951,1961,1971],{"__ignoreMap":281},[285,1941,1942,1945,1948],{"class":287,"line":288},[285,1943,1944],{"class":522},"pip",[285,1946,1947],{"class":362}," install",[285,1949,1950],{"class":362}," pb_tool\n",[285,1952,1953,1955,1958],{"class":287,"line":295},[285,1954,1903],{"class":522},[285,1956,1957],{"class":362}," compile",[285,1959,1960],{"class":291},"          # build resources\u002FUI\n",[285,1962,1963,1965,1968],{"class":287,"line":312},[285,1964,1903],{"class":522},[285,1966,1967],{"class":362}," zip",[285,1969,1970],{"class":291},"              # produce repository-ready archive\n",[285,1972,1973,1976,1979,1982,1985,1988],{"class":287,"line":325},[285,1974,1975],{"class":522},"unzip",[285,1977,1978],{"class":335}," -l",[285,1980,1981],{"class":362}," zip_build\u002F",[285,1983,1984],{"class":335},"*",[285,1986,1987],{"class":362},".zip",[285,1989,1990],{"class":291}," # confirm no __pycache__ or tests leaked in\n",[14,1992,1993,1995,1996,1998,1999,2002,2003,2006],{},[63,1994,422],{}," Running ",[24,1997,191],{}," in CI and inspecting the archive listing catches packaging regressions — a stray ",[24,2000,2001],{},"__pycache__",", a missing icon, an accidentally excluded module — before they reach the repository. Because the ",[24,2004,2005],{},"[files]"," manifest is explicit, the build is reproducible regardless of what junk sits in your working tree.",[44,2008,2010],{"id":2009},"linting-and-configuration","Linting and Configuration",[14,2012,2013,2014,2016,2017,2020],{},"Linting is the fastest feedback in the pipeline and catches a category of bugs tests never will — undefined names, unused imports, shadowed variables. Run ",[24,2015,1884],{}," (or ",[24,2018,2019],{},"ruff"," for speed) before the test stage so an obvious typo fails in seconds rather than after a multi-minute matrix. A small project-level config keeps the rules consistent across every machine and every CI job.",[276,2022,2026],{"className":2023,"code":2024,"language":2025,"meta":281,"style":281},"language-ini shiki shiki-themes github-dark","# setup.cfg\n[flake8]\nmax-line-length = 100\nextend-ignore = E203, W503\nexclude = resources_rc.py, .git, __pycache__, ui\u002F*.py\n\n[tool:pytest]\ntestpaths = tests\naddopts = -ra -q\n","ini",[24,2027,2028,2033,2038,2043,2048,2053,2057,2062,2067],{"__ignoreMap":281},[285,2029,2030],{"class":287,"line":288},[285,2031,2032],{},"# setup.cfg\n",[285,2034,2035],{"class":287,"line":295},[285,2036,2037],{},"[flake8]\n",[285,2039,2040],{"class":287,"line":312},[285,2041,2042],{},"max-line-length = 100\n",[285,2044,2045],{"class":287,"line":325},[285,2046,2047],{},"extend-ignore = E203, W503\n",[285,2049,2050],{"class":287,"line":332},[285,2051,2052],{},"exclude = resources_rc.py, .git, __pycache__, ui\u002F*.py\n",[285,2054,2055],{"class":287,"line":345},[285,2056,329],{"emptyLinePlaceholder":328},[285,2058,2059],{"class":287,"line":350},[285,2060,2061],{},"[tool:pytest]\n",[285,2063,2064],{"class":287,"line":380},[285,2065,2066],{},"testpaths = tests\n",[285,2068,2069],{"class":287,"line":392},[285,2070,2071],{},"addopts = -ra -q\n",[14,2073,2074,2076,2077,2079,2080,2083,2084,27,2087,2090,2091,2094,2095,2098,2099,2102,2103,2105,2106,2109],{},[63,2075,422],{}," Generated files (",[24,2078,1888],{},", any statically compiled ",[24,2081,2082],{},"ui\u002F*.py",") are excluded because machine output should not be held to hand-written standards. ",[24,2085,2086],{},"E203",[24,2088,2089],{},"W503"," are ignored to coexist with the ",[24,2092,2093],{},"black"," formatter, which has slightly different opinions about whitespace and line breaks. The ",[24,2096,2097],{},"[tool:pytest]"," block pins ",[24,2100,2101],{},"testpaths"," so a bare ",[24,2104,26],{}," always finds the suite, and ",[24,2107,2108],{},"-ra"," prints a summary of skips and failures at the end of every run. Committing this file means a contributor's local run matches CI exactly, eliminating \"passes on my machine\" lint disputes.",[44,2111,2113],{"id":2112},"compatibility-notes","Compatibility Notes",[2115,2116,2117,2136],"table",{},[2118,2119,2120],"thead",{},[2121,2122,2123,2127,2130,2133],"tr",{},[2124,2125,2126],"th",{},"QGIS line",[2124,2128,2129],{},"Bundled Python",[2124,2131,2132],{},"Docker tag",[2124,2134,2135],{},"Role in matrix",[2137,2138,2139,2159,2174],"tbody",{},[2121,2140,2141,2145,2148,2152],{},[2142,2143,2144],"td",{},"3.28 LTR",[2142,2146,2147],{},"3.9",[2142,2149,2150],{},[24,2151,1876],{},[2142,2153,2154,2155,2158],{},"Guards your declared ",[24,2156,2157],{},"qgisMinimumVersion","; watch for f-string\u002F3.10+ syntax",[2121,2160,2161,2164,2167,2171],{},[2142,2162,2163],{},"3.34 LTR",[2142,2165,2166],{},"3.12",[2142,2168,2169],{},[24,2170,1872],{},[2142,2172,2173],{},"Primary baseline; develop and assert against this",[2121,2175,2176,2179,2181,2185],{},[2142,2177,2178],{},"3.40 \u002F latest",[2142,2180,2166],{},[2142,2182,2183],{},[24,2184,1880],{},[2142,2186,2187],{},"Early warning for deprecations in upcoming releases",[14,2189,2190,2193,2194,2197],{},[24,2191,2192],{},"qgis.testing.start_app()"," and the ",[24,2195,2196],{},"unittest.mock"," approach are stable across the entire 3.x line, so the same test code runs unchanged on every matrix entry. The only version-sensitive parts are API calls inside your logic, which the matrix exists to police.",[44,2199,2201],{"id":2200},"key-takeaways","Key Takeaways",[49,2203,2204,2210,2216,2219,2228,2236],{},[52,2205,2206,2207,2209],{},"PyQGIS only works inside an initialized application; use ",[24,2208,2192],{}," to boot one headlessly.",[52,2211,2212,2213,2215],{},"Keep spatial logic in pure functions that take\u002Freturn QGIS objects; confine ",[24,2214,34],{}," to a thin GUI layer.",[52,2217,2218],{},"Use memory-provider layers as fast, deterministic fixtures with known geometry.",[52,2220,2221,2222,2224,2225,2227],{},"Mock ",[24,2223,34],{}," with ",[24,2226,974],{}," to test GUI decisions without a window.",[52,2229,2230,2231,2224,2233,2235],{},"Run tests under ",[24,2232,1530],{},[24,2234,1589],{}," so Qt has a display in CI.",[52,2237,2238,2239,2241],{},"Drive a GitHub Actions matrix off the ",[24,2240,1627],{}," Docker images to test multiple QGIS versions at once.",[44,2243,2245],{"id":2244},"frequently-asked-questions","Frequently Asked Questions",[14,2247,2248,2251,2252,2254,2255,2257,2258,2260],{},[63,2249,2250],{},"Why do my tests fail with \"QgsApplication not initialized\"?","\nYou ran them under a plain Python interpreter instead of the QGIS-bundled one, or you forgot to call ",[24,2253,273],{},". Tests must use the same Python that ships with QGIS, and ",[24,2256,467],{}," must call ",[24,2259,273],{}," before any fixture creates QGIS objects.",[14,2262,2263,2266,2267,2270],{},[63,2264,2265],{},"Do I need a real shapefile to test layer logic?","\nNo, and you should avoid it. Memory-provider layers (",[24,2268,2269],{},"QgsVectorLayer(\"Polygon?crs=EPSG:3857\", \"x\", \"memory\")",") are faster, deterministic, and leave no files behind. Reserve on-disk fixtures for code that specifically exercises file I\u002FO.",[14,2272,2273,2276,2277,2279,2280,2282,2283,366,2286,2289],{},[63,2274,2275],{},"pytest or unittest?","\nBoth work because ",[24,2278,269],{}," is framework-agnostic. ",[24,2281,26],{}," is recommended for its fixtures, parametrization, and concise assertions, but if your team standardizes on ",[24,2284,2285],{},"unittest.TestCase",[24,2287,2288],{},"qgis.testing.unittest"," provides a compatible base class.",[14,2291,2292,2295,2297,2298,2300,2301,2303],{},[63,2293,2294],{},"How do I test code that calls processing algorithms?",[24,2296,273],{}," initializes the native providers, but third-party algorithms require ",[24,2299,1281],{},". Once initialized you can call ",[24,2302,1285],{}," inside a test exactly as in production code and assert on the output layer.",[14,2305,2306,2309,2310,2313,2314,2316,2317,2320],{},[63,2307,2308],{},"Can I run the GUI dialogs in CI?","\nYou can instantiate and exercise ",[24,2311,2312],{},"QDialog"," subclasses under ",[24,2315,38],{},", but avoid ",[24,2318,2319],{},"exec_()"," which blocks on user input. Test the dialog's logic methods and signal handlers directly instead of opening a modal loop.",[44,2322,2324],{"id":2323},"related","Related",[49,2326,2327,2331,2335,2339,2343],{},[52,2328,2329],{},[18,2330,21],{"href":20},[52,2332,2333],{},[18,2334,58],{"href":57},[52,2336,2337],{},[18,2338,1509],{"href":1508},[52,2340,2341],{},[18,2342,1934],{"href":1933},[52,2344,2345],{},[18,2346,665],{"href":664},[2348,2349,2350],"style",{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}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 .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 .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}",{"title":281,"searchDepth":295,"depth":295,"links":2352},[2353,2354,2355,2356,2357,2358,2359,2360,2361,2362,2363,2364,2365,2366,2367],{"id":46,"depth":295,"text":47},{"id":80,"depth":295,"text":81},{"id":250,"depth":295,"text":251},{"id":471,"depth":295,"text":472},{"id":651,"depth":295,"text":652},{"id":964,"depth":295,"text":965},{"id":1271,"depth":295,"text":1272},{"id":1513,"depth":295,"text":1514},{"id":1620,"depth":295,"text":1621},{"id":1896,"depth":295,"text":1897},{"id":2009,"depth":295,"text":2010},{"id":2112,"depth":295,"text":2113},{"id":2200,"depth":295,"text":2201},{"id":2244,"depth":295,"text":2245},{"id":2323,"depth":295,"text":2324},"Unit test PyQGIS plugin code with pytest, mock iface, run headless QGIS under xvfb, and build a GitHub Actions matrix across QGIS versions with linting and pb_tool.","md",{},"\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins",{"title":5,"description":2368},"qgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Findex","qStF0SkoD2JC8fE_vS_FpGczq5koLyG_Gbs-CiEEcnk",1781781223072]