Generating synthetic GeoJSON for edge case testing
Generating synthetic GeoJSON for edge case testing is the problem of producing — on demand, and byte-identically across every CI runner — the malformed and boundary-condition feature payloads that quietly break parsers, spatial indexes, and tiling engines. The specific tools are Python 3.11+, the Shapely 2.x geometry API for construction and repair, numpy.random.Generator for seeded randomness, and the GeoJSON specification (RFC 7946) as the structural contract every fixture must either honour or deliberately violate. This page sits beneath Synthetic Vector Data Generation, the deterministic-fixture layer of the Test Data Generation & Mocking Strategies discipline, and walks through the exact code that turns a hand-wave like “test the bad inputs” into a versioned, reproducible suite of expected-invalid GeoJSON.
Validation failures rarely originate from clean production extracts; they surface when a parser meets a self-intersecting ring, a coordinate truncated to two decimals, or a polygon that wraps the anti-meridian. Production data seldom contains those anomalies because upstream systems sanitise or reject them — which is precisely why a generator, not a snapshot, must manufacture them so the failure path is proven before a real feed triggers it.
Why edge-case GeoJSON has to be generated, not harvested
Harvesting a broken feature from yesterday’s failed run is non-reproducible: by the time you pin it as a regression fixture, the upstream system may have cleaned, re-projected, or deleted it. A generator instead encodes each defect class as code, seeds it with a fixed PRNG state, and emits identical bytes on every machine. The defects themselves arise at three engineering levels that map directly to how GeoJSON is parsed and consumed.
Structural (RFC 7946) defects come from the JSON layer: a missing type, an improperly nested features array, a bbox written in [minY, minX, maxY, maxX] order instead of [minX, minY, maxX, maxY], or mixed geometry types outside a FeatureCollection. These pass json.loads but fail a schema gate.
Topological defects come from the geometry layer: self-intersecting (“bowtie”) polygons, duplicate consecutive vertices, unclosed linear rings, and zero-area slivers. They satisfy a naive coordinate check but fail OGC Simple Features validity, so PostGIS throws, Mapbox GL silently drops the feature, or a tiler emits a corrupted vector tile.
Numeric defects come from IEEE 754 double precision: 15+ significant digits bloat serialisation and corrupt deduplication hashes, while truncation to 2–4 decimals on a legacy export shifts a vertex enough to invert winding order. The same floating-point reality drives the need to configure spatial tolerance thresholds once, in native CRS units, so the generator and the validator agree on what “equal” means.
Defect families and generation parameters
The table below is the reference the generator config draws from. Each row names a defect family, the GeoJSON-level symptom, the parameter that controls it, and the predicate a downstream check uses to detect it. Threshold ranges assume geographic coordinates (EPSG:4326, degrees) unless noted.
| Defect family | GeoJSON symptom | Generation parameter | Detection predicate | Typical range |
|---|---|---|---|---|
| Ring self-intersection | Bowtie polygon, valid JSON | swap_vertices=(i, j) |
shapely.is_valid is False |
non-adjacent i, j |
| Precision truncation | Vertices rounded short | decimals |
coordinate digit count | 2 to 4 |
| Anti-meridian wrap | Longitude crosses ±180° | force_dateline=True |
bbox width > 180 |
±179.9 to ±180.1 |
| Empty / null geometry | "coordinates": [] or null |
empty_mode |
geom.is_empty / is None |
n/a |
| Winding-order flip | Outer ring clockwise | reverse_ring=True |
signed area sign | RFC 7946 wants CCW |
| Coordinate bloat | 15+ decimal places | decimals=None |
byte length per coord | unbounded |
The link between precision and snapping is exact. If the XY precision budget is
guarantees that two coordinates judged equal under grid_size = 10 ** -decimals from it — never the other way round.
Step-by-step implementation
The pipeline is schema-first: enforce JSON structure, then layer geometry construction and controlled perturbation, then classify and serialise. Every step pins its library so the output is reproducible.
1. Pin the structural contract (Pydantic 2.x)
Validate the RFC 7946 envelope before any geometry logic runs, so a structural defect is caught at the JSON layer rather than deep inside a spatial index.
from pydantic import BaseModel, field_validator
from typing import Literal
class GeoJSONFeature(BaseModel):
type: Literal["Feature"]
geometry: dict | None # None is a legal RFC 7946 null geometry
properties: dict | None = None
@field_validator("geometry")
@classmethod
def check_geometry_shape(cls, v):
if v is None:
return v # null geometry is intentionally allowed
if "type" not in v or "coordinates" not in v:
raise ValueError("geometry needs both 'type' and 'coordinates'")
return v
2. Seed a reproducible generator (NumPy 1.26+)
Use numpy.random.Generator, not the legacy global random module: it gives an isolated, reproducible stream with no global-state contamination between tests.
import numpy as np
def base_ring(rng: np.random.Generator, n: int = 5) -> list[list[float]]:
"""A seeded, closed, valid lon/lat ring to perturb later."""
pts = rng.uniform([-179, -85], [179, 85], size=(n, 2))
ring = [[round(lon, 6), round(lat, 6)] for lon, lat in pts]
ring.append(ring[0]) # close the ring per RFC 7946
return ring
# rng = np.random.default_rng(42) -> identical fixtures on every runner
3. Inject one defect per fixture
Keep each fixture to a single, named defect so a failing test points at exactly one root cause. Each branch below corresponds to a row in the parameter table.
import copy
def make_bowtie(ring: list[list[float]]) -> list[list[float]]:
r = copy.deepcopy(ring)
r[1], r[3] = r[3], r[1] # swap non-adjacent vertices -> self-intersection
return r
def truncate(ring, decimals: int = 2):
return [[round(x, decimals), round(y, decimals)] for x, y in ring]
def force_dateline(ring):
return [[(lon % 360) - 180 + 180.05, lat] for lon, lat in ring] # cross ±180°
DEFECTS = {
"self_intersecting": lambda r: {"type": "Polygon", "coordinates": [make_bowtie(r)]},
"precision_drift": lambda r: {"type": "Polygon", "coordinates": [truncate(r)]},
"anti_meridian": lambda r: {"type": "Polygon", "coordinates": [force_dateline(r)]},
"empty_geometry": lambda r: {"type": "Polygon", "coordinates": []},
"null_geometry": lambda r: None,
}
4. Classify with Shapely 2.x before writing
Run each candidate through shapely.is_valid so the generator can confirm a fixture is invalid for the reason intended. An accidentally-valid output is regenerated, never kept. The Shapely library supplies both the validity test and make_valid for the repair fixtures.
import shapely
from shapely.geometry import shape
def classify(geo_dict: dict | None) -> str:
if geo_dict is None:
return "null"
geom = shape(geo_dict)
if geom.is_empty:
return "empty"
return "valid" if geom.is_valid else "invalid"
# Expected-invalid fixtures must classify as "invalid", "empty", or "null".
5. Serialise into a versioned fixtures directory
Tag each file with its failure_mode and expected_behavior, then commit it next to the parser it exercises. The tag is what makes a future regression self-explaining.
import json
from pathlib import Path
def write_fixture(name: str, geometry, out: Path) -> Path:
feature = {
"type": "Feature",
"geometry": geometry,
"properties": {"failure_mode": name, "expected_behavior": "reject_or_repair"},
}
path = out / f"{name}.geojson"
path.write_text(json.dumps(feature, separators=(",", ":"), sort_keys=True))
return path
Verification pattern
Wire the fixtures into a parameterised test so the suite proves both halves of the contract: the structural envelope is well-formed, and the geometry is invalid for the documented reason. A non-zero exit blocks the merge.
import json, pytest
from pathlib import Path
from shapely.geometry import shape
CASES = ["self_intersecting", "precision_drift", "anti_meridian", "empty_geometry"]
@pytest.fixture(params=CASES)
def edge_case(request):
path = Path("tests/fixtures/geojson") / f"{request.param}.geojson"
return json.loads(path.read_text())
def test_fixture_is_expected_invalid(edge_case):
geom = shape(edge_case["geometry"])
# JSON envelope is well-formed, but the geometry must NOT be silently valid.
assert not (geom.is_valid and not geom.is_empty)
For ad-hoc triage the same check is one line of CLI: python -c "import json,sys; from shapely.geometry import shape; g=shape(json.load(open(sys.argv[1]))['geometry']); print('valid' if g.is_valid and not g.is_empty else 'invalid')" tests/fixtures/geojson/self_intersecting.geojson.
Failure modes and edge cases
- Accidentally-valid bowties. Swapping vertices on a near-degenerate ring can yield a still-valid polygon, so
make_bowtiesilently produces a happy-path fixture. Always assert the classification (step 4) and regenerate with a fresh seed offset untilis_validisFalse. - Anti-meridian features that pass
is_valid. A polygon crossing ±180° in EPSG:4326 is geometrically valid yet renders as a planet-wide smear after any planar overlay.is_validwill not flag it; add abbox-width guard (span > 180°) or split on the dateline, exactly as cross-format checks do when comparing GeoJSON vs Shapefile outputs. - Empty vs null are different defects.
{"type": "Polygon", "coordinates": []}parses to aPOLYGON EMPTYwhoseis_validis vacuouslyTrue, while anullgeometry is a legal RFC 7946 feature with no geometry at all. Test both branches separately; collapsing them hides a real parser divergence. - Precision truncation that flips winding order. Rounding to two decimals can move a vertex enough to reverse the signed area, turning a counter-clockwise outer ring clockwise — a defect the round-trip surfaces when testing coordinate precision loss during conversion. Assert winding explicitly rather than trusting the truncation to preserve it.
- GEOS version skew.
is_validcan disagree between GEOS 3.10 and 3.11 on borderline boundary-touching rings, so an unpinned runner turns a deterministic fixture into a flaky test. Pin the GEOS/Shapely build in the CI container — the same discipline that keeps automated CRS validation in CI pipelines reproducible.
By pinning the structural contract, seeding the randomness, injecting one named defect per fixture, and classifying every output before it is written, synthetic GeoJSON edge cases become version-controlled artifacts that prove a parser’s failure path instead of hoping a real feed never exercises it — exactly the reproducibility contract that the broader practice of Synthetic Vector Data Generation is built on.
Related
- Edge Case Spatial Data Creation — the full taxonomy of degenerate, invalid, and boundary-condition geometries beyond GeoJSON.
- Validating polygon topology with GeoPandas — the validator side that these fixtures are written to stress.
- Comparing GeoJSON vs Shapefile outputs in tests — confirming an edge-case feature survives (or correctly fails) serialisation across formats.
- Synthetic Vector Data Generation — the parent reference for deterministic, schema-constrained vector fixtures.