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.

Seeded generation and validity-gating of edge-case GeoJSON fixtures A seeded config manifest feeds a generator that branches into three defect families — structural RFC 7946 violations, topological invalidities, and numeric precision faults. Each family routes through a single synthesiser into a validity gate built from jsonschema plus shapely.is_valid. Outputs that classify as invalid, empty, or null are kept as expected-invalid fixtures and committed to a version-controlled fixtures directory; outputs that classify as still valid are rejected and looped back to the synthesiser to be regenerated with tighter parameters and a fresh seed. 1 · Seed 2 · Defect family 3 · Synthesise 4 · Validity gate 5 · Classify Config manifest seed · CRS · decimals Structural missing type · bbox order Topological bowtie · unclosed ring Numeric truncation · coord bloat Synthesiser one defect per fixture Validity gate jsonschema + shapely.is_valid Expected-invalid invalid · empty · null Accidentally-valid reject · regenerate fixtures/ committed keep valid? regenerate · tighter params + new seed

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 ϵ\epsilon and the snap grid is gg, then choosing

g2ϵg \le 2\epsilon

guarantees that two coordinates judged equal under ϵ\epsilon collapse to the same grid node. A coarser grid produces phantom duplicate vertices; a finer one lets floating-point drift survive into the fixture and makes the “expected-invalid” classification non-deterministic. Pick ϵ\epsilon from the CRS unit first, then derive 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

  1. Accidentally-valid bowties. Swapping vertices on a near-degenerate ring can yield a still-valid polygon, so make_bowtie silently produces a happy-path fixture. Always assert the classification (step 4) and regenerate with a fresh seed offset until is_valid is False.
  2. 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_valid will not flag it; add a bbox-width guard (span > 180°) or split on the dateline, exactly as cross-format checks do when comparing GeoJSON vs Shapefile outputs.
  3. Empty vs null are different defects. {"type": "Polygon", "coordinates": []} parses to a POLYGON EMPTY whose is_valid is vacuously True, while a null geometry is a legal RFC 7946 feature with no geometry at all. Test both branches separately; collapsing them hides a real parser divergence.
  4. 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.
  5. GEOS version skew. is_valid can 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.