Mocking Geospatial Data for Tests

Mocking geospatial data is the practice of substituting live spatial sources — tile servers, geocoders, spatial databases, raster stores — with deterministic, in-memory stand-ins so that spatial logic can be validated in isolation. It matters because production spatial sources introduce schema drift, network latency, and unpredictable feature density that make assertions flaky and non-reproducible. This pattern sits inside the broader Geospatial QA Fundamentals & Architecture discipline: where that parent body of work defines the pipeline shape and precision policy, this page focuses on how to build the fixtures and doubles that feed every layer of that pipeline. Unlike conventional software mocking, spatial mocking must preserve coordinate reference system (CRS) alignment, topological validity, and geometric precision — a mock polygon that silently self-intersects or drifts across a CRS transformation defeats the test it was meant to support. A mature approach treats mock data as versioned, byte-reproducible artifacts provisioned alongside the test runner rather than scraped from disk at runtime.

Choosing a spatial mock strategy by data class and test tier A top-down decision flow. The root node, spatial source under test, fans out to four data classes. Vector geometry resolves to an in-memory GeoDataFrame at the unit tier. A raster tile or array resolves to a synthetic numpy band stack at the unit and integration tiers. A PostGIS connection resolves to a Dockerised PostGIS instance at the integration tier. An external API resolves to a recorded response payload at the integration and end-to-end tiers. Spatial source under test branch by data class Vector geometry points · lines · polygons Raster tile / array bands · extent · affine PostGIS connection ST_* · GiST planner External API geocode · tile server In-memory GeoDataFrame Synthetic numpy band stack Dockerised PostGIS Recorded response payload Unit tier Unit + integration Integration tier Integration + E2E

Mock Strategy Taxonomy

Choosing the right double depends on the spatial data class and the test layer it serves. Mocks that are too heavy slow the suite; mocks that are too light strip away the spatial semantics — ST_Intersects, GiST index selectivity, WKB serialization — that the test exists to verify. The table below maps each spatial data class to a recommended strategy, the tolerance posture it demands, and the CRS units that constrain its thresholds.

Spatial data class Recommended double Tolerance strategy Typical threshold CRS units
In-memory vector geometry Seeded GeoDataFrame Coordinate epsilon after round-trip 1e-7 deg / 1e-3 m degrees (4326), metres (3857)
Raster tile / array Synthetic numpy band stack Per-pixel resolution bound ±½ pixel map units per pixel
Spatial relationship Overlay overlap ratio Area-delta envelope overlap ≥ 0.85 projected metres
Topology check Hausdorff / Fréchet distance Bounded shape divergence ≤ snap tolerance source CRS units
PostGIS connection Dockerised PostGIS Version-pinned extension parity exact function semantics source CRS units
External API (geocode/tile) Recorded response payload Schema + field-level equality exact n/a

The recommended posture is to fail closed: a mock should reject self-intersecting polygons, unclosed rings, and invalid multiparts before a spatial join runs, rather than letting an invalid geometry propagate into an assertion where the failure surfaces far from its cause.

Vector Geometry Doubles

In-memory vector fixtures replace shapefile and GeoJSON reads with seeded coordinate arrays, eliminating I/O and guaranteeing byte-level reproducibility across a distributed test matrix. The core idea is to construct geometries from a seeded random generator and validate them on creation, then attach the CRS explicitly so no downstream operation has to infer it. A minimal predicate looks like make_valid(Polygon(rng.uniform(-10, 10, (5, 2)))) — generate, then immediately repair, so invalid topology never enters the fixture. For large fixtures, stream geometries through a generator and bound the count to keep each fixture sub-second and memory-safe. This double is the workhorse of unit tests and pairs directly with synthetic vector data generation in the test-data pillar, which covers richer attribute synthesis.

Raster Tile Doubles

Raster mocks substitute synthetic numpy band stacks for real GeoTIFFs or tile-server responses. Each fixture pins resolution, extent, nodata value, and an affine transform so that pixel-to-coordinate mapping is exact. The validity predicate here is a resolution bound rather than a vertex check: a resampled pixel may land within ±0.5 of its source cell, and assertions must accept that envelope. Isolate raster fixtures from vector fixtures in separate worker pools to prevent cross-modal memory contention — a full band stack can dwarf a vector fixture by orders of magnitude. The deeper edge cases (band alignment, mixed dtypes, overviews) are covered by raster mocking techniques.

Spatial Relationship and Topology Doubles

Relationship doubles assert predicates, not coordinates. Floating-point arithmetic and projection transforms introduce micro-drift that vertex-by-vertex equality cannot survive, so these mocks lean on bounded metrics. The Hausdorff distance quantifies the worst-case divergence between two geometries:

dH(A,B)=max{ supaAinfbBab,  supbBinfaAab }d_H(A, B) = \max\left\{\ \sup_{a \in A} \inf_{b \in B} \lVert a - b \rVert,\ \ \sup_{b \in B} \inf_{a \in A} \lVert a - b \rVert\ \right\}

A topology assertion passes when d_H(A, B) stays at or below the snap tolerance configured for the test, rather than requiring identical vertices. Relationship checks use overlap ratios — intersections.area / gdf_a.area constrained to a configurable floor such as 0.85. Defining these envelopes once and loading them from config is exactly the discipline covered in setting up spatial tolerance thresholds in assertions; reuse those thresholds here so a mock and a production assertion never disagree on what “close enough” means.

Tolerance Configuration

Configuration management must externalize tolerance thresholds, precision models, and geometry validation rules into structured YAML or TOML manifests rather than scattering magic numbers through test bodies. A production-ready mock configuration enforces coordinate precision limits, defines acceptable Hausdorff distances for topology checks, and specifies raster resolution bounds. When wiring these into pytest, parse tolerance parameters at collection time and apply them globally via session-scoped fixtures or custom markers such as @pytest.mark.spatial_tolerance(0.001). This prevents ad-hoc overrides that silently relax determinism and keeps behaviour identical across local development, staging, and CI runners. Tolerance bounds are CRS-unit-sensitive: an epsilon of 1e-7 is appropriate for WGS84 degrees but meaningless for projected metres, where 1e-3 is the practical floor — encode the unit alongside the value so the manifest is unambiguous.

Production-Grade Python Implementation

The following pattern structures vector mocks for a pytest-based spatial QA pipeline. It prioritizes memory safety, CRS normalization, and tolerance-aware assertions, and loads its tolerance from a session-scoped config fixture so no threshold is hard-coded inside a test.

import pytest
import geopandas as gpd
import numpy as np
from shapely.geometry import Polygon
from shapely.validation import make_valid

# Session-scoped fixture: one source of truth for CRS + tolerance.
@pytest.fixture(scope="session")
def crs_config():
    return {"source": "EPSG:4326", "target": "EPSG:3857", "tolerance": 1e-6}

# Generator-based synthesis keeps peak memory bounded for large counts.
def generate_synthetic_polygons(count: int, seed: int = 42):
    rng = np.random.default_rng(seed)
    for _ in range(count):
        coords = rng.uniform(-10, 10, (5, 2))
        # Repair on creation so invalid topology never enters a fixture.
        yield make_valid(Polygon(coords))

@pytest.fixture
def synthetic_gdf(crs_config):
    geoms = list(generate_synthetic_polygons(100))
    gdf = gpd.GeoDataFrame(
        {"id": range(100), "geometry": geoms},
        crs=crs_config["source"],
    )
    # Normalize to the target CRS once, up front.
    return gdf.to_crs(crs_config["target"])

# Tolerance-aware assertion: compare predicates, not raw coordinates.
def assert_spatial_intersection(gdf_a, gdf_b, min_overlap_ratio=0.85):
    intersections = gdf_a.overlay(gdf_b, how="intersection")
    overlap_ratios = intersections.area / gdf_a.area
    assert (overlap_ratios >= min_overlap_ratio).all(), \
        "Spatial overlap below configured tolerance threshold"

Key implementation guidelines:

  • Call shapely.validation.make_valid() immediately after geometry generation so invalid topology cannot propagate into a spatial join.
  • Use contextlib scopes or explicit gc.collect() when streaming large raster tiles or vector batches, and release GDAL/OGR handles deterministically to avoid leaks in parallel workers.
  • Pin dependency versions in requirements.txt or pyproject.toml to avoid silent CRS or precision-model changes across library updates (Shapely 2.x, GeoPandas 0.14+).
  • Validate fixture schemas against production contracts with pydantic or pandera before executing spatial operations, so a drifted mock fails fast at collection time.

PostGIS and Database-Side Counterparts

Mocking the connection layer alone strips away the C-level spatial functions — ST_Intersects, ST_Buffer, topology triggers — that the test needs, so relational spatial engines call for a real-but-isolated database rather than a pure mock. The full treatment, including connection pooling, transactional rollback, and extension-version parity, lives in best practices for mocking PostGIS connections. The server-side counterpart of the in-memory overlap check is a single spatial predicate evaluated by the database itself:

-- Seed two geometries in the test CRS and assert their overlap server-side.
WITH a AS (SELECT ST_GeomFromText('POLYGON((0 0,0 4,4 4,4 0,0 0))', 4326) AS g),
     b AS (SELECT ST_GeomFromText('POLYGON((1 1,1 5,5 5,5 1,1 1))', 4326) AS g)
SELECT ST_Area(ST_Intersection(a.g, b.g)) / ST_Area(a.g) AS overlap_ratio
FROM a, b;

Drive it from Python with psycopg2 against a Dockerised instance whose postgis and pgRouting versions match production exactly, seeding inside a transaction you roll back at teardown:

import psycopg2

def assert_postgis_overlap(dsn, min_ratio=0.85):
    conn = psycopg2.connect(dsn)
    try:
        with conn.cursor() as cur:
            cur.execute("BEGIN;")  # roll back so no spatial state leaks between tests
            cur.execute(OVERLAP_SQL)
            (ratio,) = cur.fetchone()
            assert ratio >= min_ratio, f"server-side overlap {ratio:.3f} below floor"
    finally:
        conn.rollback()
        conn.close()

Provision the database via Docker Compose or a Kubernetes operator with read-only roles and autovacuum disabled during the run to stabilize query plans — the isolation requirements here are dictated by Security Boundaries in Spatial QA, which mocks must never violate by inheriting production credentials, routes, or write permissions.

Pipeline Integration

Mock fixtures must integrate cleanly into automated deployment pipelines, not just pass locally. Configure pytest to run spatial tests in isolated worker pools with pytest-xdist, respecting GDAL/OGR thread-safety limits so parallel workers don’t corrupt shared driver state. Cache generated fixtures as CI artifacts (GitHub Actions, GitLab CI) to avoid recomputing the same seeded geometries on every run. Pin the container’s libgeos and PROJ versions so a runner upgrade cannot silently change a geometry function’s result. Pre-commit hooks should validate CRS consistency and geometry validity before a merge, and each spatial check should emit structured log output — the CRS pair, tolerance value, and pass/fail predicate — so failures are diagnosable from the log alone. How these gates layer across the test hierarchy is governed by Understanding the GIS Test Pyramid: in-memory vector doubles back the unit tier, recorded payloads and Dockerised PostGIS back the integration tier, and scaled-down production snapshots back the end-to-end tier. The cardinality of those snapshots is bounded by Scoping Rules for Map Data Validation, which keeps fixtures inside explicit bounding boxes and feature-density caps so execution stays deterministic.

Common Failure Modes and Gotchas

  1. Silent CRS unit mismatch. A tolerance of 1e-7 reads as sub-millimetre in WGS84 degrees but as sub-micrometre in projected metres — apply a degree epsilon to a metric CRS and every assertion passes vacuously. Always encode the unit beside the threshold.
  2. Invalid geometry entering a fixture. Skipping make_valid() lets a self-intersecting polygon reach a spatial join, where it raises a TopologyException far from the line that created it. Repair on synthesis, not on assertion.
  3. Round-trip drift across to_crs. Transforming to the target CRS and back rarely returns identical coordinates; vertex-equality assertions on round-tripped geometry are inherently flaky. Bound the divergence with a Hausdorff envelope instead.
  4. GDAL/OGR handle leaks under pytest-xdist. Failing to release contexts in parallel workers exhausts file handles mid-run and produces failures that don’t reproduce single-threaded. Close handles deterministically.
  5. Pure-mock PostGIS connections. Patching psycopg2.connect strips ST_* functions and the GiST planner, masking spatial join cardinality and performance regressions. Use a Dockerised instance with matching extension versions.
  6. Boundary-touching DE-9IM edge cases. Two polygons that share only an edge are not an overlap under most predicates; a mock that treats touches as intersects will assert the wrong relationship. Pin the exact predicate the production check uses.
  7. Unseeded randomness. A fixture built without a fixed seed produces different geometries per run, turning a real regression into noise. Seed the generator and pin the count.

Conclusion

Treating mock data as first-class, versioned pipeline artifacts — seeded for reproducibility, repaired on creation, bounded by CRS-aware tolerance, and isolated from production infrastructure — is what lets a spatial suite run deterministically across every runner. Build the doubles to the precision and security policy defined in Geospatial QA Fundamentals & Architecture, and the rest of the validation pipeline inherits that determinism for free.