Property-Based Testing for Geometry Round-Trips
State what an encode-decode cycle must preserve — geometry type, component count, area within a tolerance, and topological equality — then let hypothesis generate coordinates until it finds an input that violates one. The value is not the generation; it is that the properties force you to say precisely what “round-trips correctly” means, which turns out to be harder and more useful than writing the test.
This page belongs to the Testing & CI for Spatial Pipelines guide.
Why This Matters
A hand-written fixture contains the geometry you thought of. Encoders break on the geometry you did not: coordinates near the limits of float64 precision, polygons with hundreds of interior rings, rings whose first and last vertices differ in the last bit, multipart geometries with one empty component, and coordinates whose textual representation round-trips differently from their binary one.
Property-based testing finds those cases by construction. It also does something a hand-written test cannot: when it finds a failure it shrinks the input, so the report is the smallest geometry that breaks the code rather than the thousand-vertex one it happened to generate.
Prerequisites
- Python 3.9+ with
hypothesis6.90+ andshapely2.0+ - An encoder and decoder to test — the WKB or GeoPackage Binary path from your pipeline
- A tolerance decided in advance for anything compared numerically
- Familiarity with the encoding from How to Read GeoPackage Geometry Blobs in Python
Primary Method
# test_roundtrip.py — properties an encode/decode cycle must preserve
from hypothesis import given, settings, strategies as st
from shapely import from_wkb, to_wkb, make_valid
from shapely.geometry import Polygon, MultiPolygon
# Coordinates in a realistic projected range, avoiding denormals and infinities
coord = st.floats(
min_value=-1e7, max_value=1e7,
allow_nan=False, allow_infinity=False, width=64,
)
@st.composite
def simple_polygons(draw):
"""A convex quadrilateral, guaranteed valid by construction."""
x = sorted(draw(st.lists(coord, min_size=2, max_size=2, unique=True)))
y = sorted(draw(st.lists(coord, min_size=2, max_size=2, unique=True)))
return Polygon([(x[0], y[0]), (x[0], y[1]), (x[1], y[1]), (x[1], y[0])])
@given(simple_polygons())
@settings(max_examples=500, deadline=None)
def test_wkb_roundtrip_preserves_geometry(poly):
restored = from_wkb(to_wkb(poly))
assert restored.geom_type == poly.geom_type
assert restored.equals(poly), "round trip changed the geometry"
assert restored.area == poly.area, "round trip changed the area"
Building the polygon from sorted, distinct bounds rather than from four arbitrary points is what keeps the strategy useful. Arbitrary points generate self-intersecting rings constantly, and a test that spends its budget rejecting invalid inputs never reaches the interesting valid ones.
Step-by-Step Walkthrough
1. State the properties before writing the strategy
The hard part is deciding what must hold. Four properties cover most encoders, and each is a distinct claim:
- Type is preserved. A
Polygonin is aPolygonout — not aMultiPolygon, not aGeometryCollection. - Structure is preserved. Component count and interior-ring count are unchanged.
- Geometry is preserved.
restored.equals(original)— a topological comparison, not a coordinate one. - Area is preserved exactly. For a lossless binary encoding this really is exact, and asserting approximate equality here would hide a real bug.
Writing them out is where the value is. “Round-trips correctly” is not a testable statement; those four are.
2. Generate the awkward structure deliberately
The default strategies produce simple shapes. Interesting failures need interior rings and multipart geometries, and those have to be constructed.
@st.composite
def polygons_with_holes(draw):
"""An outer square with n non-overlapping square holes in a row."""
n = draw(st.integers(min_value=1, max_value=8))
size = 100.0
outer = [(0, 0), (0, size), (size, size), (size, 0)]
holes, step = [], size / (n + 1)
for i in range(n):
cx = step * (i + 1)
w = step / 3
holes.append([(cx - w, 40), (cx - w, 60), (cx + w, 60), (cx + w, 40)])
return Polygon(outer, holes)
@given(polygons_with_holes())
def test_holes_survive_the_round_trip(poly):
restored = from_wkb(to_wkb(poly))
assert len(restored.interiors) == len(poly.interiors)
assert restored.equals(poly)
Constructing holes procedurally rather than randomly keeps every generated example valid while still varying the structure — which is the property that makes a strategy productive.
3. Test the GeoPackage envelope, not only bare WKB
The bare WKB path is Shapely’s and is well tested. Your code is the GeoPackage Binary wrapper around it, and that is where the bugs are.
@given(simple_polygons(), st.sampled_from([0, 1, 2, 3, 4]), st.integers(1024, 32767))
def test_gpb_roundtrip(poly, envelope_code, srs_id):
"""Your header writer and header parser must agree, for every envelope code."""
blob = write_gpb(poly, srs_id=srs_id, envelope_code=envelope_code)
assert blob[:2] == b"GP"
assert read_gpb_srs_id(blob) == srs_id
restored = from_wkb(gpb_to_wkb(blob))
assert restored.equals(poly)
Sampling the envelope code across all five values is what catches the classic bug: a header-length table that is right for codes 0 and 1 and wrong for 2, 3 or 4, which no hand-written fixture using the default envelope would ever reach.
4. Promote every counterexample to a regression test
A property test that found a bug should not be the only thing guarding it. Add the shrunk example as an ordinary test, so the regression is checked in seconds on every run rather than depending on the generator rediscovering it.
def test_regression_envelope_code_4_header_length():
"""Found by property testing on 2026-08-04: header length table was
missing the XYZM case, so the payload slice started 16 bytes early."""
poly = Polygon([(0, 0), (0, 1), (1, 1), (1, 0)])
blob = write_gpb(poly, srs_id=27700, envelope_code=4)
assert from_wkb(gpb_to_wkb(blob)).equals(poly)
Recording where the case came from is worth the comment line. A regression test with no context is one somebody deletes during a refactor.
5. Bound the run so CI stays fast
from hypothesis import settings, HealthCheck
settings.register_profile(
"ci", max_examples=200, deadline=None,
suppress_health_check=[HealthCheck.too_slow],
)
settings.register_profile("nightly", max_examples=5_000, deadline=None)
Two hundred examples per property costs a second or two and catches the great majority of what a strategy will ever find. The nightly profile explores the tail without slowing every commit.
Verification
The strategy has to be shown to generate what it claims, because a subtly-wrong strategy produces a test that passes without exercising anything.
from hypothesis import given, note, strategies as st
from collections import Counter
@given(polygons_with_holes())
@settings(max_examples=200)
def test_strategy_actually_varies_hole_count(poly):
"""Record the distribution; a strategy stuck at one hole is broken."""
seen[len(poly.interiors)] += 1
def test_strategy_distribution_is_wide():
assert len(seen) >= 5, f"strategy only produced {sorted(seen)} hole counts"
And confirm the properties can fail, by asserting them against a deliberately broken encoder:
def test_properties_catch_a_broken_encoder():
"""An encoder that drops interior rings must fail the structure property."""
poly = Polygon(
[(0, 0), (0, 10), (10, 10), (10, 0)],
[[(2, 2), (2, 8), (8, 8), (8, 2)]],
)
broken = Polygon(poly.exterior) # holes discarded
assert len(broken.interiors) != len(poly.interiors)
assert not broken.equals(poly)
Alternative Approaches or Edge Cases
Generating from a real corpus. Where a body of real geometry exists, sampling from it finds a different class of problem: the messiness of actual data rather than the extremes of the coordinate space. The two are complementary, and a suite with both is meaningfully stronger than one with either.
Testing the whole pipeline as a property. Encode, write to a container, read back through the driver, decode — and assert the same four properties end to end. Slower, and it catches the class of bug where each component is individually correct and the composition is not.
Coordinate ranges that matter. Restricting coordinates to a realistic projected range keeps the test relevant; allowing the full float64 range finds failures nobody will ever hit. Where a pipeline handles geographic coordinates, add a second strategy bounded to plausible longitude and latitude, because the interesting precision behaviour there is at a completely different scale.
Troubleshooting
The test fails with Unsatisfiable or a filter health check
Cause: The strategy generates mostly invalid geometry and rejects it, so Hypothesis cannot find enough examples. Fix: Construct valid geometry rather than filtering for it, as the sorted-bounds strategy does. Rejection sampling is the usual cause of an unproductive property test.
Area equality fails by a tiny amount
Cause: Something in the path is not lossless — most often a text intermediate such as WKT, which serialises coordinates as decimal. Fix: Find the text step and remove it, or relax the property to a relative tolerance and record why. Silently relaxing it hides exactly the bug the property exists to find.
Every run finds a different failure
Cause: Several genuine bugs, or one bug with many manifestations. Fix: Fix the shrunk case from the first failure and re-run; property tests usually collapse a cluster of apparent failures into one root cause. Hypothesis’s example database makes the previous failures reappear first, which is what you want.
Frequently Asked Questions
Does this replace hand-written tests?
No, and it is weakest exactly where hand-written tests are strongest. A property test says “this holds for everything the strategy generates”; a hand-written test says “this specific realistic case behaves this specific way”, which is what documents intent. Property tests find bugs; example tests explain behaviour. A suite wants both.
How do I test properties that are not exactly equal?
State the tolerance as part of the property and justify it in a comment. “Area is preserved to a relative tolerance of 1e-9 because the path includes a projection” is a property; “area is roughly the same” is not. The discipline of having to justify the tolerance usually reveals whether the lossy step should be there at all.
Should the example database be committed?
Not usually — it is a cache, it is machine-specific in practice, and committing it creates merge conflicts nobody can resolve. What should be committed is the shrunk counterexample as an explicit regression test, which is the durable form of the same information and runs in milliseconds.
Related
- Testing & CI for Spatial Pipelines — parent guide: where the encoding band sits
- How to Read GeoPackage Geometry Blobs in Python — the header parsing these properties exercise
- How to Serialize MultiPolygon Geometries to WKB in Python — the encoder under test
- How to Build a Test GeoPackage Fixture in pytest — the hand-written half of the suite