[{"data":1,"prerenderedAt":1627},["ShallowReactive",2],{"doc:\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Frun-qgis-plugin-tests-in-github-actions":3},{"id":4,"title":5,"body":6,"description":1617,"extension":1618,"meta":1619,"navigation":250,"path":1623,"seo":1624,"stem":1625,"__hash__":1626},"docs\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Frun-qgis-plugin-tests-in-github-actions\u002Findex.md","Run QGIS Plugin Tests in GitHub Actions",{"type":7,"value":8,"toc":1604},"minimark",[9,13,22,31,189,194,216,220,436,474,478,628,641,645,648,658,775,792,798,906,911,983,987,1121,1140,1144,1147,1243,1260,1263,1354,1358,1437,1441,1506,1510,1522,1526,1535,1545,1554,1560,1569,1573,1600],[10,11,5],"h1",{"id":12},"run-qgis-plugin-tests-in-github-actions",[14,15,16,17,21],"p",{},"A test suite that only runs when someone remembers to run it is a suite that stops passing quietly. Continuous integration fixes that, and for a QGIS plugin it is easier than it looks: the official Docker images carry QGIS and its Python bindings, so a workflow is a container, a virtual display and a ",[18,19,20],"code",{},"pytest"," invocation.",[14,23,24,25,30],{},"This recipe belongs to ",[26,27,29],"a",{"href":28},"\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002F","Testing and CI for Plugins",". It covers the workflow file, why a display is still needed for a headless run, testing across several QGIS versions at once, caching, and using the result as a release gate.",[14,32,33],{},[34,35,40,44,48,55,72,81,89,95,100,109,115,119,121,124,127,130,134,137,142,146,149,155,159,162,170,173,177,181,183,185],"svg",{"viewBox":36,"role":37,"ariaLabel":38,"xmlns":39},"0 0 760 268","img","A continuous integration pipeline running the same test suite against three QGIS versions in parallel, with the release job waiting for all three","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[41,42,43],"title",{},"One push, three QGIS versions, one gate",[45,46,47],"desc",{},"A push triggers a matrix of three jobs, each running the plugin's test suite inside a container for a different QGIS release. All three must pass before the packaging and release job runs, so a plugin cannot be published against a version it fails on.",[49,50],"rect",{"x":51,"y":51,"width":52,"height":53,"fill":54},"0","760","268","#f6f3ea",[56,57,58],"defs",{},[59,60,67],"marker",{"id":61,"viewBox":62,"refX":63,"refY":64,"markerWidth":65,"markerHeight":65,"orient":66},"ciArrow","0 0 10 10","8","5","7","auto-start-reverse",[68,69],"path",{"d":70,"fill":71},"M0 0 L10 5 L0 10 z","#2f3b35",[73,74,80],"text",{"x":75,"y":76,"style":77,"fill":78,"textAnchor":79},"380","28","text-anchor:middle;font-size:14px;font-weight:bold;font-family:sans-serif","#17211d","middle","The matrix is the point — one version passing proves little",[49,82],{"x":83,"y":84,"width":85,"height":86,"rx":63,"fill":87,"stroke":78,"style":88},"20","112","128","52","#fffdf7","stroke-width:2.5",[73,90,94],{"x":91,"y":92,"style":93,"fill":78,"textAnchor":79},"84","136","text-anchor:middle;font-size:12px;font-weight:bold;font-family:sans-serif","push \u002F PR",[73,96,99],{"x":91,"y":97,"style":98,"fill":71,"textAnchor":79},"154","text-anchor:middle;font-size:10px;font-family:sans-serif","any branch",[49,101],{"x":102,"y":103,"width":104,"height":105,"rx":63,"fill":106,"stroke":107,"style":108},"204","48","228","56","#eff3ff","#2563eb","stroke-width:2",[73,110,114],{"x":111,"y":112,"style":113,"fill":107,"textAnchor":79},"318","72","text-anchor:middle;font-size:11px;font-weight:bold;font-family:sans-serif","qgis\u002Fqgis:release-3_28",[73,116,118],{"x":111,"y":117,"style":98,"fill":71,"textAnchor":79},"90","pytest · Python 3.9",[49,120],{"x":102,"y":84,"width":104,"height":105,"rx":63,"fill":106,"stroke":107,"style":108},[73,122,123],{"x":111,"y":92,"style":113,"fill":107,"textAnchor":79},"qgis\u002Fqgis:release-3_34",[73,125,126],{"x":111,"y":97,"style":98,"fill":71,"textAnchor":79},"pytest · Python 3.12",[49,128],{"x":102,"y":129,"width":104,"height":105,"rx":63,"fill":106,"stroke":107,"style":108},"176",[73,131,133],{"x":111,"y":132,"style":113,"fill":107,"textAnchor":79},"200","qgis\u002Fqgis:release-3_40",[73,135,126],{"x":111,"y":136,"style":98,"fill":71,"textAnchor":79},"218",[49,138],{"x":139,"y":84,"width":85,"height":105,"rx":63,"fill":140,"stroke":141,"style":88},"492","#edf8e9","#15803d",[73,143,145],{"x":144,"y":92,"style":113,"fill":141,"textAnchor":79},"556","all green?",[73,147,148],{"x":144,"y":97,"style":98,"fill":71,"textAnchor":79},"needs: test",[49,150],{"x":151,"y":84,"width":152,"height":105,"rx":63,"fill":153,"stroke":154,"style":88},"656","88","#eef7f4","#0f766e",[73,156,158],{"x":157,"y":92,"style":113,"fill":154,"textAnchor":79},"700","package",[73,160,161],{"x":157,"y":97,"style":98,"fill":71,"textAnchor":79},"zip + release",[163,164],"line",{"x1":165,"y1":166,"x2":167,"y2":168,"stroke":71,"style":169},"148","126","198","82","stroke-width:2;marker-end:url(#ciArrow)",[163,171],{"x1":165,"y1":172,"x2":167,"y2":172,"stroke":71,"style":169},"138",[163,174],{"x1":165,"y1":175,"x2":167,"y2":176,"stroke":71,"style":169},"150","196",[163,178],{"x1":179,"y1":168,"x2":180,"y2":166,"stroke":71,"style":169},"432","486",[163,182],{"x1":179,"y1":172,"x2":180,"y2":172,"stroke":71,"style":169},[163,184],{"x1":179,"y1":176,"x2":180,"y2":175,"stroke":71,"style":169},[163,186],{"x1":187,"y1":172,"x2":188,"y2":172,"stroke":71,"style":169},"620","650",[190,191,193],"h2",{"id":192},"prerequisites","Prerequisites",[195,196,197,206,209],"ul",{},[198,199,200,201,205],"li",{},"A plugin with tests that pass locally — see ",[26,202,204],{"href":203},"\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Funit-test-qgis-plugin-with-pytest\u002F","Unit Test a QGIS Plugin with pytest",".",[198,207,208],{},"A GitHub repository with Actions enabled.",[198,210,211,212,215],{},"Tests that do not need a real display beyond what ",[18,213,214],{},"xvfb"," provides.",[190,217,219],{"id":218},"a-working-workflow","A working workflow",[221,222,227],"pre",{"className":223,"code":224,"language":225,"meta":226,"style":226},"language-yaml shiki shiki-themes github-dark","name: tests\n\non:\n  push:\n  pull_request:\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    container:\n      image: qgis\u002Fqgis:release-3_34\n    steps:\n      - uses: actions\u002Fcheckout@v4\n\n      - name: Install test dependencies\n        run: pip3 install --break-system-packages pytest pytest-cov\n\n      - name: Run tests\n        env:\n          QT_QPA_PLATFORM: offscreen\n          PYTHONPATH: ${{ github.workspace }}\n        run: xvfb-run -a pytest tests\u002F -v --cov=parcel_tools\n","yaml","",[18,228,229,245,252,262,270,278,283,291,299,310,318,329,337,351,356,368,379,384,396,404,415,426],{"__ignoreMap":226},[230,231,233,237,241],"span",{"class":163,"line":232},1,[230,234,236],{"class":235},"s4JwU","name",[230,238,240],{"class":239},"s95oV",": ",[230,242,244],{"class":243},"sU2Wk","tests\n",[230,246,248],{"class":163,"line":247},2,[230,249,251],{"emptyLinePlaceholder":250},true,"\n",[230,253,255,259],{"class":163,"line":254},3,[230,256,258],{"class":257},"sDLfK","on",[230,260,261],{"class":239},":\n",[230,263,265,268],{"class":163,"line":264},4,[230,266,267],{"class":235},"  push",[230,269,261],{"class":239},[230,271,273,276],{"class":163,"line":272},5,[230,274,275],{"class":235},"  pull_request",[230,277,261],{"class":239},[230,279,281],{"class":163,"line":280},6,[230,282,251],{"emptyLinePlaceholder":250},[230,284,286,289],{"class":163,"line":285},7,[230,287,288],{"class":235},"jobs",[230,290,261],{"class":239},[230,292,294,297],{"class":163,"line":293},8,[230,295,296],{"class":235},"  test",[230,298,261],{"class":239},[230,300,302,305,307],{"class":163,"line":301},9,[230,303,304],{"class":235},"    runs-on",[230,306,240],{"class":239},[230,308,309],{"class":243},"ubuntu-latest\n",[230,311,313,316],{"class":163,"line":312},10,[230,314,315],{"class":235},"    container",[230,317,261],{"class":239},[230,319,321,324,326],{"class":163,"line":320},11,[230,322,323],{"class":235},"      image",[230,325,240],{"class":239},[230,327,328],{"class":243},"qgis\u002Fqgis:release-3_34\n",[230,330,332,335],{"class":163,"line":331},12,[230,333,334],{"class":235},"    steps",[230,336,261],{"class":239},[230,338,340,343,346,348],{"class":163,"line":339},13,[230,341,342],{"class":239},"      - ",[230,344,345],{"class":235},"uses",[230,347,240],{"class":239},[230,349,350],{"class":243},"actions\u002Fcheckout@v4\n",[230,352,354],{"class":163,"line":353},14,[230,355,251],{"emptyLinePlaceholder":250},[230,357,359,361,363,365],{"class":163,"line":358},15,[230,360,342],{"class":239},[230,362,236],{"class":235},[230,364,240],{"class":239},[230,366,367],{"class":243},"Install test dependencies\n",[230,369,371,374,376],{"class":163,"line":370},16,[230,372,373],{"class":235},"        run",[230,375,240],{"class":239},[230,377,378],{"class":243},"pip3 install --break-system-packages pytest pytest-cov\n",[230,380,382],{"class":163,"line":381},17,[230,383,251],{"emptyLinePlaceholder":250},[230,385,387,389,391,393],{"class":163,"line":386},18,[230,388,342],{"class":239},[230,390,236],{"class":235},[230,392,240],{"class":239},[230,394,395],{"class":243},"Run tests\n",[230,397,399,402],{"class":163,"line":398},19,[230,400,401],{"class":235},"        env",[230,403,261],{"class":239},[230,405,407,410,412],{"class":163,"line":406},20,[230,408,409],{"class":235},"          QT_QPA_PLATFORM",[230,411,240],{"class":239},[230,413,414],{"class":243},"offscreen\n",[230,416,418,421,423],{"class":163,"line":417},21,[230,419,420],{"class":235},"          PYTHONPATH",[230,422,240],{"class":239},[230,424,425],{"class":243},"${{ github.workspace }}\n",[230,427,429,431,433],{"class":163,"line":428},22,[230,430,373],{"class":235},[230,432,240],{"class":239},[230,434,435],{"class":243},"xvfb-run -a pytest tests\u002F -v --cov=parcel_tools\n",[14,437,438,442,443,446,447,450,451,454,455,458,459,462,463,462,466,469,470,473],{},[439,440,441],"strong",{},"Breakdown:"," Running the job ",[439,444,445],{},"inside"," the QGIS container is what makes ",[18,448,449],{},"import qgis.core"," work without installing anything — the image already has QGIS, its bindings and a matched GDAL and PROJ. ",[18,452,453],{},"--break-system-packages"," is required because the image's Python is externally managed; that is acceptable here because the container is disposable. Setting ",[18,456,457],{},"PYTHONPATH"," to the workspace lets the tests import the plugin package from the repository root. Both ",[18,460,461],{},"QT_QPA_PLATFORM=offscreen"," ",[439,464,465],{},"and",[18,467,468],{},"xvfb-run"," appear, and the redundancy is deliberate: the offscreen platform covers most cases, while a handful of QGIS classes still initialise an X connection, and ",[18,471,472],{},"xvfb-run -a"," gives them one to connect to.",[190,475,477],{"id":476},"test-across-qgis-versions","Test across QGIS versions",[221,479,481],{"className":223,"code":480,"language":225,"meta":226,"style":226},"jobs:\n  test:\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        qgis: [\"release-3_28\", \"release-3_34\", \"release-3_40\"]\n    container:\n      image: qgis\u002Fqgis:${{ matrix.qgis }}\n    steps:\n      - uses: actions\u002Fcheckout@v4\n      - run: pip3 install --break-system-packages pytest\n      - run: xvfb-run -a pytest tests\u002F -v\n        env:\n          QT_QPA_PLATFORM: offscreen\n          PYTHONPATH: ${{ github.workspace }}\n",[18,482,483,489,495,503,510,520,527,552,558,567,573,583,595,606,612,620],{"__ignoreMap":226},[230,484,485,487],{"class":163,"line":232},[230,486,288],{"class":235},[230,488,261],{"class":239},[230,490,491,493],{"class":163,"line":247},[230,492,296],{"class":235},[230,494,261],{"class":239},[230,496,497,499,501],{"class":163,"line":254},[230,498,304],{"class":235},[230,500,240],{"class":239},[230,502,309],{"class":243},[230,504,505,508],{"class":163,"line":264},[230,506,507],{"class":235},"    strategy",[230,509,261],{"class":239},[230,511,512,515,517],{"class":163,"line":272},[230,513,514],{"class":235},"      fail-fast",[230,516,240],{"class":239},[230,518,519],{"class":257},"false\n",[230,521,522,525],{"class":163,"line":280},[230,523,524],{"class":235},"      matrix",[230,526,261],{"class":239},[230,528,529,532,535,538,541,544,546,549],{"class":163,"line":285},[230,530,531],{"class":235},"        qgis",[230,533,534],{"class":239},": [",[230,536,537],{"class":243},"\"release-3_28\"",[230,539,540],{"class":239},", ",[230,542,543],{"class":243},"\"release-3_34\"",[230,545,540],{"class":239},[230,547,548],{"class":243},"\"release-3_40\"",[230,550,551],{"class":239},"]\n",[230,553,554,556],{"class":163,"line":293},[230,555,315],{"class":235},[230,557,261],{"class":239},[230,559,560,562,564],{"class":163,"line":301},[230,561,323],{"class":235},[230,563,240],{"class":239},[230,565,566],{"class":243},"qgis\u002Fqgis:${{ matrix.qgis }}\n",[230,568,569,571],{"class":163,"line":312},[230,570,334],{"class":235},[230,572,261],{"class":239},[230,574,575,577,579,581],{"class":163,"line":320},[230,576,342],{"class":239},[230,578,345],{"class":235},[230,580,240],{"class":239},[230,582,350],{"class":243},[230,584,585,587,590,592],{"class":163,"line":331},[230,586,342],{"class":239},[230,588,589],{"class":235},"run",[230,591,240],{"class":239},[230,593,594],{"class":243},"pip3 install --break-system-packages pytest\n",[230,596,597,599,601,603],{"class":163,"line":339},[230,598,342],{"class":239},[230,600,589],{"class":235},[230,602,240],{"class":239},[230,604,605],{"class":243},"xvfb-run -a pytest tests\u002F -v\n",[230,607,608,610],{"class":163,"line":353},[230,609,401],{"class":235},[230,611,261],{"class":239},[230,613,614,616,618],{"class":163,"line":358},[230,615,409],{"class":235},[230,617,240],{"class":239},[230,619,414],{"class":243},[230,621,622,624,626],{"class":163,"line":370},[230,623,420],{"class":235},[230,625,240],{"class":239},[230,627,425],{"class":243},[14,629,630,632,633,636,637,640],{},[439,631,441],{}," The matrix runs the same suite once per image, in parallel. ",[18,634,635],{},"fail-fast: false"," is important — the default cancels the remaining jobs on the first failure, and when you are specifically looking for a version-specific break, seeing which versions pass is the whole answer. Covering the oldest LTR your ",[18,638,639],{},"metadata.txt"," claims to support plus the newest release is the minimum honest test of that claim; the compatibility notes on every page of this site exist because those differences are real.",[190,642,644],{"id":643},"write-tests-that-survive-ci","Write tests that survive CI",[14,646,647],{},"Two habits make the difference between a suite that runs anywhere and one that only runs on your machine.",[14,649,650,653,654,657],{},[439,651,652],{},"Initialise QGIS once per session."," Creating and destroying ",[18,655,656],{},"QgsApplication"," repeatedly is slow and, in some builds, unstable.",[221,659,663],{"className":660,"code":661,"language":662,"meta":226,"style":226},"language-python shiki shiki-themes github-dark","import pytest\nfrom qgis.core import QgsApplication\n\n@pytest.fixture(scope=\"session\")\ndef qgis_app():\n    app = QgsApplication([], False)\n    app.initQgis()\n    from processing.core.Processing import Processing\n    Processing.initialize()\n    yield app\n    app.exitQgis()\n","python",[18,664,665,674,687,691,713,724,739,744,757,762,770],{"__ignoreMap":226},[230,666,667,671],{"class":163,"line":232},[230,668,670],{"class":669},"snl16","import",[230,672,673],{"class":239}," pytest\n",[230,675,676,679,682,684],{"class":163,"line":247},[230,677,678],{"class":669},"from",[230,680,681],{"class":239}," qgis.core ",[230,683,670],{"class":669},[230,685,686],{"class":239}," QgsApplication\n",[230,688,689],{"class":163,"line":254},[230,690,251],{"emptyLinePlaceholder":250},[230,692,693,697,700,704,707,710],{"class":163,"line":264},[230,694,696],{"class":695},"svObZ","@pytest.fixture",[230,698,699],{"class":239},"(",[230,701,703],{"class":702},"s9osk","scope",[230,705,706],{"class":669},"=",[230,708,709],{"class":243},"\"session\"",[230,711,712],{"class":239},")\n",[230,714,715,718,721],{"class":163,"line":272},[230,716,717],{"class":669},"def",[230,719,720],{"class":695}," qgis_app",[230,722,723],{"class":239},"():\n",[230,725,726,729,731,734,737],{"class":163,"line":280},[230,727,728],{"class":239},"    app ",[230,730,706],{"class":669},[230,732,733],{"class":239}," QgsApplication([], ",[230,735,736],{"class":257},"False",[230,738,712],{"class":239},[230,740,741],{"class":163,"line":285},[230,742,743],{"class":239},"    app.initQgis()\n",[230,745,746,749,752,754],{"class":163,"line":293},[230,747,748],{"class":669},"    from",[230,750,751],{"class":239}," processing.core.Processing ",[230,753,670],{"class":669},[230,755,756],{"class":239}," Processing\n",[230,758,759],{"class":163,"line":301},[230,760,761],{"class":239},"    Processing.initialize()\n",[230,763,764,767],{"class":163,"line":312},[230,765,766],{"class":669},"    yield",[230,768,769],{"class":239}," app\n",[230,771,772],{"class":163,"line":320},[230,773,774],{"class":239},"    app.exitQgis()\n",[14,776,777,779,780,783,784,787,788,205],{},[439,778,441],{}," A session-scoped fixture pays the start-up cost once for the whole run. Yielding the application rather than returning it lets the teardown run after the last test. ",[18,781,782],{},"Processing.initialize()"," belongs here too, because a test that calls ",[18,785,786],{},"processing.run()"," fails with \"algorithm not found\" without it — the same trap described in ",[26,789,791],{"href":790},"\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002F","Headless QGIS and Server Automation",[14,793,794,797],{},[439,795,796],{},"Build fixtures rather than shipping data files."," A memory layer created in a fixture has no path, no permissions and no encoding surprises:",[221,799,801],{"className":660,"code":800,"language":662,"meta":226,"style":226},"@pytest.fixture\ndef parcels():\n    from qgis.core import QgsVectorLayer, QgsFeature, QgsGeometry\n    layer = QgsVectorLayer(\"Polygon?crs=EPSG:27700&field=ref:string&field=area:double\",\n                           \"parcels\", \"memory\")\n    feature = QgsFeature(layer.fields())\n    feature.setGeometry(QgsGeometry.fromWkt(\"POLYGON((0 0, 0 10, 10 10, 10 0, 0 0))\"))\n    feature.setAttributes([\"P-001\", 100.0])\n    layer.dataProvider().addFeatures([feature])\n    return layer\n",[18,802,803,808,817,828,844,856,866,877,893,898],{"__ignoreMap":226},[230,804,805],{"class":163,"line":232},[230,806,807],{"class":695},"@pytest.fixture\n",[230,809,810,812,815],{"class":163,"line":247},[230,811,717],{"class":669},[230,813,814],{"class":695}," parcels",[230,816,723],{"class":239},[230,818,819,821,823,825],{"class":163,"line":254},[230,820,748],{"class":669},[230,822,681],{"class":239},[230,824,670],{"class":669},[230,826,827],{"class":239}," QgsVectorLayer, QgsFeature, QgsGeometry\n",[230,829,830,833,835,838,841],{"class":163,"line":264},[230,831,832],{"class":239},"    layer ",[230,834,706],{"class":669},[230,836,837],{"class":239}," QgsVectorLayer(",[230,839,840],{"class":243},"\"Polygon?crs=EPSG:27700&field=ref:string&field=area:double\"",[230,842,843],{"class":239},",\n",[230,845,846,849,851,854],{"class":163,"line":272},[230,847,848],{"class":243},"                           \"parcels\"",[230,850,540],{"class":239},[230,852,853],{"class":243},"\"memory\"",[230,855,712],{"class":239},[230,857,858,861,863],{"class":163,"line":280},[230,859,860],{"class":239},"    feature ",[230,862,706],{"class":669},[230,864,865],{"class":239}," QgsFeature(layer.fields())\n",[230,867,868,871,874],{"class":163,"line":285},[230,869,870],{"class":239},"    feature.setGeometry(QgsGeometry.fromWkt(",[230,872,873],{"class":243},"\"POLYGON((0 0, 0 10, 10 10, 10 0, 0 0))\"",[230,875,876],{"class":239},"))\n",[230,878,879,882,885,887,890],{"class":163,"line":293},[230,880,881],{"class":239},"    feature.setAttributes([",[230,883,884],{"class":243},"\"P-001\"",[230,886,540],{"class":239},[230,888,889],{"class":257},"100.0",[230,891,892],{"class":239},"])\n",[230,894,895],{"class":163,"line":301},[230,896,897],{"class":239},"    layer.dataProvider().addFeatures([feature])\n",[230,899,900,903],{"class":163,"line":312},[230,901,902],{"class":669},"    return",[230,904,905],{"class":239}," layer\n",[14,907,908,910],{},[439,909,441],{}," The URI syntax defines the CRS and fields inline, so the fixture is self-contained and readable. WKT geometry keeps the test's intent visible — a reviewer can see the square without opening a file. Tests built this way run identically on every machine and in every container, and they fail for reasons that are about your code rather than about a missing file.",[14,912,913],{},[34,914,917,920,923,926,929,937,942,947,950,953,957,961,964,968,971,974,977,980],{"viewBox":915,"role":37,"ariaLabel":916,"xmlns":39},"0 0 760 254","Comparison of a test suite depending on external data files against one building fixtures in memory, showing the failure modes each is exposed to",[41,918,919],{},"File-based fixtures against in-memory ones",[45,921,922],{},"A suite that loads shapefiles from a test data directory can fail because of a missing file, a path separator, a locale-dependent encoding or a repository that grew large. A suite that builds memory layers in fixtures has none of those failure modes and runs identically everywhere.",[49,924],{"x":51,"y":51,"width":52,"height":925,"fill":54},"254",[73,927,928],{"x":75,"y":76,"style":77,"fill":78,"textAnchor":79},"Fewer moving parts, fewer red builds that mean nothing",[49,930],{"x":931,"y":103,"width":932,"height":933,"rx":934,"fill":935,"stroke":936,"style":88},"16","356","180","10","#fdf2e2","#b45309",[73,938,941],{"x":939,"y":940,"style":93,"fill":936,"textAnchor":79},"194","74","tests\u002Fdata\u002F*.shp",[73,943,946],{"x":939,"y":944,"style":945,"fill":71,"textAnchor":79},"102","text-anchor:middle;font-size:11px;font-family:sans-serif","a missing sidecar file",[73,948,949],{"x":939,"y":166,"style":945,"fill":71,"textAnchor":79},"a path separator difference",[73,951,952],{"x":939,"y":175,"style":945,"fill":71,"textAnchor":79},"an encoding that depends on locale",[73,954,956],{"x":939,"y":955,"style":945,"fill":71,"textAnchor":79},"174","a repository nobody wants to clone",[73,958,960],{"x":939,"y":959,"style":945,"fill":936,"textAnchor":79},"206","four ways to fail before reaching your code",[49,962],{"x":963,"y":103,"width":932,"height":933,"rx":934,"fill":140,"stroke":141,"style":88},"388",[73,965,967],{"x":966,"y":940,"style":93,"fill":141,"textAnchor":79},"566","memory layer fixtures",[73,969,970],{"x":966,"y":944,"style":945,"fill":71,"textAnchor":79},"no files at all",[73,972,973],{"x":966,"y":166,"style":945,"fill":71,"textAnchor":79},"CRS and fields declared inline",[73,975,976],{"x":966,"y":175,"style":945,"fill":71,"textAnchor":79},"geometry visible as WKT",[73,978,979],{"x":966,"y":955,"style":945,"fill":71,"textAnchor":79},"the repository stays small",[73,981,982],{"x":966,"y":959,"style":945,"fill":141,"textAnchor":79},"a red build means a real bug",[190,984,986],{"id":985},"gate-the-release-on-the-tests","Gate the release on the tests",[221,988,990],{"className":223,"code":989,"language":225,"meta":226,"style":226},"  package:\n    needs: test\n    if: startsWith(github.ref, 'refs\u002Ftags\u002Fv')\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\u002Fcheckout@v4\n      - name: Build plugin zip\n        run: |\n          NAME=parcel_tools\n          mkdir -p dist\n          zip -r \"dist\u002F${NAME}.zip\" \"$NAME\" \\\n            -x \"*.pyc\" \"*\u002F__pycache__\u002F*\" \"*\u002Ftests\u002F*\"\n      - uses: actions\u002Fupload-artifact@v4\n        with:\n          name: plugin-zip\n          path: dist\u002F*.zip\n",[18,991,992,999,1009,1019,1027,1033,1043,1054,1063,1068,1073,1078,1083,1094,1101,1111],{"__ignoreMap":226},[230,993,994,997],{"class":163,"line":232},[230,995,996],{"class":235},"  package",[230,998,261],{"class":239},[230,1000,1001,1004,1006],{"class":163,"line":247},[230,1002,1003],{"class":235},"    needs",[230,1005,240],{"class":239},[230,1007,1008],{"class":243},"test\n",[230,1010,1011,1014,1016],{"class":163,"line":254},[230,1012,1013],{"class":235},"    if",[230,1015,240],{"class":239},[230,1017,1018],{"class":243},"startsWith(github.ref, 'refs\u002Ftags\u002Fv')\n",[230,1020,1021,1023,1025],{"class":163,"line":264},[230,1022,304],{"class":235},[230,1024,240],{"class":239},[230,1026,309],{"class":243},[230,1028,1029,1031],{"class":163,"line":272},[230,1030,334],{"class":235},[230,1032,261],{"class":239},[230,1034,1035,1037,1039,1041],{"class":163,"line":280},[230,1036,342],{"class":239},[230,1038,345],{"class":235},[230,1040,240],{"class":239},[230,1042,350],{"class":243},[230,1044,1045,1047,1049,1051],{"class":163,"line":285},[230,1046,342],{"class":239},[230,1048,236],{"class":235},[230,1050,240],{"class":239},[230,1052,1053],{"class":243},"Build plugin zip\n",[230,1055,1056,1058,1060],{"class":163,"line":293},[230,1057,373],{"class":235},[230,1059,240],{"class":239},[230,1061,1062],{"class":669},"|\n",[230,1064,1065],{"class":163,"line":301},[230,1066,1067],{"class":243},"          NAME=parcel_tools\n",[230,1069,1070],{"class":163,"line":312},[230,1071,1072],{"class":243},"          mkdir -p dist\n",[230,1074,1075],{"class":163,"line":320},[230,1076,1077],{"class":243},"          zip -r \"dist\u002F${NAME}.zip\" \"$NAME\" \\\n",[230,1079,1080],{"class":163,"line":331},[230,1081,1082],{"class":243},"            -x \"*.pyc\" \"*\u002F__pycache__\u002F*\" \"*\u002Ftests\u002F*\"\n",[230,1084,1085,1087,1089,1091],{"class":163,"line":339},[230,1086,342],{"class":239},[230,1088,345],{"class":235},[230,1090,240],{"class":239},[230,1092,1093],{"class":243},"actions\u002Fupload-artifact@v4\n",[230,1095,1096,1099],{"class":163,"line":353},[230,1097,1098],{"class":235},"        with",[230,1100,261],{"class":239},[230,1102,1103,1106,1108],{"class":163,"line":358},[230,1104,1105],{"class":235},"          name",[230,1107,240],{"class":239},[230,1109,1110],{"class":243},"plugin-zip\n",[230,1112,1113,1116,1118],{"class":163,"line":370},[230,1114,1115],{"class":235},"          path",[230,1117,240],{"class":239},[230,1119,1120],{"class":243},"dist\u002F*.zip\n",[14,1122,1123,462,1125,1127,1128,1131,1132,1135,1136,205],{},[439,1124,441],{},[18,1126,148],{}," makes this job wait for every matrix entry to pass, so a tag can never produce a package from a failing commit. The ",[18,1129,1130],{},"if"," condition restricts packaging to version tags, keeping ordinary pushes cheap. Excluding ",[18,1133,1134],{},"__pycache__"," and the tests directory from the zip matters because the QGIS plugin repository rejects archives containing compiled artefacts, and the packaging rules are covered in ",[26,1137,1139],{"href":1138},"\u002Fqgis-plugin-development\u002Fpublishing-to-the-qgis-plugin-repository\u002Fwrite-metadata-txt-qgis-plugin\u002F","Write metadata.txt for a QGIS Plugin",[190,1141,1143],{"id":1142},"make-the-feedback-fast-enough-to-be-used","Make the feedback fast enough to be used",[14,1145,1146],{},"A suite nobody waits for is a suite people stop reading. Three changes keep a QGIS matrix inside a couple of minutes.",[221,1148,1150],{"className":223,"code":1149,"language":225,"meta":226,"style":226},"      - name: Cache pip\n        uses: actions\u002Fcache@v4\n        with:\n          path: ~\u002F.cache\u002Fpip\n          key: pip-${{ matrix.qgis }}-${{ hashFiles('requirements-dev.txt') }}\n\n      - name: Run tests\n        run: xvfb-run -a pytest tests\u002F -q -x --durations=10 -p no:cacheprovider\n        env:\n          QT_QPA_PLATFORM: offscreen\n          PYTHONPATH: ${{ github.workspace }}\n",[18,1151,1152,1163,1173,1179,1188,1198,1202,1212,1221,1227,1235],{"__ignoreMap":226},[230,1153,1154,1156,1158,1160],{"class":163,"line":232},[230,1155,342],{"class":239},[230,1157,236],{"class":235},[230,1159,240],{"class":239},[230,1161,1162],{"class":243},"Cache pip\n",[230,1164,1165,1168,1170],{"class":163,"line":247},[230,1166,1167],{"class":235},"        uses",[230,1169,240],{"class":239},[230,1171,1172],{"class":243},"actions\u002Fcache@v4\n",[230,1174,1175,1177],{"class":163,"line":254},[230,1176,1098],{"class":235},[230,1178,261],{"class":239},[230,1180,1181,1183,1185],{"class":163,"line":264},[230,1182,1115],{"class":235},[230,1184,240],{"class":239},[230,1186,1187],{"class":243},"~\u002F.cache\u002Fpip\n",[230,1189,1190,1193,1195],{"class":163,"line":272},[230,1191,1192],{"class":235},"          key",[230,1194,240],{"class":239},[230,1196,1197],{"class":243},"pip-${{ matrix.qgis }}-${{ hashFiles('requirements-dev.txt') }}\n",[230,1199,1200],{"class":163,"line":280},[230,1201,251],{"emptyLinePlaceholder":250},[230,1203,1204,1206,1208,1210],{"class":163,"line":285},[230,1205,342],{"class":239},[230,1207,236],{"class":235},[230,1209,240],{"class":239},[230,1211,395],{"class":243},[230,1213,1214,1216,1218],{"class":163,"line":293},[230,1215,373],{"class":235},[230,1217,240],{"class":239},[230,1219,1220],{"class":243},"xvfb-run -a pytest tests\u002F -q -x --durations=10 -p no:cacheprovider\n",[230,1222,1223,1225],{"class":163,"line":301},[230,1224,401],{"class":235},[230,1226,261],{"class":239},[230,1228,1229,1231,1233],{"class":163,"line":312},[230,1230,409],{"class":235},[230,1232,240],{"class":239},[230,1234,414],{"class":243},[230,1236,1237,1239,1241],{"class":163,"line":320},[230,1238,420],{"class":235},[230,1240,240],{"class":239},[230,1242,425],{"class":243},[14,1244,1245,1247,1248,1251,1252,1255,1256,1259],{},[439,1246,441],{}," Keying the cache on both the QGIS image and a hash of the requirements file means each matrix entry keeps its own wheels and the cache invalidates precisely when a dependency changes. ",[18,1249,1250],{},"-x"," stops at the first failure, which is what you want on a pull request — the second failure is usually the first one repeating. ",[18,1253,1254],{},"--durations=10"," prints the ten slowest tests, and reading that list once is normally enough to find the fixture that should have been session-scoped. ",[18,1257,1258],{},"-q"," keeps the log readable, since a wall of dots is easier to scan than a wall of names.",[14,1261,1262],{},"Split the workflow when the suite grows. Fast unit tests can run on every push against a single QGIS version, while the full matrix runs on pull requests and on the main branch — the arrangement gives a thirty-second answer during development and full coverage before anything merges. Uploading the coverage report as an artefact, rather than gating on a coverage percentage, tends to be the more useful trade: it puts the information in front of a reviewer without failing builds for a number that rarely reflects test quality.",[14,1264,1265],{},[34,1266,1269,1272,1275,1278,1281,1287,1296,1300,1303,1307,1311,1314,1317,1320,1322,1325,1329,1332,1334,1339,1343,1347,1350],{"viewBox":1267,"role":37,"ariaLabel":1268,"xmlns":39},"0 0 760 244","Two continuous integration timelines: a cold run reinstalling dependencies for each matrix entry, and a cached run where installation is near-instant and the tests dominate",[41,1270,1271],{},"Where a matrix build spends its minutes",[45,1273,1274],{},"Without a dependency cache, each of three matrix entries spends most of its time installing packages before the tests begin. With the cache restored, installation shrinks to a few seconds and the run is dominated by the tests themselves, cutting total feedback time by more than half.",[49,1276],{"x":51,"y":51,"width":52,"height":1277,"fill":54},"244",[73,1279,1280],{"x":75,"y":76,"style":77,"fill":78,"textAnchor":79},"The tests were never the slow part",[73,1282,1286],{"x":1283,"y":1284,"style":1285,"fill":936},"24","80","font-size:12px;font-weight:bold;font-family:sans-serif","cold",[49,1288],{"x":1289,"y":1290,"width":1291,"height":1292,"rx":1293,"fill":87,"stroke":1294,"style":1295},"140","62","60","26","4","#59645f","stroke-width:1.5",[73,1297,1299],{"x":1298,"y":1284,"style":98,"fill":71,"textAnchor":79},"170","checkout",[49,1301],{"x":102,"y":1290,"width":1302,"height":1292,"rx":1293,"fill":935,"stroke":936,"style":108},"330",[73,1304,1306],{"x":1305,"y":1284,"style":98,"fill":71,"textAnchor":79},"369","pip install — downloaded again for every entry",[49,1308],{"x":1309,"y":1290,"width":1310,"height":1292,"rx":1293,"fill":153,"stroke":154,"style":1295},"538","182",[73,1312,20],{"x":1313,"y":1284,"style":98,"fill":71,"textAnchor":79},"629",[73,1315,1316],{"x":1283,"y":175,"style":1285,"fill":141},"cached",[49,1318],{"x":1289,"y":1319,"width":1291,"height":1292,"rx":1293,"fill":87,"stroke":1294,"style":1295},"132",[73,1321,1299],{"x":1298,"y":175,"style":98,"fill":71,"textAnchor":79},[49,1323],{"x":102,"y":1319,"width":1324,"height":1292,"rx":1293,"fill":140,"stroke":141,"style":108},"70",[73,1326,1328],{"x":1327,"y":175,"style":98,"fill":71,"textAnchor":79},"239","restore",[49,1330],{"x":1331,"y":1319,"width":1310,"height":1292,"rx":1293,"fill":153,"stroke":154,"style":1295},"278",[73,1333,20],{"x":1305,"y":175,"style":98,"fill":71,"textAnchor":79},[73,1335,1338],{"x":1336,"y":175,"style":1337,"fill":141},"500","font-size:11px;font-family:sans-serif","done, in under a third of the time",[163,1340],{"x1":1289,"y1":1341,"x2":1342,"y2":1341,"stroke":1294,"style":1295},"192","720",[73,1344,1346],{"x":1289,"y":1345,"style":98,"fill":1294,"textAnchor":79},"210","0:00",[73,1348,1349],{"x":1342,"y":1345,"style":98,"fill":1294,"textAnchor":79},"feedback",[73,1351,1353],{"x":75,"y":1352,"style":945,"fill":1294,"textAnchor":79},"232","Key the cache on the image and a hash of the requirements file, so it invalidates exactly when it should",[190,1355,1357],{"id":1356},"qgis-version-compatibility","QGIS version compatibility",[1359,1360,1361,1380],"table",{},[1362,1363,1364],"thead",{},[1365,1366,1367,1371,1374,1377],"tr",{},[1368,1369,1370],"th",{},"Image tag",[1368,1372,1373],{},"QGIS",[1368,1375,1376],{},"Python",[1368,1378,1379],{},"Notes",[1381,1382,1383,1399,1412,1424],"tbody",{},[1365,1384,1385,1388,1391,1394],{},[1386,1387,114],"td",{},[1386,1389,1390],{},"3.28 LTR",[1386,1392,1393],{},"3.9",[1386,1395,1396,1398],{},[18,1397,453],{}," not needed.",[1365,1400,1401,1403,1406,1409],{},[1386,1402,123],{},[1386,1404,1405],{},"3.34 LTR",[1386,1407,1408],{},"3.12",[1386,1410,1411],{},"Baseline for this page.",[1365,1413,1414,1416,1419,1421],{},[1386,1415,133],{},[1386,1417,1418],{},"3.40",[1386,1420,1408],{},[1386,1422,1423],{},"Scoped Qt enums; a good early warning for Qt 6.",[1365,1425,1426,1429,1432,1434],{},[1386,1427,1428],{},"qgis\u002Fqgis:latest",[1386,1430,1431],{},"development",[1386,1433,1408],{},[1386,1435,1436],{},"Rebuilt continuously — useful as an allowed-to-fail matrix entry, never as a gate.",[190,1438,1440],{"id":1439},"troubleshooting","Troubleshooting",[195,1442,1443,1453,1465,1473,1481,1489],{},[198,1444,1445,1448,1449,1452],{},[439,1446,1447],{},"\"No module named qgis\"."," The job is not running inside the QGIS container, or a ",[18,1450,1451],{},"setup-python"," step replaced the interpreter. Do not add one — use the container's Python.",[198,1454,1455,462,1458,1460,1461,1464],{},[439,1456,1457],{},"\"could not connect to display\".",[18,1459,468],{}," is missing, or ",[18,1462,1463],{},"QT_QPA_PLATFORM"," was not set. Use both.",[198,1466,1467,462,1470,1472],{},[439,1468,1469],{},"Tests pass locally and fail in CI with \"algorithm not found\".",[18,1471,782],{}," is not called in the fixture.",[198,1474,1475,1478,1479,205],{},[439,1476,1477],{},"pip refuses to install."," The image's Python is externally managed; add ",[18,1480,453],{},[198,1482,1483,462,1486,1488],{},[439,1484,1485],{},"The suite is very slow.",[18,1487,656],{}," is being created per test. Make the fixture session-scoped.",[198,1490,1491,1494,1495,1498,1499,1502,1503,205],{},[439,1492,1493],{},"A test fails only on the newest image."," Usually a scoped-enum change. Write ",[18,1496,1497],{},"Qgis.MessageLevel.Warning"," rather than ",[18,1500,1501],{},"Qgis.Warning",", and prefer imports from ",[18,1504,1505],{},"qgis.PyQt",[190,1507,1509],{"id":1508},"conclusion","Conclusion",[14,1511,1512,1513,1515,1516,1518,1519,1521],{},"CI for a QGIS plugin is a container image, ",[18,1514,468],{},", an offscreen Qt platform and ",[18,1517,20],{},". Run the suite as a matrix across the QGIS versions your metadata claims to support, with ",[18,1520,635],{}," so you learn which ones break; build fixtures in memory rather than shipping data files; and make packaging depend on the test job so a failing commit can never be released.",[190,1523,1525],{"id":1524},"frequently-asked-questions","Frequently Asked Questions",[14,1527,1528,1531,1532,1534],{},[439,1529,1530],{},"Do I need xvfb if I set the offscreen platform?","\nUsually not, but a few QGIS classes still touch X during initialisation. ",[18,1533,472],{}," costs nothing and removes an entire class of intermittent failure.",[14,1536,1537,1540,1541,1544],{},[439,1538,1539],{},"Can I use GitHub's Ubuntu runner without a container?","\nYes, by adding the QGIS apt repository and installing ",[18,1542,1543],{},"qgis-python",", but it is slower and the version is whatever the repository currently offers. The container pins it.",[14,1546,1547,1550,1553],{},[439,1548,1549],{},"How do I test the plugin's GUI?",[18,1551,1552],{},"pytest-qt"," drives widgets under the offscreen platform. Keep GUI tests thin — most logic should live outside the dialog so it can be tested without one.",[14,1555,1556,1559],{},[439,1557,1558],{},"Should I run tests on Windows and macOS too?","\nOnly if the plugin has platform-specific code. QGIS's Python API behaves consistently across platforms; the differences that bite are versions, not operating systems.",[14,1561,1562,1565,1566,1568],{},[439,1563,1564],{},"How do I publish to the plugin repository from CI?","\nUpload the artefact and submit it with the repository's API using a token stored as a repository secret. Keep the submission on tags only, and treat the version bump in ",[18,1567,639],{}," as part of the release commit.",[190,1570,1572],{"id":1571},"related","Related",[195,1574,1575,1580,1584,1588,1594],{},[198,1576,1577,1579],{},[26,1578,29],{"href":28}," — the guide this recipe belongs to",[198,1581,1582],{},[26,1583,204],{"href":203},[198,1585,1586],{},[26,1587,1139],{"href":1138},[198,1589,1590],{},[26,1591,1593],{"href":1592},"\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Frun-pyqgis-in-docker-container\u002F","Run PyQGIS in a Docker Container",[198,1595,1596],{},[26,1597,1599],{"href":1598},"\u002Fqgis-plugin-development\u002Fpublishing-to-the-qgis-plugin-repository\u002F","Publishing to the QGIS Plugin Repository",[1601,1602,1603],"style",{},"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 .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}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 .s4JwU, html code.shiki .s4JwU{--shiki-default:#85E89D}",{"title":226,"searchDepth":247,"depth":247,"links":1605},[1606,1607,1608,1609,1610,1611,1612,1613,1614,1615,1616],{"id":192,"depth":247,"text":193},{"id":218,"depth":247,"text":219},{"id":476,"depth":247,"text":477},{"id":643,"depth":247,"text":644},{"id":985,"depth":247,"text":986},{"id":1142,"depth":247,"text":1143},{"id":1356,"depth":247,"text":1357},{"id":1439,"depth":247,"text":1440},{"id":1508,"depth":247,"text":1509},{"id":1524,"depth":247,"text":1525},{"id":1571,"depth":247,"text":1572},"Put a QGIS plugin test suite in CI — run pytest inside the official QGIS container with a virtual display, cache dependencies, test across LTR versions, and gate releases on the result.","md",{"slug":12,"type":1620,"breadcrumb":1621,"datePublished":1622,"dateModified":1622},"article","Tests in GitHub Actions","2026-08-10","\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Frun-qgis-plugin-tests-in-github-actions",{"title":5,"description":1617},"qgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Frun-qgis-plugin-tests-in-github-actions\u002Findex","eZ2rdBQxSTLuW_Mz-DYFp8S8tll4I17IBk4esdVS3S0",1786401337555]