Seeding Deterministic Spatial Fixtures for Tests

Random points make spatial tests flaky and unreadable. Build fixtures from fixed coordinates with known relationships, so every assertion states a fact rather than a tolerance.

← 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 True
The fixture set and what each point is forA schematic of the test geofence with three points placed relative to it: one clearly inside, one exactly on the eastern boundary and one four metres outside. Beyond the fence, three landmarks sit at increasing distances: St Paul's at 2131 metres, Greenwich at 9148 metres and Edinburgh at 534 kilometres. Each point is annotated with the behaviour it exists to test, from predicate boundary semantics through radius filters to cross-projection distance.Twelve coordinates, each with a jobtest fenceINSIDE_FENCEcontainment must be trueON_BOUNDARYST_Within false, ST_Intersects trueJUST_OUTSIDE (4 m)catches a sloppy buffer or toleranceTrafalgarSt Paul's2 131 mGreenwich9 148 m — outside a 5 km radiusEdinburgh534 km — catches unit bugsEvery point earns its place by being the one that failswhen a specific bug is introduced. A random pointcannot make that promise.

Key Parameters & Options

ChoiceRecommendationWhy
Coordinate sourcepublished landmarksReviewable without running anything
Fixture namingby relationship, not identityjust_outside says what a failure means
Boundary pointsalways include oneThe only way to distinguish ST_Within from ST_Intersects
A far-away pointalways include oneCatches degree-versus-metre confusion instantly
Isolationtransaction rolled back per testSame state everywhere, no re-seeding cost
Randomnessproperty tests only, seededUseful 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.

Fixture to bug mappingFive bugs with the fixture that catches each and whether random data would have found it. A radius filter using degrees instead of metres is caught by the Edinburgh point and would be missed by random points inside a small box. Confusing ST_Within with ST_Intersects is caught by the boundary point and is missed by random data almost always. An off-by-a-few-metres buffer is caught by the just-outside point and missed by random data. A lost WHERE clause is caught by any test asserting an exact count and missed by an at-least-one assertion. A reprojection error is caught by the exact distance assertion and missed entirely by tolerant assertions.Which fixture catches which bugBugCaught byRandom?radius filter in degrees, not metresEDINBURGHnoST_Within used where ST_Intersects meantON_BOUNDARYnobuffer off by a few metresJUST_OUTSIDEnoWHERE clause silently droppedexact row-count assertionnoreprojection error on outputexact distance assertionnoRandom fixtures catch none of these, because every assertion they permit is one these bugs also satisfy.

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.

Suite runtime by isolation strategyThree isolation strategies measured across suites of 50, 200 and 600 tests. Truncating and re-seeding per test scales badly, taking 14 seconds at 50 tests, 58 at 200 and 174 at 600. Restoring from a template database takes 6, 24 and 71 seconds. A transaction rolled back per test takes 3, 9 and 26 seconds, staying comfortably usable as a pre-commit check even at 600 tests.Runtime by isolation strategy and suite sizetruncate + reseedtemplaterollback50 tests14 s6 s3 s200 tests58 s24 s9 s600 tests174 s71 s26 s — still a pre-commit checkUse the template strategy only for the handful of tests that must observe a commit; roll back for the rest.

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 > 0 passes 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 run
def 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"

← Back to CI/CD Pipelines for Spatial APIs