This document is the single authoritative reference for why the test suite is structured the way it is, how to choose the right test tier for a new test, and where known coverage gaps exist. Consult it before writing any new test or assessing CI failures.
What this document is not:
- A tutorial on pytest syntax — see the pytest documentation.
- A list of tactical rules for geometry checks or test structure — see
ai/core/testing-rules.md.
This plugin is a companion to IB-Tool 3 and mirrors its testing conventions at a scale appropriate for a single-module plugin. See IB-Tool 3's own docs/test-strategy.md for the full-size version of this document.
siedgr() chains twelve processing.run() calls (centroids → heatmap → pixels-to-points → Voronoi → dissolve → line explode → distance-based cleanup → merge → polygonize → field calculator). A single step that silently returns an empty or invalid geometry does not raise — it just produces a wrong partition that still opens in QGIS. This is why geometry validity checks (isNull, isEmpty, isGeosValid) are mandatory for every test that touches siedgr()'s output, not just a feature-count check.
The demarcation between unit and integration tests is not "uses QGIS API" but specifically whether processing.run() is called. IbToolPartition.__init__, tr(), add_action(), initGui(), unload(), and the input-validation branches of run() touch no Processing algorithm and are unit-testable against a fully mocked qgis package. siedgr() calls processing.run() twelve times and is the integration boundary.
test_ibtoolpartion.py::TestIbToolPartitionSiedgr mocks qgis.processing entirely and asserts what siedgr() asks Processing to do (call count, algorithm IDs, parameter dicts) — cheap, fast, catches accidental parameter regressions. test_siedgr_integration.py runs the real pipeline against a live QGIS Processing environment and asserts what actually comes out (valid polygons, NAME field, correct partition count). Both are needed; neither substitutes for the other. Do not mark the mocked contract test integration — it is a unit test of a call contract, not of QGIS behaviour.
Empty input paths, non-existent files, non-numeric cell sizes, and empty output paths are guaranteed user inputs, not edge cases to defer. Every validation branch in IbToolPartition.run() is tested explicitly via mock_iface.messageBar().pushMessage assertions.
radius = 2 * cell_size, the 'PART_' || $id naming formula, and the qgis:heatmapkerneldensityestimation dependency are not obvious from reading siedgr()'s twelve processing calls in isolation. Test docstrings and assertion messages make these expectations explicit rather than requiring a re-read of the source.
Four tiers are used. Every test must carry exactly one tier marker and may additionally carry edge_case.
Definition: No call to processing.run() against a real QGIS Processing registry. May mock qgis.processing entirely to assert call contracts.
When to use: Constructor/lifecycle tests, run() input validation, the siedgr() call-contract tests, dialog .ui-file checks.
Execution: Runs anywhere Python + QGIS libraries are installed (mocked-QGIS tests run without QGIS at all). Does not require Docker.
Example targets: IbToolPartion.py (everything except the real siedgr() pipeline), IbToolPartion_dialog.py .ui declarations.
Definition: Calls processing.run() for real, or instantiates a live Qt/QGIS widget via get_qgis_app().
When to use: Testing the real siedgr() pipeline end to end, or testing that the dialog widgets exist and respond on a live QDialog instance.
Execution: Requires Docker (docker run --rm qgis-plugin-test) or a local QGIS installation with Processing initialized. Automatically skipped when QGIS is unavailable or when qgis:heatmapkerneldensityestimation is not registered in the running Processing build (checked at collection time in test_siedgr_integration.py).
Example targets: test_siedgr_integration.py, the live-widget tests in test_IbToolPartion_dialog.py, test_qgis_environment.py.
Definition: Cross-cutting tag combined with unit or integration. Marks a test that exercises a boundary or degenerate input.
Catalog of mandatory edge cases for this plugin:
add_to_toolbar=False/add_to_menu=Falseonadd_action()dlgattribute absent orNoneonselect_output_file()- Non-numeric
cell_sizeinrun() cell_size=1(minimum plausible value) passed tosiedgr()- A single building as
siedgr()input - Two widely separated building clusters (must yield ≥ 2 partitions)
Definition: Exercises time or memory bounds, or a parameter combination known to be runtime-sensitive (e.g. a very small cell_size, which inflates the intermediate heatmap raster). Always carries both performance-adjacent care and slow so it can be excluded from fast local runs via -m "not slow".
Current use: test_siedgr_integration.py::TestSiedgrEdgeCases::test_very_small_cell_size is marked slow (and edge_case) because a small cell_size increases raster resolution; the building extent is kept deliberately tiny to bound runtime. No dedicated @pytest.mark.performance-only test exists yet — see Gap Analysis.
Per-category floor values, not aspirational goals. Coverage below these thresholds signals a gap to close before merging new features.
| Module | Target |
|---|---|
IbToolPartion.py (excluding siedgr()) |
80% |
IbToolPartion.py siedgr() |
75% |
IbToolPartion_dialog.py |
65% |
scripts/ |
90% |
| Overall project | 70% |
conftest.py handles only pytest infrastructure: it adds the plugin's parent directory to sys.path so import ibtoolpartion.X resolves both locally and in the container (PYTHONPATH=/plugins), plus generic fixtures (temp_dir, plugin_dir, mock_qgis_interface). It does not import QGIS modules — doing so would trigger a circular import error via qgis.utils._import before QGIS is initialized.
test/layer_factories.py is the canonical home for shared layer and geometry factory helpers. It is a regular Python module (not a pytest plugin) and must be imported after calling get_qgis_app() in each test file:
from .utilities import get_qgis_app
QGIS_APP, _CANVAS, _IFACE, _PARENT = get_qgis_app()
from .layer_factories import (
make_polygon_layer, make_line_layer, make_point_layer,
make_square_geom, add_feature_to_layer,
write_layer_as_shp, write_layer_as_gpkg,
make_building_grid_layer,
)Current functions in layer_factories.py:
make_polygon_layer(crs, name)/make_line_layer(crs, name)/make_point_layer(crs, name)— empty in-memory layersmake_square_geom(x0, y0, size)— axis-aligned squareQgsGeometryadd_feature_to_layer(layer, geom)— adds aQgsFeatureand returns itwrite_layer_as_shp(layer, path)/write_layer_as_gpkg(layer, path)— needed becauseIbToolPartition.siedgr()takes file paths, not layer objectsmake_building_grid_layer(n_x, n_y, size, spacing, crs)— building-footprint grid used assiedgr()input
| Fixture type | Scope |
|---|---|
QgsVectorLayer instances |
function — layers are mutable; reuse across tests causes interference |
QgsApplication (QGIS singleton, via get_qgis_app()) |
module-level singleton, cached across the whole test run — expensive to initialize, safe to share read-only |
File paths (tmp_path) |
function (pytest built-in) |
| Production module | Test file | ~Tests | Dominant tier | Notable gaps |
|---|---|---|---|---|
IbToolPartion.py (__init__, tr, add_action, initGui, unload, select_output_file, run() validation) |
test_ibtoolpartion.py |
21 | unit | Full run() success path (dialog exec_() accepted → siedgr() called → success message) is not covered; only the four validation-error branches are |
IbToolPartion.py siedgr() — call contract |
test_ibtoolpartion.py::TestIbToolPartitionSiedgr |
4 | unit | — |
IbToolPartion.py siedgr() — real pipeline |
test_siedgr_integration.py |
7 | integration | No @pytest.mark.performance test with a large (50+) building set |
IbToolPartion_dialog.py |
test_IbToolPartion_dialog.py |
10 | unit (.ui text) + integration (live widgets) |
select_input_file/select_output_file → QFileDialog interaction and the HU_Button/Output_Button signal wiring in run() are not exercised |
__init__.py classFactory() |
test_init.py |
1 | smoke | classFactory() itself is untested — needs a live iface from the running QGIS application (see Justified Exclusions) |
resources.py |
test_resources.py |
1 | smoke | Generated file; icon presence only |
i18n/*.qm |
test_translations.py |
1 | smoke | — |
| Plugin folder / import structure | test_plugin_structure.py |
2 | smoke | — |
| QGIS environment / Processing availability | test_qgis_environment.py |
4 | smoke / integration | — |
scripts/create_release_zip.py |
test_create_release_zip.py |
40 | unit | Pure-Python; no QGIS dependency |
Partitioning.pyt |
— | 0 | — | Justified Exclusion — ArcGIS toolbox, not part of the QGIS plugin runtime |
Use this checklist when adding a new test.
- New function or class → write a test for its normal behavior + at least one edge case.
- Bug fix → write a regression test that reproduces the original bug, then verifies the fix.
- Edge case discovered during review → add to the existing test class under
@pytest.mark.edge_case.
Does the function under test call processing.run() against a real registry?
├── No → @pytest.mark.unit
└── Yes → @pytest.mark.integration
(also requires Docker / local QGIS for execution)
Is this testing a boundary / degenerate input?
└── Yes → additionally add @pytest.mark.edge_case
Does it measure runtime/scaling, or use a parameter combination known
to be runtime-sensitive (e.g. tiny cell_size)?
└── Yes → additionally add @pytest.mark.slow
IbToolPartion.py (excluding siedgr()'s real pipeline) → test_ibtoolpartion.py. The real siedgr() pipeline → test_siedgr_integration.py. The dialog → test_IbToolPartion_dialog.py. If none fit, create test_{module_name}.py following the class structure in ai/core/testing-rules.md.
Every test for a function that returns a QgsVectorLayer must include:
assert result_layer is not None
assert result_layer.featureCount() > 0 # or == expected_count
for feat in result_layer.getFeatures():
geom = feat.geometry()
assert not geom.isNull(), "Geometry must not be null"
assert not geom.isEmpty(), "Geometry must not be empty"
assert geom.isGeosValid(), "Geometry must be GEOS-valid"Every test method must have a docstring in the imperative mood describing what behavior it verifies:
def test_single_building(self, plugin, tmp_path):
"""siedgr() completes and produces a valid partition for a single building."""| Gap | Action |
|---|---|
Full run() success path untested |
Add a unit test that configures the mock dialog with valid paths/cell_size, stubs siedgr(), and asserts the success pushMessage call |
No @pytest.mark.performance test for siedgr() at scale |
Add a performance + slow test with 50+ buildings, asserting it completes within a stated time budget |
A missing qgis:heatmapkerneldensityestimation skips every siedgr() integration test without failing |
The collection-time skipif in test_siedgr_integration.py keeps the suite green even when the algorithm is absent from the Processing build — CI then reports success while siedgr() has zero real coverage. In the Docker image the algorithm must be present: make its absence a hard error there (assert it is registered when QGIS_PREFIX_PATH=/usr) and keep the skip for local runs only |
siedgr() is a single ~120-line method with 12 processing.run() calls |
Its steps cannot be tested individually — any failing step surfaces as "the whole pipeline is wrong". Splitting it into named steps (centroids → heatmap → voronoi → cleanup → polygonize) would let the integration tests pinpoint failures. It also still writes to stdout via print() instead of the QGIS logger (IbToolPartion.py, run()) |
| Gap | Action |
|---|---|
select_input_file/select_output_file QFileDialog interaction untested |
Mock QFileDialog.getOpenFileName/getSaveFileName and assert the dialog's Input_HU/output_file text fields are updated |
HU_Button/Output_Button signal wiring in run() untested |
Requires a live Qt event loop; cover via the same get_qgis_app() pattern used in test_IbToolPartion_dialog.py |
Documented decisions that are not gaps — known exclusions with stated reasons.
| Module / function | Reason for exclusion |
|---|---|
resources.py |
Generated by the Qt resource compiler (pyrcc5/compile.bat), not hand-written. |
Partitioning.pyt |
An ArcGIS Toolbox shipped alongside the QGIS plugin in the same repository; not part of the QGIS plugin runtime and excluded from .coveragerc. |
__init__.py classFactory() |
Requires a live iface object provided by the running QGIS application. Covered indirectly by the test_init.py metadata smoke test and the Docker CI run. |
For the full CI/CD pipeline description and Docker environment setup, see docs/contributing.md.
Quick reference for common test runs:
# Unit tests only (no QGIS Processing required)
pytest test/ -m "unit" -v
# Skip slow tests
pytest test/ -m "not slow" -v
# Full run (requires Docker or local QGIS)
docker run --rm -v $(pwd):/plugins/ibtoolpartion qgis-plugin-test
# Coverage report
pytest test/ --cov --cov-report=html
# Single module
pytest test/test_siedgr_integration.py -v| File | Content |
|---|---|
docs/contributing.md |
CI/CD pipeline, Docker environment, code linting |
ai/core/testing-rules.md |
Tactical rules: geometry checks, test structure, framework conventions |