Adding Spatial Regression Tests to a CI Pipeline
The scenario: a dependency bump passes every unit test, deploys cleanly, and changes a reported setback area by 0.4 percent. Nobody notices for two months, and then a reviewer compares two versions of the same submission. Spatial regressions are hard to catch with ordinary tests because the outputs are geometries rather than values, and because “close enough” is a real requirement rather than a smell. This page builds the fixture suite that catches them, and it is the CI half of spatial pipeline orchestration and deployment.
Root-cause analysis
Spatial regressions escape ordinary testing for three structural reasons.
- Geometry equality is the wrong assertion. Two polygons that differ by a floating-point ulp in one vertex are not equal, and a test that demands equality fails on every platform change. Tests have to assert on measurable properties — area, length, centroid, validity, CRS — with explicit tolerances.
- The defect is in a dependency, not in the diff. A PROJ upgrade, a GEOS overlay change or a GDAL resampling fix produces different output from unchanged code. Tests that only exercise the repository’s own logic never see it.
- The magnitude is below the noise floor of a visual check. An 8-centimetre coordinate shift and a 0.4 percent area change are invisible on a map and material in a submission, so the review that would catch a big error catches nothing.
Pre-flight validation: what belongs in the fixture
The fixture should be small enough to commit and pathological enough to be interesting. Six items cover most of the surface: a control point with a known reprojection, a polygon with a known area in an equal-area frame, a self-intersecting bowtie, a polygon with a hole, a pair of layers that overlap along a shared edge, and a small raster with a known windowed-read result. Together they are a few kilobytes and exercise every library in the stack.
from pathlib import Path
import geopandas as gpd
import pytest
FIXTURES = Path(__file__).parent / "fixtures"
@pytest.fixture(scope="session")
def parcels() -> gpd.GeoDataFrame:
"""Twelve parcels: two invalid, one with a hole, one straddling a UTM zone edge."""
return gpd.read_file(FIXTURES / "parcels.gpkg")
@pytest.fixture(scope="session")
def constraints() -> gpd.GeoDataFrame:
"""Three constraint layers that overlap along shared edges, as real ones do."""
return gpd.read_file(FIXTURES / "constraints.gpkg")
Fix implementation: the four assertions
The tests below are deliberately property-based rather than snapshot-based. Each one asserts something that must remain true, with a tolerance chosen from what the domain actually needs — a centimetre for coordinates, a hundredth of a percent for areas.
import math
import geopandas as gpd
import pyproj
import pytest
CONTROL_LONLAT = (-101.8313, 35.2220)
CONTROL_UTM14N = (334_936.15, 3_899_889.52)
KNOWN_PARCEL_HA = 42.187 # measured once in EPSG:5070, checked forever after
def test_control_point_reprojects_to_the_same_metre():
"""Catches a PROJ pipeline or datum-grid change — the invisible regression."""
t = pyproj.Transformer.from_crs(4326, 32614, always_xy=True)
x, y = t.transform(*CONTROL_LONLAT)
assert math.isclose(x, CONTROL_UTM14N[0], abs_tol=0.01)
assert math.isclose(y, CONTROL_UTM14N[1], abs_tol=0.01)
def test_parcel_area_is_stable(parcels):
"""Catches an equal-area frame change or a geometry repair that moved a boundary."""
p = parcels.loc[parcels["parcel_id"] == "P-0007"].to_crs(5070)
ha = float(p.area.iloc[0]) / 10_000.0
assert ha == pytest.approx(KNOWN_PARCEL_HA, rel=1e-4)
def test_repair_preserves_area(parcels):
"""Catches a make_valid change that turns a bowtie into a different shape."""
bad = parcels.loc[~parcels.is_valid].to_crs(5070)
assert len(bad) == 2, "fixture should carry exactly two invalid geometries"
repaired = bad.geometry.make_valid()
assert repaired.is_valid.all()
# A bowtie repair legitimately changes area; a hole repair must not.
hole = parcels.loc[parcels["parcel_id"] == "P-0011"].to_crs(5070)
assert float(hole.geometry.make_valid().area.iloc[0]) == pytest.approx(
float(hole.area.iloc[0]), rel=1e-9
)
def test_overlay_area_reconciles(parcels, constraints):
"""Catches a GEOS overlay change and any union/difference arithmetic regression."""
study = parcels.to_crs(5070).union_all()
excl = constraints.to_crs(5070).clip(study).union_all()
buildable = study.difference(excl)
gross_ha = study.area / 10_000.0
excl_ha = excl.area / 10_000.0
build_ha = buildable.area / 10_000.0
assert build_ha + excl_ha == pytest.approx(gross_ha, rel=1e-9)
assert build_ha <= gross_ha
Fallback routing and performance tuning
- Keep the fixture in the repository, not in object storage. A test that needs network access is a test that fails for reasons unrelated to the change under review.
- Never let CI call a public portal. Portal outages and rate limits become build failures, and the failure mode teaches the team to ignore red builds. Record a response once and replay it.
- Separate fast from slow. The property tests above run in under a second and belong on every commit; a scale test over a real partition belongs in a nightly job where a failure is informative rather than blocking.
- Assert on properties, never on WKT strings. A snapshot of well-known text is a test that fails on every platform, coordinate-precision or library change, which trains everyone to regenerate it without reading the diff.
- Pin the container in CI too. Running the tests in the same image the pipeline deploys is what makes them meaningful; running them against whatever the runner has installed tests the runner.
Downstream validation
When a spatial test fails, the useful output is the magnitude and the direction of the change, not merely that it changed. A failure that reports “area 42.187 → 42.203 ha (+0.038 percent, +0.016 ha)” is triageable in seconds; one that reports “assertion failed” starts an investigation.
def report_delta(name: str, expected: float, actual: float, unit: str) -> str:
"""Format a spatial regression so the reviewer can judge it without rerunning anything."""
delta = actual - expected
pct = (delta / expected * 100.0) if expected else float("inf")
return f"{name}: {expected:.4f} → {actual:.4f} {unit} ({delta:+.4f}, {pct:+.3f}%)"
Frequently asked questions
What tolerance should a coordinate assertion use?
One centimetre for projected coordinates is a good default: it is far tighter than any datum realisation change that matters and far looser than floating-point noise. For geographic coordinates, express the tolerance in metres by converting rather than in degrees, because a degree of longitude is not a fixed distance and a degree-based tolerance is latitude-dependent.
Should the fixture include real project data?
No. Use synthetic geometries with the same pathologies — an invalid ring, a hole, a shared edge, a zone-straddling extent — because real parcels carry ownership information, cannot always be redistributed, and are far larger than a test needs. Synthetic fixtures also let you construct the awkward cases deliberately rather than hoping a real extract contains them.
How do I test a raster pipeline without committing a large raster?
Commit a small one: a 64 by 64 float32 GeoTIFF with a known mean, a known nodata pattern and a known windowed-read result is under 20 kilobytes and exercises the same code path as a national product. What it will not exercise is memory behaviour, which belongs in the nightly scale test.
What should happen when a dependency upgrade legitimately changes a result?
Update the expected value in the same commit as the upgrade, with the delta in the commit message. That is the whole workflow the fixture exists to support: the test does not prevent change, it forces the change to be seen and recorded. A silent 8-centimetre shift becomes a line in the history explaining why the number moved.
How many fixture geometries are enough?
A dozen, chosen for pathology rather than for coverage. Two invalid rings, one polygon with a hole, one pair sharing an edge, one geometry straddling a UTM zone boundary and a handful of ordinary parcels will exercise every branch a spatial pipeline has. Adding a hundred well-behaved parcels adds runtime and finds nothing, because well-behaved geometry is not where the defects live.
Should the tests assert on the number of features?
Yes, and it is one of the cheapest assertions available. A repair that silently splits a bowtie into two polygons, an overlay that explodes one parcel into fragments, or a filter that drops rows all show up as a changed feature count long before they show up as a changed area. Assert the count alongside the area and the two together pin the behaviour.
What about testing the CRS handling itself?
Test that the output CRS is the declared one, and test one reprojected coordinate — not the transformation machinery, which is PROJ’s job. The failure mode worth catching is a pipeline that loses or overwrites a CRS somewhere in the middle, which shows up as an output whose declared frame is right and whose coordinates are in a different one. The control point catches exactly that.
Should a spatial test run against the real object store?
No — mock the boundary and test the parsing. What the pipeline reads from object storage is bytes in a known format, and a fixture file exercises every code path that matters without a network call, a credential or a bucket that someone can empty. The one thing worth testing against a real store is the credential and permission wiring, and that belongs in a smoke test at deploy time rather than in the commit path.
How do these tests interact with the run-record assertions?
They are the same assertions at two moments. The CI tests run them against a fixture on every commit; the pipeline runs a subset of them against real partitions on every run. Writing them once as functions that take data and raise, rather than as test bodies, is what makes that reuse possible — and it means a production failure and a CI failure produce the same message.
Related
- Spatial Pipeline Orchestration & Deployment — the pipeline these tests guard
- Containerizing a GeoPandas Pipeline with Docker and GDAL — the image these tests should run inside
- Validating Geometry Topology with Shapely 2 Predicates — the repair behaviour the fixture pins
- Spatial Data Quality & Validation — the quality gate these tests keep honest