Property-Based Tests for Spatial Transforms
This guide, part of Testing Geospatial Analysis Code, shows how to test coordinate and geometry transformations by generating hundreds of inputs and asserting properties, instead of hand-writing a few examples and their expected outputs.
Problem Context & Constraints
Hand-written tests for spatial code share a blind spot: the author chooses inputs they have thought about. The inputs that break spatial code are the ones nobody thought about — a longitude of exactly 180, a polygon whose two vertices coincide, a point that sits precisely on a tract boundary, a ring with a spike of zero width, a coordinate in the southern hemisphere in a pipeline written in the northern one.
Property-based testing inverts the approach. You describe the space of valid inputs and a property every output must satisfy, and the framework generates inputs — biased toward edges and extremes — until it finds one that violates the property. When it does, it shrinks that input to the simplest example that still fails, which is usually a far better bug report than the original.
Three constraints shape how to use it for spatial work. The input strategy must produce valid inputs, or the test spends its time rejecting garbage; generating random coordinate lists produces mostly invalid polygons. The property must be genuinely invariant — something that holds for every valid input, not merely for typical ones. And tolerances must be explicit, because floating-point geometry is approximate and an exact-equality property will fail on legitimate rounding.
Prerequisites
python3.11hypothesis6.103.1,pytest8.2.2shapely2.0.4,pyproj3.6.1,geopandas0.14.4,numpy1.26.4- The functions under test, importable without touching protected data
- A declared working coordinate reference system and its area of use
Step-by-Step Solution
Build strategies that generate valid coordinates and valid polygons, then assert invariants for each transformation the pipeline performs.
# Property-based tests for coordinate and geometry transformations.
# Pinned: hypothesis==6.103.1, pytest==8.2.2, shapely==2.0.4, pyproj==3.6.1, numpy==1.26.4
import math
import numpy as np
import pytest
from hypothesis import assume, given, settings, strategies as st
from pyproj import CRS, Transformer
from shapely.geometry import Point, Polygon
from shapely.validation import make_valid
WORK = CRS.from_epsg(5070)
AREA = WORK.area_of_use # west, south, east, north in degrees
# --- Strategies ------------------------------------------------------------
lon_in_area = st.floats(min_value=AREA.west, max_value=AREA.east, allow_nan=False)
lat_in_area = st.floats(min_value=AREA.south, max_value=AREA.north, allow_nan=False)
@st.composite
def star_polygon(draw, cx=1_000_000.0, cy=2_000_000.0):
"""Valid simple polygons: vertices at increasing angles, random radii.
Sorting by angle guarantees a simple (non-self-intersecting) ring, so the
test spends its budget on valid inputs instead of rejecting garbage.
"""
n = draw(st.integers(min_value=3, max_value=40))
angles = sorted(draw(st.lists(st.floats(0, 2 * math.pi, exclude_max=True),
min_size=n, max_size=n, unique=True)))
radii = draw(st.lists(st.floats(10.0, 50_000.0), min_size=n, max_size=n))
pts = [(cx + r * math.cos(a), cy + r * math.sin(a)) for a, r in zip(angles, radii)]
poly = Polygon(pts)
assume(poly.is_valid and poly.area > 1.0)
return poly
# --- Invariants --------------------------------------------------------------
@given(lon_in_area, lat_in_area)
@settings(max_examples=800, deadline=None)
def test_round_trip_within_a_centimetre(lon, lat):
fwd = Transformer.from_crs(4326, WORK, always_xy=True)
inv = Transformer.from_crs(WORK, 4326, always_xy=True)
x, y = fwd.transform(lon, lat)
lon2, lat2 = inv.transform(x, y)
# About 1e-7 degrees is a centimetre; generous for any epidemiological use.
assert math.isclose(lon, lon2, abs_tol=1e-7)
assert math.isclose(lat, lat2, abs_tol=1e-7)
@given(star_polygon(), st.floats(1.0, 5_000.0))
def test_buffer_contains_and_grows(poly, d):
b = poly.buffer(d)
assert b.contains(poly.representative_point())
assert b.area >= poly.area
@given(star_polygon(), star_polygon(cx=1_010_000.0))
def test_intersection_area_bounded(a, b):
i = a.intersection(b)
tol = 1e-6 * max(a.area, b.area)
assert i.area <= a.area + tol and i.area <= b.area + tol
@given(star_polygon())
def test_area_preserved_by_equal_area_projection(poly):
"""Re-projecting through geographic coordinates and back must not change area."""
to_geo = Transformer.from_crs(WORK, 4326, always_xy=True)
back = Transformer.from_crs(4326, WORK, always_xy=True)
xs, ys = poly.exterior.coords.xy
gx, gy = to_geo.transform(np.asarray(xs), np.asarray(ys))
rx, ry = back.transform(gx, gy)
again = Polygon(zip(rx, ry))
assert math.isclose(again.area, poly.area, rel_tol=1e-6)
@given(star_polygon())
def test_make_valid_is_idempotent(poly):
once = make_valid(poly)
twice = make_valid(once)
assert once.equals(twice)
The polygon strategy is the part worth reusing. Generating vertices at sorted angles around a centre guarantees a simple ring, so every generated input is valid and the test budget is spent checking behaviour rather than discarding malformed shapes. Varying the radius per vertex produces concave, spiky and near-degenerate polygons, which is where overlay and buffer code breaks.
The area-preservation test is specific to epidemiology, where equal-area projections are chosen precisely so that rates are comparable across a map. It asserts that sending a polygon out to geographic coordinates and back does not change its area, which catches a wrong axis order, a wrong datum, or a transform silently falling back to a less accurate method.
Validation & Edge Cases
Strategies that reject most inputs. If assume discards most generated values, the framework warns and the test is weak. Build the validity into the generator, as the angular strategy does, rather than filtering after the fact.
Properties that are almost invariant. “The centroid lies inside the polygon” is false for concave shapes and will fail correctly. Use a representative point when inside-ness is what you mean.
Tolerances that are too tight. Floating-point geometry accumulates error. A relative tolerance scaled to the operand size is usually right; an absolute tolerance in metres fails on large polygons and passes wrongly on tiny ones.
Inputs outside the working projection’s area of use. A projected system is only valid over its area of use, and generating coordinates outside it tests behaviour nobody should rely on. Constrain the strategy to the area of use and test out-of-area handling separately, as an explicit rejection.
Invariants for the analysis layer, not only geometry. The same approach applies above the geometry library. A spatial weights builder should produce a symmetric matrix for contiguity on any generated tessellation; a rate calculation should return the same answer when every numerator and denominator is multiplied by the same constant only if it is a ratio, and a different answer if it applies a small-number adjustment; an aggregation to a coarser level should never produce a total different from the sum of its parts. Each of these is a one-line property that checks a piece of epidemiological logic against every input the generator can think of.
Slow properties that time out. Overlay-heavy properties on complex generated polygons can take seconds per example. Disable the per-example deadline for those tests, reduce the example count, and move them to a nightly suite rather than letting a timeout masquerade as a failure on every commit.
Nondeterminism in the code under test. A property that fails intermittently is often exposing an unseeded random component or an unstable sort inside the function. That is a real defect worth fixing, not a flaky test to be retried until it passes.
Found failures that are not regressions yet. When the framework finds a failure, save the shrunk example as an explicit test case. Otherwise the database of found examples is the only record, and it lives on one machine.
Falsifying example: test_buffer_contains_and_grows(
poly=,
d=1.0,
)
The tolerance question in that list is concrete: a fixed absolute tolerance is simultaneously too tight for large polygons and too loose for small ones:
Compliance Notes
Property-based tests are code-quality controls rather than disclosure controls, but they carry one compliance obligation: they must never generate or embed real addresses or case locations. Generated coordinates within the study area are synthetic by construction; a test that loads a sample of real points to “make it realistic” has put a fragment of the case file into the repository and into continuous integration logs.
Record the strategies, the number of examples per test, the tolerances and the pinned library versions in the test configuration, so a reviewer assessing the pipeline’s validation can see exactly what was checked and how strictly. Store every shrunk failing example as a named regression test, which turns a transient discovery into a permanent part of the evidence that the method was validated.
Related Topics
- Testing Geospatial Analysis Code — the parent guide and the other test layers.
- How to Align WGS84 and UTM for County Health Data — the transformations these round-trip tests protect.
- Making a Spatial Pipeline Idempotent and Restartable — the order-dependence that generated inputs also expose.