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…

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 hypothesis 6.90+ and shapely 2.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

python
# 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.

What each kind of test reachesA hand-written fixture covers the shapes the author thought of, which is a small and well-chosen set. A property-based strategy covers a much wider space including coordinate extremes, degenerate rings and unusual component counts. The intersection is where both agree; the region only the strategy reaches is where encoder bugs actually live, and the region neither reaches is what a real-data corpus is for.hand-written fixturethe shapes you thought ofsmall, well-chosen, readablegenerated strategycoordinate extremes, odd countswhere encoder bugs liveboth agreeordinary shapesalready well coveredneither reachesreal-world messinesswhat a data corpus is for
Property testing widens coverage in one specific direction — coordinate and structure extremes — and does nothing for the bottom-right box.

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 Polygon in is a Polygon out — not a MultiPolygon, not a GeometryCollection.
  • 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.

python
@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.

python
@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.

Shrinking turns a random failure into a minimal reproductionA property test finds a failure on a generated polygon with hundreds of vertices and extreme coordinates. Hypothesis then repeatedly simplifies the input while the failure persists, reducing it to the smallest example that still breaks — often a two-ring polygon with small integer coordinates. The report is that minimal case, which is what makes the failure diagnosable.first failure400 verticesextreme coordinatesshrinkingsimplify while it still failsautomatic, dozens of stepsreported case2 rings, integersdiagnosable in a minute
Shrinking is the feature that makes generated failures worth having; without it a random 400-vertex counterexample is nearly useless.

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.

python
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

python
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.

python
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:

python
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)
Four properties, four distinct claimsType preservation catches an encoder that promotes a polygon to a multipolygon. Structure preservation catches one that drops interior rings. Topological equality catches one that moves a vertex. Exact area equality catches a precision loss that topological equality tolerates. Each property catches a failure the others do not, which is why all four are asserted rather than one.type preservedcatches a silent promotion to a multipart typestructure preservedcatches dropped interior rings or componentstopologically equalcatches a moved vertex, ignores harmless reorderingarea exactly equalcatches precision loss that topological equality tolerates
Asserting only the third — the obvious one — misses both a type promotion and a precision loss.

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.