Property-based testing for geometry normalization
Your normalisation function has fourteen example-based tests and they all pass. It also has a bug: on a polygon whose lowest-leftmost coordinate appears twice, the start-vertex selection is unstable, so the same geometry canonicalises two different ways depending on ring order — which means the change detector reports a rezone every time the county republishes. Nobody wrote that example because nobody thought of it. A generator would have found it in about forty tries. This guide writes generators that produce the shapes municipal data actually contains, chooses invariants that genuinely hold, and reads the counterexamples they produce. It is the practical form of layer two in testing spatial data pipelines.
Diagnosis: which properties are actually invariant? jump to heading
The temptation is to assert everything, and over-strong properties fail on legitimate input, get weakened until they assert nothing, and are then deleted. Sort candidate properties into three groups first.
Genuinely invariant — must hold for every valid input:
canonical(canonical(g)) == canonical(g)— idempotence.canonical(g).area == g.area— normalisation reorders and rounds; it must not resize.canonical(g).equals(g)topologically, within the declared precision.digest(canonical(g)) == digest(canonical(reverse_rings(g)))— ring direction carries no meaning.
Invariant only within a stated tolerance — true given a declared precision:
- vertex count is preserved, unless rounding collapses two vertices onto one grid cell.
- round-tripping through WKB is exact, given the same precision model.
Not invariant, though it looks like it — the trap:
- “the vertex count never changes” is false, because rounding legitimately merges coincident vertices.
- “the bounding box is unchanged” is false at the precision boundary, by up to half a grid cell.
Getting a property into the wrong group costs an afternoon of arguing with a correct counterexample.
Step-by-step implementation jump to heading
1. Write generators that produce municipal pathologies jump to heading
A generator of random polygons mostly produces shapes county data never contains. A generator built from the known pathologies produces failures you will actually see in production.
from hypothesis import strategies as st
from shapely.geometry import Polygon
# Realistic municipal coordinates: a projected CRS in metres, plausible easting and
# northing. Random floats over the whole double range test the float library, not you.
easting = st.floats(min_value=400_000, max_value=700_000, allow_nan=False,
allow_infinity=False, width=64)
northing = st.floats(min_value=3_000_000, max_value=5_000_000, allow_nan=False,
allow_infinity=False, width=64)
@st.composite
def parcel_quads(draw):
"""A convex quadrilateral with a skew, which is what most platted lots are."""
x0, y0 = draw(easting), draw(northing)
w = draw(st.floats(min_value=0.5, max_value=400.0))
h = draw(st.floats(min_value=0.5, max_value=400.0))
skew = draw(st.floats(min_value=-60.0, max_value=60.0))
clockwise = draw(st.booleans())
ring = [(x0, y0), (x0 + w, y0 + skew), (x0 + w, y0 + h + skew), (x0, y0 + h)]
return Polygon(ring[::-1] if clockwise else ring)
@st.composite
def parcels_with_duplicate_vertices(draw):
"""County exports contain repeated consecutive vertices in quantity. This is the
generator that finds unstable start-vertex selection."""
base = draw(parcel_quads())
coords = list(base.exterior.coords)[:-1]
idx = draw(st.integers(min_value=0, max_value=len(coords) - 1))
coords.insert(idx, coords[idx]) # exact duplicate
return Polygon(coords + [coords[0]])
@st.composite
def parcels_with_holes(draw):
outer = draw(parcel_quads())
minx, miny, maxx, maxy = outer.bounds
if (maxx - minx) < 4 or (maxy - miny) < 4:
return outer # too small for a hole
pad = draw(st.floats(min_value=1.0, max_value=min(maxx - minx, maxy - miny) / 3))
hole = Polygon([(minx + pad, miny + pad), (maxx - pad, miny + pad),
(maxx - pad, maxy - pad), (minx + pad, maxy - pad)])
return Polygon(outer.exterior.coords, [hole.exterior.coords])
municipal_parcels = st.one_of(parcel_quads(), parcels_with_duplicate_vertices(),
parcels_with_holes())
2. Assert the invariants, with the tolerance stated in the assertion jump to heading
import math
from hypothesis import assume, given, settings
PRECISION = 3 # millimetre grid
GRID = 10 ** -PRECISION
AREA_REL_TOL = 1e-9
@given(municipal_parcels)
@settings(max_examples=500, deadline=None)
def test_canonical_is_idempotent(poly):
assume(poly.is_valid) # the contract is for valid input
once = canonical(poly)
assert canonical(once).equals_exact(once, 0), "canonical form is not stable"
@given(municipal_parcels)
@settings(max_examples=500, deadline=None)
def test_canonical_preserves_area(poly):
assume(poly.is_valid)
# Rounding to the grid can move each vertex by up to half a cell, so the area
# tolerance is derived from the perimeter rather than picked.
slack = poly.length * GRID
assert math.isclose(canonical(poly).area, poly.area,
rel_tol=AREA_REL_TOL, abs_tol=slack + 1e-9)
@given(municipal_parcels)
@settings(max_examples=500, deadline=None)
def test_ring_direction_carries_no_meaning(poly):
assume(poly.is_valid)
reversed_rings = Polygon(list(poly.exterior.coords)[::-1],
[list(h.coords)[::-1] for h in poly.interiors])
assert digest(canonical(poly)) == digest(canonical(reversed_rings))
@given(municipal_parcels)
@settings(max_examples=300, deadline=None)
def test_vertex_count_only_ever_falls(poly):
"""The CORRECT version of "vertex count is preserved": rounding may merge
coincident vertices, so the count can fall and must never rise."""
assume(poly.is_valid)
before = len(poly.exterior.coords) - 1
after = len(canonical(poly).exterior.coords) - 1
assert after <= before
The derived slack in the area test is the pattern worth copying. A hard-coded abs_tol=0.01 either fails on a large perimeter or hides a real error on a small one; deriving it from the perimeter and the grid makes the tolerance mean something.
3. Read the counterexample rather than weakening the property jump to heading
Hypothesis shrinks a failure to something minimal, and the shrunk case usually names the bug directly:
Falsifying example: test_canonical_is_idempotent(
poly=<POLYGON ((400000 3000000, 400000 3000000, 400000.5 3000000, ...))>
)
Two identical leading vertices. The start-vertex selection uses min() over the coordinate list, min() returns the first of equal minima, and the index of that minimum differs between the original and the once-canonicalised ring. The fix is a total order that breaks ties deterministically:
def _start_index(coords):
"""A total order over coordinates. Ties on (x, y) are broken by index so the
choice is deterministic even when a coordinate appears twice — which is what
the duplicate-vertex generator found."""
return min(range(len(coords)), key=lambda i: (coords[i][0], coords[i][1], i))
4. Keep the failing case as a permanent example jump to heading
@pytest.mark.parametrize("poly", [
# From a Hypothesis counterexample, 2026-08-11: duplicate leading vertex made
# start-vertex selection unstable, so the change detector saw a phantom rezone
# on every republication.
Polygon([(400000, 3000000), (400000, 3000000), (400000.5, 3000000),
(400000.5, 3000000.5), (400000, 3000000.5)]),
])
def test_regression_duplicate_leading_vertex(poly):
once = canonical(poly)
assert canonical(once).equals_exact(once, 0)
Property tests find bugs; example tests pin them. Promoting each counterexample into a named regression test with a comment saying what it broke is what keeps the fix from being undone by a later refactor — and it runs in microseconds rather than five hundred generated cases.
Verification & testing jump to heading
The meta-question for a property suite is whether it would actually catch a regression, and that is testable by mutation:
def test_the_suite_catches_an_unstable_start_vertex(monkeypatch):
"""Deliberately break canonical() and assert the property test notices. A
property suite that survives an injected bug is decoration."""
def unstable_start(coords):
return min(range(len(coords)), key=lambda i: (coords[i][0], coords[i][1]))
monkeypatch.setattr("normalise._start_index", unstable_start)
with pytest.raises(AssertionError):
test_canonical_is_idempotent()
def test_generators_produce_valid_polygons():
"""A generator emitting invalid geometry makes every property fail for the
generator's reasons rather than the code's."""
for _ in range(200):
poly = municipal_parcels.example()
assert poly.is_valid or poly.is_empty
Failure recovery jump to heading
A property fails on legitimate input. The property is wrong, not the code — that is the common case and it is a useful finding. Move it from “invariant” to “invariant within a tolerance,” derive the tolerance from something physical, and record why in a comment. Do not delete it.
A counterexample nobody can interpret. Shrink further by narrowing the generator: constrain to one pathology at a time and re-run. A failure that only appears when a duplicate vertex and a hole and a clockwise ring coincide is three bugs wearing a coat.
The suite becomes too slow. Cut example counts on the expensive properties and raise them on the cheap ones; area and idempotence are fast, and anything calling into GEOS overlay is not. Move heavy generated tests into a nightly suite and keep the promoted regression examples in the fast one.
Frequently asked questions jump to heading
Why not just write more example tests?
Because the bugs that survive review are the cases nobody thought of, and examples are limited to what you thought of. The duplicate-leading-vertex bug is a good illustration: it is obvious in hindsight, appears in real county exports, and nobody writes it as an example. A generator finds it in tens of tries.
Should generators produce invalid geometry?
Not in the same strategy as valid input. A generator emitting bow-ties makes every property fail for the generator’s reasons, which trains you to ignore failures. Test invalid input explicitly, as examples, asserting that it is rejected — that is a different contract and deserves its own tests.
How do you pick a tolerance for a property?
Derive it from something physical rather than tuning it until the suite is green. For area under grid rounding, perimeter times grid size bounds the error; for a reprojection round trip, the transformation’s own claimed accuracy does. A tolerance with a derivation in a comment survives review; a tuned constant creeps upward until the property asserts nothing.
What do you do with a counterexample after fixing the bug?
Promote it into a named regression test with a comment recording what it broke and when it was found. The property test keeps hunting for new cases; the example pins this one cheaply and permanently, so a later refactor that reintroduces the bug fails in microseconds rather than after five hundred generated examples.
Related jump to heading
- Parent topic: Testing Spatial Data Pipelines
- Section overview: Municipal Zoning Data Architecture & Compliance Frameworks
- Creating minimal parcel fixtures that reproduce topology bugs — shrinking a failure you found in production rather than in a generator
- Change Detection & Geometry Diffing — the canonical form these properties are asserted against
- Building golden-file tests for geometry transformations — the layer that checks agreement with a known answer