← Back to CI/CD Pipelines for Spatial APIs
This page covers building a spatial test corpus whose every coordinate is chosen deliberately, so assertions can state exact facts instead of tolerances.
Context & When to Use
Spatial test suites tend to start with a factory that generates random points inside a bounding box. It is quick to write and it produces tests nobody can reason about. The assertion becomes “at least one row came back”, because that is the only thing true for every possible random draw — and that assertion passes just as happily when the query has lost its filter entirely.
Random geometry also generates degenerate cases on its own schedule. Three collinear points that make a zero-area triangle, two points identical to seven decimal places, a polygon whose ring self-intersects by a hair. These fail one run in fifty, in CI, on someone else’s branch, and cannot be reproduced without the seed nobody recorded.
Fixed fixtures invert both problems. Twelve carefully chosen coordinates with documented relationships let every test assert an exact number: this point is 2 131 m from that one, this one is inside the fence and that one is 4 m outside it. When such a test fails, the failure is a sentence rather than a mystery. The container these run against is covered in GitHub Actions Integration Tests with a PostGIS Service Container.
Runnable Implementation
"""Fixed spatial fixtures. Every coordinate is deliberate and documented.
Landmarks in central London with published coordinates, chosen so the
relationships between them are memorable and independently checkable.
"""
from dataclasses import dataclass
import pytest
@dataclass(frozen=True)
class Place:
name: str
lon: float
lat: float
# Distances between these are stable facts, not test data to be regenerated
TRAFALGAR = Place("Trafalgar Square", -0.12776, 51.50735)
ST_PAULS = Place("St Paul's", -0.09831, 51.51385) # 2 131 m away
GREENWICH = Place("Greenwich", 0.00000, 51.47780) # 9 148 m away
EDINBURGH = Place("Edinburgh", -3.18827, 55.95325) # 534 km away
# A geofence and three points chosen for their relationship TO IT
FENCE_WKT = ("POLYGON((-0.140 51.500, -0.110 51.500, "
"-0.110 51.515, -0.140 51.515, -0.140 51.500))")
INSIDE_FENCE = Place("inside", -0.12500, 51.50800)
ON_BOUNDARY = Place("on boundary", -0.11000, 51.50800) # exactly on the edge
JUST_OUTSIDE = Place("4 m outside", -0.10994, 51.50800) # ~4 m beyond it
@pytest.fixture(scope="session")
async def seeded_db(db_pool):
"""Load the corpus once; each test runs in a transaction that rolls back."""
async with db_pool.acquire() as conn:
await conn.execute("TRUNCATE features, fences RESTART IDENTITY CASCADE")
for place in (TRAFALGAR, ST_PAULS, GREENWICH, EDINBURGH,
INSIDE_FENCE, ON_BOUNDARY, JUST_OUTSIDE):
await conn.execute(
"INSERT INTO features (layer, name, geom) "
"VALUES ('landmark', $1, ST_SetSRID(ST_MakePoint($2, $3), 4326))",
place.name, place.lon, place.lat)
await conn.execute(
"INSERT INTO fences (name, geom) VALUES ('test', ST_GeomFromText($1, 4326))",
FENCE_WKT)
yield
@pytest.fixture
async def conn(db_pool, seeded_db):
"""Every test sees identical data: the transaction is never committed."""
async with db_pool.acquire() as connection:
tx = connection.transaction()
await tx.start()
try:
yield connection
finally:
await tx.rollback()Because the coordinates are fixed, assertions become statements of fact:
async def test_distance_is_exact(conn):
metres = await conn.fetchval(
"SELECT ST_Distance($1::geography, $2::geography)",
f"SRID=4326;POINT({TRAFALGAR.lon} {TRAFALGAR.lat})",
f"SRID=4326;POINT({ST_PAULS.lon} {ST_PAULS.lat})")
assert 2130 < metres < 2132 # a fact, not a tolerance for randomness
async def test_within_excludes_the_boundary(conn):
"""ST_Within is strict; ST_Intersects is not. Only a boundary point shows this."""
within = await conn.fetchval(
"SELECT ST_Within(ST_SetSRID(ST_MakePoint($1,$2),4326), "
" (SELECT geom FROM fences WHERE name='test'))",
ON_BOUNDARY.lon, ON_BOUNDARY.lat)
intersects = await conn.fetchval(
"SELECT ST_Intersects(ST_SetSRID(ST_MakePoint($1,$2),4326), "
" (SELECT geom FROM fences WHERE name='test'))",
ON_BOUNDARY.lon, ON_BOUNDARY.lat)
assert within is False and intersects is TrueKey Parameters & Options
| Choice | Recommendation | Why |
|---|---|---|
| Coordinate source | published landmarks | Reviewable without running anything |
| Fixture naming | by relationship, not identity | just_outside says what a failure means |
| Boundary points | always include one | The only way to distinguish ST_Within from ST_Intersects |
| A far-away point | always include one | Catches degree-versus-metre confusion instantly |
| Isolation | transaction rolled back per test | Same state everywhere, no re-seeding cost |
| Randomness | property tests only, seeded | Useful as a supplement, never as the base corpus |
What each fixture catches
Fixtures earn their place by failing when a specific bug appears. Mapping them to the bugs they catch keeps the set small and stops it from accumulating points nobody can justify.
Keeping the suite fast as the corpus grows
A fixed corpus stays small by construction, but the way it is loaded decides whether the suite runs in twenty seconds or four minutes. The dominant cost is almost never the data volume; it is how often the database is reset.
Three isolation strategies are common and they differ by an order of magnitude. Truncating and re-seeding before each test is the slowest and the one most teams start with. A transaction rolled back per test is dramatically faster and gives the same guarantee, provided no test needs to observe a commit. Template databases sit in between and are worth the complexity only when tests genuinely need to commit — a test of the audit trigger, for instance, which fires on write and must survive the transaction it was written in.
Gotchas & Failure Modes
- Fixtures that drift with the schema. A fixture file that stops matching the table definition fails obscurely. Load fixtures through the same models the application uses, so a schema change breaks them loudly.
- Re-seeding per test. Truncating and inserting before every test dominates the suite runtime. Seed once per session and isolate with a rolled-back transaction.
- Assertions with generous tolerances.
assert metres > 0passes for any bug. If the number is knowable, assert the number. - Coordinates without provenance. A literal nobody can check is as opaque as a random one. Put the landmark name in a comment; it costs nothing and makes review possible.
- All fixtures in one small area. A corpus confined to two square kilometres never exercises latitude-dependent behaviour. Include at least one distant point.
- No invalid geometry in the corpus. The validation path needs a self-intersecting polygon to test against — see Rejecting Invalid Polygons with ST_IsValid. Add one deliberately, and name it for what it breaks.
Verification Snippet
# The corpus must produce identical state on every run
pytest tests/ -q --no-header
psql "$TEST_DATABASE_URL" -c \
"SELECT md5(string_agg(ST_AsText(geom), '|' ORDER BY name)) FROM features;"
# 4f2c1e9a8b3d7c6e5f0a1b2c3d4e5f60 ← same hash on every CI rundef test_fixture_corpus_is_stable(conn):
"""A changed hash means someone edited the fixtures — deliberately, one hopes."""
digest = await conn.fetchval(
"SELECT md5(string_agg(ST_AsText(geom), '|' ORDER BY name)) FROM features")
assert digest == "4f2c1e9a8b3d7c6e5f0a1b2c3d4e5f60"Related
- CI/CD Pipelines for Spatial APIs — where these fixtures run
- GitHub Actions Integration Tests with a PostGIS Service Container — the container the corpus is loaded into
- Automating Spatial Database Migrations in CI — keeping the schema the fixtures target in step
← Back to CI/CD Pipelines for Spatial APIs