What to Test in a GIS Pipeline (and What You Cannot)

On this page

Problem statement

Every guide to testing says "write tests". Almost none of them say what to assert on when the output is a polygon.

The usual advice transfers badly. A web developer asserts response.status_code == 200 and moves on. The spatial equivalent — did this clip produce the right shape? — has no single right answer, because:

from shapely.geometry import Polygon

# these two polygons are "the same" by any reasonable standard
a = Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])
b = Polygon([(1, 1), (0, 1), (0, 0), (1, 0)])

print("a == b      ", a == b)            # different vertex order
print("a.equals(b) ", a.equals(b))       # same point set
print("a.wkt == b.wkt", a.wkt == b.wkt)
print("a.area == b.area", a.area == b.area)   # but so is every other unit square
a == b       False
a.equals(b)  True
a.wkt == b.wkt False
a.area == b.area True

So people write no tests at all, or they write the one test that is easy to write — comparing output files byte for byte — and it breaks on the next GEOS upgrade for reasons nobody can explain.

Every test on this page is written to a file and run with pytest against real data: Oakham's Land Registry parcels and Rutland's ONS wards. The problem is not knowing how to test. It is knowing which properties of spatial output are worth asserting on, and which are noise that will make the suite fail for reasons that do not matter.

Quick answer

Assert on properties that would be wrong if the code were wrong, and that stay the same when nothing meaningful changes:

Assert on Not on
CRS of the output exact coordinate values
feature count, or the change in it row order
geometry type (Polygon, not MultiPolygon) WKT strings
validity (is_valid.all()) file size or bytes
area/length within a tolerance floating-point equality
column names and dtypes column order
null counts the whole DataFrame at once
that bad input raises that good input "works"
%%writefile tests/test_quick.py
from pipeline import clip_to_boundary


def test_clip_keeps_crs_and_shrinks_area(parcels, boundary):
    out = clip_to_boundary(parcels, boundary)
    assert out.crs == parcels.crs                     # a silent CRS drop is the classic bug
    assert len(out) <= len(parcels)                   # clip can only remove
    assert out.is_valid.all()                         # clip can produce slivers
    assert out.geometry.area.sum() < parcels.geometry.area.sum()
    assert set(out.columns) == set(parcels.columns)   # attributes survived
!{sys.executable} -m pytest -q -p no:cacheprovider --color=no --disable-warnings tests/test_quick.py
.                                                                        [100%]
1 passed in 0.33s

Five cheap assertions. Between them they catch a dropped CRS, a broken predicate, an invalid result and a lost attribute table — which is most of what actually goes wrong.

The two kinds of failure

Loud failures that raise exceptions contrasted with silent failures that produce plausible wrong output. only one of these needs a test suiteLoud — one run finds it✕file not found✕column does not exist✕driver not supported✕out of memory✕the script stopsSilent — only a test finds it✕CRS dropped, output still valid✕buffer in degrees, not metres✕join duplicates every row✕clip against the wrong boundary✕the script reports successTests exist for the right-hand column spatialworkflow.io
The failures worth testing for are the ones that do not raise.

Loud failures — a missing file, a bad column name — announce themselves the first time you run the script. You do not need a test suite to find them; you need one run.

Silent failures produce output. A spatial join with mismatched CRS returns zero rows, which looks like "no matches in this area". A buffer in degrees returns polygons, just enormous ones. A clip against the wrong boundary returns a perfectly valid subset of the wrong place. Tests exist for the second kind. If a test would only ever catch something an exception already catches, it is not earning its runtime. Here is the first of those, on the real wards:

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    joined = gpd.sjoin(parcels, wards.to_crs(4326), predicate="within")
print(f"{len(joined)} rows from a join of {len(parcels):,} parcels to wards in the wrong CRS; "
      f"{len(caught)} warning, no exception")
0 rows from a join of 5,006 parcels to wards in the wrong CRS; 1 warning, no exception

Structure, geometry, values, invariants, golden results

Five ascending levels of assertion, from output structure to golden results. work up the ladder — most bugs die on rungs one and two1structure — columns, dtypes, uniquenessmicroseconds2geometry — CRS, type, validity, non-emptythe big wins3values — ranges and ratios, never exactuse a tolerance4invariants — area conserved, idempotentcovers every input5golden result — one, regenerated on purposelast resortEach rung costs more to maintain than the one below it spatialworkflow.io
Work up the ladder. Most bugs are caught on the first two rungs.

1. Structure — does the output have the right shape?

The cheapest and most valuable assertions. They run in microseconds and catch schema drift, silent column renames, and functions that quietly return the input unchanged.

%%writefile tests/test_structure.py
import geopandas as gpd
import pytest

from pipeline import area_by_ward


@pytest.fixture
def result(parcels, wards):
    return area_by_ward(parcels, wards)


def test_output_schema(result):
    assert isinstance(result, gpd.GeoDataFrame)
    assert result.geometry.name == "geometry"
    assert set(result.columns) >= {"INSPIREID", "WD24NM", "area_m2", "geometry"}
    assert result["INSPIREID"].dtype == "int32"
    assert result["INSPIREID"].is_unique
!{sys.executable} -m pytest -q -p no:cacheprovider --color=no --disable-warnings -rf tests/test_structure.py 2>&1 | grep -E "^FAILED|passed|failed" | cut -c1-110
FAILED tests/test_structure.py::test_output_schema - assert False
1 failed in 0.35s

is_unique is the underrated one, and here it fails for real: area_by_ward() joins parcels to wards with intersects, and every parcel that straddles a ward boundary comes back once per ward. A spatial join that duplicates rows is the most common way a GIS result becomes quietly wrong — the map looks fine and the totals are inflated. The dtype line matters too: the INSPIRE IDs arrive as int32, and a test written from memory as int64 would fail for a reason that has nothing to do with the code.

2. Geometry — is it the right kind of shape, and is it valid?

%%writefile tests/test_geometry.py
from pipeline import clip_to_boundary


def test_geometry_properties(parcels, boundary):
    result = clip_to_boundary(parcels, boundary)
    assert result.crs is not None
    assert result.crs.is_projected                    # area/length are meaningless otherwise
    assert result.geom_type.isin(["Polygon", "MultiPolygon"]).all()
    assert result.is_valid.all()
    assert not result.geometry.is_empty.any()


def test_clip_makes_multipolygons(parcels, boundary):
    result = clip_to_boundary(parcels, boundary)
    print("\n", result.geom_type.value_counts().to_dict())
!{sys.executable} -m pytest -q -p no:cacheprovider --color=no --disable-warnings -s tests/test_geometry.py
.
 {'Polygon': 1631}
.
2 passed in 0.32s

Every row came back a Polygon, and that is itself worth a second look. A plain gpd.clip of these parcels to Oakham North East returns 1,609 rows, three of them MultiPolygon and five GeometryCollection; with keep_geom_type=True it returns 1,631 all-Polygon rows, because it splits those multi-part results into separate rows that share an INSPIREID. Assert the set of types the next step can handle, and the uniqueness of the key. is_empty deserves its own line. An empty geometry is not null, so dropna() misses it; it is valid, so is_valid passes it; and it writes to a file without complaint. It then contributes nothing to every downstream operation.

3. Values — are the numbers in the right range?

Never assert an exact area. Assert a range, a ratio, or a relationship:

%%writefile tests/test_values.py
import math

import pytest

from pipeline import buffer_metres


def test_buffer_area_is_plausible(points_27700):
    out = buffer_metres(points_27700, 50)
    expected = math.pi * 50 ** 2
    assert out.geometry.area.iloc[0] == pytest.approx(expected, rel=0.01)
    # the relationship matters more than the number
    wider = buffer_metres(points_27700, 100)
    assert (wider.geometry.area > out.geometry.area).all()


def test_exact_area_is_brittle(points_27700):
    assert buffer_metres(points_27700, 50).geometry.area.iloc[0] == pytest.approx(math.pi * 50 ** 2, rel=1e-4)
!{sys.executable} -m pytest -q -p no:cacheprovider --color=no --disable-warnings tests/test_values.py 2>&1 | grep -E "^E +assert|passed|failed" | head -3
E       assert np.float64(7841.371226366008) == 7853.981633974483 ± 0.785398
E         comparison failed
1 failed, 1 passed in 0.18s

The relationship survives a change in buffer resolution, and so does the area at rel=0.01. At rel=1e-4 the same area fails — 7,841.4 m² against πr² = 7,854.0 m² — because a default buffer is a 64-sided polygon, 0.16% smaller than the circle. A tolerance tighter than the approximation tests GEOS's segment count rather than your code, which is exactly the kind of failure that teaches people to ignore the test suite.

What each test on this page found when run against real Rutland data. the suite, run on Oakhamtestresultwhat it foundkey is unique after joinfail5,196 rows, 5,006 parcelsclip, keep_geom_typepass1,631 rows, 1,609 parcelsbuffer area, rel=1e-4fail7,841.4 vs πr² 7,854.0dissolve keeps area: wardspassa true coveragedissolve keeps area: parcelsfailtitles overlapclip twice = clip oncefail1,631 then 1,628 rowsBNG round trip to 1 mmpassholds in RutlandFour failures, each a fact about the code, the data or a library, not about the tests spatialworkflow.io
The whole suite, run on this page against real parcels and wards.

4. Invariants — what must be true of any correct run?

Invariants are the highest-value tests in spatial work because they hold for every input, so one test covers cases you never thought to write fixtures for.

%%writefile tests/test_invariants.py
import pytest

from pipeline import clip_to_boundary, dissolve_all


def test_dissolve_preserves_total_area_of_a_coverage(wards):
    out = dissolve_all(wards)
    assert out.geometry.area.sum() == pytest.approx(wards.geometry.area.sum(), rel=1e-6)


def test_dissolve_preserves_total_area_of_parcels(parcels):
    out = dissolve_all(parcels)
    assert out.geometry.area.sum() == pytest.approx(parcels.geometry.area.sum(), rel=1e-6)


def test_clip_is_idempotent(parcels, boundary):
    once = clip_to_boundary(parcels, boundary)
    twice = clip_to_boundary(once, boundary)
    assert len(once) == len(twice)


def test_reproject_round_trip(parcels):
    there_and_back = parcels.to_crs(4326).to_crs(parcels.crs)
    assert there_and_back.geometry.geom_equals_exact(parcels.geometry, tolerance=0.001).all()
!{sys.executable} -m pytest -q -p no:cacheprovider --color=no --disable-warnings -rf tests/test_invariants.py 2>&1 | grep -E "^FAILED|passed|failed" | cut -c1-110
E         comparison failed
FAILED tests/test_invariants.py::test_dissolve_preserves_total_area_of_parcels
FAILED tests/test_invariants.py::test_clip_is_idempotent - assert 1631 == 1628
2 failed, 2 passed in 0.89s

Two of the four fail, and both failures are findings. Dissolve must conserve area — of a coverage: Rutland's wards pass, and Oakham's parcels do not, because registered parcels overlap, so the dissolved area is smaller than the sum of the parts. And the clip is not idempotent: the first pass returns 1,631 rows, the second 1,628, because keep_geom_type=True had split multi-part results into rows with duplicate IDs and the second pass handles those rows differently again. An invariant states an assumption about the data and the code, and a real fixture is what tests it. The reprojection round trip holds to a millimetre here — like the others, it compares the code against itself, with no carefully constructed expected result.

5. Golden results — the last resort

A golden test stores a known-good output and compares against it. It catches anything the rungs above miss, and it fails for every irrelevant reason too.

%%writefile tests/test_golden.py
from pathlib import Path

import geopandas as gpd
import pytest
from geopandas.testing import assert_geodataframe_equal

from pipeline import clip_to_boundary

GOLDEN = Path("tests/golden/oakham_ne.gpkg")


def test_matches_golden(parcels, boundary):
    out = clip_to_boundary(parcels, boundary).sort_values("INSPIREID").reset_index(drop=True)
    if not GOLDEN.exists():
        GOLDEN.parent.mkdir(parents=True, exist_ok=True)
        out.to_file(GOLDEN)
        pytest.skip("golden file written — review and commit it")
    golden = gpd.read_file(GOLDEN).sort_values("INSPIREID").reset_index(drop=True)
    assert_geodataframe_equal(out, golden, check_less_precise=True, check_geom_type=False)
!{sys.executable} -m pytest -q -p no:cacheprovider --color=no --disable-warnings -rs tests/test_golden.py | tail -1
!{sys.executable} -m pytest -q -p no:cacheprovider --color=no --disable-warnings tests/test_golden.py | tail -1
1 skipped in 0.26s
1 passed in 0.32s

Use one, on one representative dataset, and expect to regenerate it deliberately when the pipeline changes. A suite of twenty golden tests is a suite nobody maintains.

Assertions on Oakham parcels and a silent CRS bug

The data used on this page

HM Land Registry's INSPIRE Index Polygons for Rutland (© Crown copyright and database rights; OGL), cut to Oakham, and Rutland's 15 wards from the ONS December 2024 boundaries (OGL). The setup blocks write the module under test and the pytest fixtures; every test file on this page imports from them, so they run before anything else.

import sys
import warnings
from pathlib import Path

import geopandas as gpd
import requests

for folder in ("data", "tests"):
    Path(folder).mkdir(exist_ok=True)
if not Path("data/Rutland.zip").exists():
    Path("data/Rutland.zip").write_bytes(requests.get(
        "https://use-land-property-data.service.gov.uk/datasets/inspire/download/Rutland.zip", timeout=300).content)
if not Path("data/wards.geojson").exists():
    Path("data/wards.geojson").write_bytes(requests.get(
        "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
        "Wards_December_2024_Boundaries_UK_BFC/FeatureServer/0/query",
        params={"where": "LAD24CD='E06000017'", "outFields": "WD24NM", "outSR": 27700, "f": "geojson"},
        timeout=120).content)
wards = gpd.read_file("data/wards.geojson")
oakham = wards[wards["WD24NM"].str.startswith("Oakham")].union_all()
parcels = gpd.read_file("/vsizip/data/Rutland.zip/Land_Registry_Cadastral_Parcels.gml",
                        columns=["INSPIREID"], DOWNLOAD_SCHEMA="NO")
parcels = parcels[parcels.intersects(oakham)].reset_index(drop=True)
parcels.to_file("data/oakham_parcels.gpkg")
print(f"{len(parcels):,} Oakham parcels, {len(wards)} wards")
5,006 Oakham parcels, 15 wards
%%writefile pipeline.py
"""The functions this page tests."""
import geopandas as gpd


def clip_to_boundary(gdf, boundary):
    return gpd.clip(gdf, boundary.to_crs(gdf.crs), keep_geom_type=True)


def area_by_ward(parcels, wards):
    joined = gpd.sjoin(parcels, wards[["WD24NM", "geometry"]], predicate="intersects").drop(columns="index_right")
    return joined.assign(area_m2=joined.area)


def buffer_metres(gdf, distance):
    if not gdf.crs.is_projected:
        raise ValueError("buffer_metres needs a projected CRS")
    return gdf.assign(geometry=gdf.buffer(distance))


def dissolve_all(gdf):
    return gdf[["geometry"]].dissolve()


def clean(gdf):
    out = gdf[gdf.geometry.notna()].copy()
    out["geometry"] = out.geometry.make_valid()
    return out


def add_area_column(gdf):
    if not gdf.crs.is_projected:
        raise ValueError("add_area_column needs a projected CRS")
    return gdf.assign(area_m2=gdf.area)
%%writefile tests/conftest.py
import geopandas as gpd
import pytest
import shapely
from shapely.geometry import Point


@pytest.fixture(scope="session")
def parcels():
    return gpd.read_file("data/oakham_parcels.gpkg")


@pytest.fixture(scope="session")
def wards():
    return gpd.read_file("data/wards.geojson")


@pytest.fixture(scope="session")
def boundary(wards):
    return wards[wards["WD24NM"] == "Oakham North East"]


@pytest.fixture
def points_27700():
    return gpd.GeoDataFrame(geometry=[Point(486000, 308800)], crs="EPSG:27700")


@pytest.fixture
def dirty_parcels(parcels):
    """Ten real parcels: two with the geometry removed, two made into bow-ties."""
    dirty = parcels.head(10).copy()
    dirty.loc[[0, 1], "geometry"] = None
    for i in (2, 3):
        ring = list(dirty.geometry[i].exterior.coords)
        ring[1], ring[2] = ring[2], ring[1]
        dirty.loc[i, "geometry"] = shapely.Polygon(ring)
    return dirty

Example 1: a reusable assertion helper

Most spatial tests repeat the same four checks. Extract them.

%%writefile tests/asserts.py
import geopandas as gpd


def assert_clean_layer(gdf, *, crs=None, geom_types=None, min_rows=1):
    """The assertions every layer in this pipeline must satisfy."""
    assert isinstance(gdf, gpd.GeoDataFrame), f"expected GeoDataFrame, got {type(gdf)}"
    assert len(gdf) >= min_rows, f"only {len(gdf)} rows"
    assert gdf.crs is not None, "layer has no CRS"
    if crs is not None:
        assert gdf.crs == crs, f"expected {crs}, got {gdf.crs}"
    if geom_types is not None:
        bad = gdf[~gdf.geom_type.isin(geom_types)]
        assert bad.empty, f"{len(bad)} rows are not {geom_types}"
    assert gdf.is_valid.all(), f"{(~gdf.is_valid).sum()} invalid geometries"
    assert not gdf.geometry.is_empty.any(), "layer contains empty geometries"
    assert not gdf.geometry.isna().any(), "layer contains null geometries"

Now each test is one line of intent plus one line of specifics:

%%writefile tests/test_helper.py
from asserts import assert_clean_layer
from pipeline import clip_to_boundary


def test_clip_output(parcels, boundary):
    out = clip_to_boundary(parcels, boundary)
    assert_clean_layer(out, crs=parcels.crs, geom_types={"Polygon", "MultiPolygon"})
    assert len(out) <= len(parcels)


def test_helper_reports_the_problem(parcels, boundary):
    out = clip_to_boundary(parcels, boundary)
    assert_clean_layer(out, geom_types={"MultiPolygon"})
!{sys.executable} -m pytest -q -p no:cacheprovider --color=no --disable-warnings -rf tests/test_helper.py 2>&1 | grep -E "^E +AssertionError|passed|failed" | sed 's/^E *//'
AssertionError: 1631 rows are not {'MultiPolygon'}
1 failed, 1 passed in 0.27s

The second test is written to fail, to show what the helper says when a layer does not meet it: one line naming the check and the count, instead of a bare assert False.

Example 2: asserting on the change, not the result

Where the absolute answer is hard to pin down, the delta usually is not.

%%writefile tests/test_delta.py
from pipeline import clean


def test_cleaning_removes_only_what_it_should(dirty_parcels):
    before = {"rows": len(dirty_parcels),
              "invalid": int((~dirty_parcels.is_valid).sum()),
              "null_geom": int(dirty_parcels.geometry.isna().sum())}
    out = clean(dirty_parcels)
    print("\n", before, "->", len(out), "rows")
    assert (~out.is_valid).sum() == 0
    assert out.geometry.isna().sum() == 0
    # everything removed should be accounted for by the null count
    assert len(out) == before["rows"] - before["null_geom"]
!{sys.executable} -m pytest -q -p no:cacheprovider --color=no --disable-warnings -s tests/test_delta.py | tail -3
 {'rows': 10, 'invalid': 2, 'null_geom': 2} -> 8 rows
.
1 passed in 0.13s

The fixture is ten real Oakham parcels with deliberate damage — two geometries removed and two turned into bow-ties by swapping a pair of vertices, because the INSPIRE polygons themselves are all valid. That last assertion is the real test: it checks that cleaning removed the null geometries and repaired the invalid ones rather than dropping them. A clean() that silently discarded invalid parcels would pass a naive "output is valid" test and fail this one.

Example 3: what a test for a silent CRS bug looks like

%%writefile tests/test_crs_bug.py
import geopandas as gpd
import pytest
from shapely.geometry import Polygon

from pipeline import add_area_column


def test_area_calculation_refuses_geographic_crs():
    gdf = gpd.GeoDataFrame(geometry=[Polygon([(-0.74, 52.66), (-0.72, 52.66), (-0.72, 52.68), (-0.74, 52.68)])],
                           crs="EPSG:4326")
    with pytest.raises(ValueError, match="projected"):
        add_area_column(gdf)


def test_area_calculation_accepts_projected_crs(parcels):
    out = add_area_column(parcels)
    assert out["area_m2"].min() > 0
    assert out["area_m2"].max() < 1e9        # nothing in Oakham is 1,000 km²
    print("\n largest parcel:", round(out["area_m2"].max()), "m²")
!{sys.executable} -m pytest -q -p no:cacheprovider --color=no --disable-warnings -s tests/test_crs_bug.py | tail -2
.
2 passed in 0.15s

The upper bound is a sanity rail, not a precise expectation. It fires the moment somebody computes area in degrees and gets a number seven orders of magnitude too small — or too large, depending on which way the mistake went.

Geometry has many representations of one truth

Checklist separating stable spatial assertions from brittle ones. assert on the truth, not on the representationCRS of the outputstablefeature count and uniquenessstablegeometry type and validitystablearea within a relative tolerancestableexact coordinate equalitybreaks on GEOS upgradeWKT string comparisonbreaks on vertex orderoutput file bytesbreaks on GDAL upgradeA test that fails for irrelevant reasons gets ignored, then deleted spatialworkflow.io
A test that fails for irrelevant reasons gets ignored, then deleted.

The reason spatial testing feels harder than it is comes down to one thing: geometry has many representations of the same truth. The same square can be wound clockwise or anticlockwise, start at any vertex, carry a redundant collinear point, or be stored as a Polygon or a one-part MultiPolygon. All of those are the same shape. None of them are the same bytes.

Every brittle spatial test is a test that accidentally asserted on the representation instead of the truth. Byte comparison, WKT comparison, exact coordinate comparison and == on geometries all do this. They pass today and fail when GEOS changes its vertex ordering, when GDAL changes its default precision, or when someone runs the pipeline on a machine with a different PROJ version.

Assertions on derived properties — area, length, count, validity, type, CRS, bounds — are stable under all of those changes and unstable under the changes you care about. That is the whole trick.

There is a second, quieter reason to prefer property assertions: they document intent. assert out.crs.is_projected tells the next reader that this function's output is meant to be measurable. A byte comparison tells them nothing at all.

Empty results, exact equality and platform drift

  • Empty results are a legitimate outcome, so assert deliberately. assert len(out) > 0 is right when the fixture guarantees matches, and wrong when the function is supposed to return nothing for a disjoint input. Write both tests.
  • geom_equals_exact needs a tolerance and respects vertex order; equals ignores order but is slower. For test assertions, equals is usually what you mean.
  • Floating-point results can differ between library versions and platforms. Keep tolerances at the level your question needs, not at the level today's run happens to reach.
  • Test the writer, not just the transform. Shapefile truncates column names to 10 characters and cannot store a datetime; a transform that passes every test can still produce a file that loses data on write. See how to fix truncated shapefile column names.
  • Do not test GeoPandas. test_that_buffer_returns_polygons tests a dependency, not your code. Test the function that calls it, including the argument you might get wrong.
  • Randomised fixtures need a fixed seed. A test that fails one run in fifty is worse than no test.

FAQ

What is the single most valuable assertion in a GIS test suite?

assert out.crs == expected_crs. A dropped or wrong CRS is the most common silent failure in Python GIS, it produces plausible output every time, and one line catches it.

Should I compare geometries with ==?

No. == on two geometry objects compares them element-wise as a Series, and on two individual shapes compares representation rather than shape. Use .equals() for "same shape", or .geom_equals_exact(other, tolerance=...) when vertex order also matters.

How do I test something that depends on a real dataset?

Cut a tiny extract — five to twenty features — commit it, and test against that. If the logic genuinely needs scale, that is a performance test, not a correctness test, and it belongs behind a marker so it does not run on every commit.

Is it worth testing that a file was written?

Testing that the file exists is nearly worthless; testing that reading it back gives you the same layer is worth a lot. The round trip is where shapefile column truncation, dropped CRS and coerced dtypes show up.

How many tests does a pipeline actually need?

One per silent failure mode you can name. That is usually five to fifteen, and it is far more valuable than a hundred tests that assert good input produces some output.

What about testing map output?

Compare the data that feeds the map, not the image. Image comparison fails on font rendering, matplotlib versions and antialiasing — all noise. If you must, assert on figure structure: number of artists, axis limits, colormap name.

Do property-based tests make sense here?

Yes, for invariants. Generating random valid polygons with hypothesis and asserting that dissolve conserves area, or that buffer then negative-buffer approximately restores the original, finds edge cases that hand-written fixtures never reach.