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.
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:
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
contextlibscopes or explicitgc.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.txtorpyproject.tomlto avoid silent CRS or precision-model changes across library updates (Shapely 2.x, GeoPandas 0.14+). - Validate fixture schemas against production contracts with
pydanticorpanderabefore 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
- Silent CRS unit mismatch. A tolerance of
1e-7reads 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. - Invalid geometry entering a fixture. Skipping
make_valid()lets a self-intersecting polygon reach a spatial join, where it raises aTopologyExceptionfar from the line that created it. Repair on synthesis, not on assertion. - 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. - 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. - Pure-mock PostGIS connections. Patching
psycopg2.connectstripsST_*functions and the GiST planner, masking spatial join cardinality and performance regressions. Use a Dockerised instance with matching extension versions. - Boundary-touching DE-9IM edge cases. Two polygons that share only an edge are not an overlap under most predicates; a mock that treats
touchesasintersectswill assert the wrong relationship. Pin the exact predicate the production check uses. - 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.
Related
- Spatial Assertion Types Explained — the assertion taxonomy your mocks feed.
- Scoping Rules for Map Data Validation — bounding-box and cardinality limits for fixtures.
- Security Boundaries in Spatial QA — isolation rules mocks must honour.
- Understanding the GIS Test Pyramid — which double belongs at which test tier.
- Best practices for mocking PostGIS connections — the database-side deep dive.
- Test data generation and mocking strategies — synthetic vector and raster fixture generation across the wider pipeline.