Generating Synthetic Invalid Geometries for Tests

Every geometry rule needs a geometry that breaks it, and waiting for production to supply one is not a plan. This guide builds a catalogue of deliberately invalid geometries — one minimal generator per defect class, each asserted against the GEOS reason string it should produce — plus the valid look-alikes that catch over-broad rules. The catalogue then does double duty: it is the fixture source for individual rule tests, and it is the coverage check that stops a defect class being added without a rule to detect it. It supplies the fixtures assumed by Testing Spatial Validation Code.

Prerequisites

  • shapely 2.0+ and pytest 8.x. GEOS 3.10 or newer is assumed; reason strings differ slightly across older versions.
  • A list of the defect classes your rules claim to detect. The catalogue is derived from that list, not the other way round — if a rule exists with no fixture, that gap is the first finding.
  • Familiarity with the OGC validity definitions described in Geometry Validity Checks for Vector Data, because the fixture names should match the vocabulary the rules use.

Step-by-Step Procedure

Step 1 — One minimal generator per defect class

# tests/fixtures/invalid.py
import json

from shapely.geometry import Polygon, MultiPolygon, LineString, shape
from shapely import wkt


def bowtie() -> Polygon:
    """Exterior ring crosses itself once, at (5, 5)."""
    return Polygon([(0, 0), (10, 10), (10, 0), (0, 10)])


def hole_outside_shell() -> Polygon:
    """Interior ring lies entirely beyond the exterior."""
    shell = [(0, 0), (10, 0), (10, 10), (0, 10)]
    hole = [(12, 2), (16, 2), (16, 6), (12, 6)]
    return Polygon(shell, [hole])


def nested_holes() -> Polygon:
    """One interior ring contained inside another."""
    shell = [(0, 0), (20, 0), (20, 20), (0, 20)]
    outer_hole = [(4, 4), (16, 4), (16, 16), (4, 16)]
    inner_hole = [(7, 7), (12, 7), (12, 12), (7, 12)]
    return Polygon(shell, [outer_hole, inner_hole])


def duplicate_rings() -> MultiPolygon:
    """Two identical parts in one multipolygon."""
    part = Polygon([(0, 0), (5, 0), (5, 5), (0, 5)])
    return MultiPolygon([part, part])


def degenerate_ring() -> Polygon:
    """Three collinear points: zero area, no interior."""
    return Polygon([(0, 0), (5, 5), (10, 10)])


def unclosed_ring() -> Polygon:
    """Shapely closes rings, so this defect must be built at the text level."""
    return wkt.loads("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 5))")


def self_touching_line() -> LineString:
    """Non-simple linestring: revisits a vertex."""
    return LineString([(0, 0), (10, 0), (5, 5), (5, -5)])


def near_duplicate_vertices(epsilon: float = 1e-9) -> Polygon:
    """Two vertices closer together than the working precision."""
    return Polygon([(0, 0), (10, 0), (10 + epsilon, 1e-12), (10, 10), (0, 10)])


def geojson_with_reversed_winding() -> dict:
    """Structurally fine, but exterior ring is clockwise (RFC 7946 wants counter-clockwise)."""
    return json.loads(json.dumps({
        "type": "Polygon",
        "coordinates": [[[0, 0], [0, 10], [10, 10], [10, 0], [0, 0]]],
    }))

Verification: each function returns in a line or two and its coordinates can be sketched on paper. A generator that needs a loop is usually testing the generator rather than the rule.

The defect catalogue, and what each fixture pinsGrid of six defect classes with the minimal construction for each and the GEOS reason string the fixture must continue to produce.Minimal constructionReason string it pinsSelf-intersection4-point bowtieSelf-intersectionHole outside shellsquare + detached inner ringHole lies outside shellNested holessquare + two nested ringsHoles are nestedDuplicate ringsmultipolygon of one part twiceDuplicate RingsDegenerate ringthree collinear pointsToo few points in geometry componentUnclosed ringWKT with first ≠ lastparse-level defect, not in-memoryAsserting the reason string is what stops a fixture drifting into a different defect while its test keeps passing.
Six constructions cover the OGC validity failures a real pipeline encounters.

Step 2 — Pin each fixture to its reason string

# tests/test_fixture_integrity.py
import pytest
import shapely

from tests.fixtures import invalid

EXPECTED_REASON = {
    "bowtie": "Self-intersection",
    "hole_outside_shell": "Hole lies outside shell",
    "nested_holes": "Holes are nested",
    "duplicate_rings": "Duplicate Rings",
    "degenerate_ring": "Too few points in geometry component",
}


@pytest.mark.parametrize("name,fragment", EXPECTED_REASON.items())
def test_fixture_produces_expected_reason(name, fragment):
    geom = getattr(invalid, name)()
    assert not shapely.is_valid(geom), f"{name} should be invalid"
    reason = shapely.is_valid_reason(geom)
    assert fragment.lower() in reason.lower(), (
        f"{name} drifted: expected {fragment!r}, GEOS says {reason!r}")

Verification: change a coordinate in bowtie so the rings no longer cross and confirm this test fails. That is the whole purpose — a fixture that quietly becomes a different defect makes its rule test meaningless while still passing.

Step 3 — Add the valid look-alikes

# tests/fixtures/valid_lookalikes.py
from shapely.geometry import Polygon, MultiPolygon, LineString


def figure_of_eight_but_valid() -> MultiPolygon:
    """Looks like a bowtie; is two disjoint valid parts."""
    return MultiPolygon([
        Polygon([(0, 0), (5, 5), (0, 10)]),
        Polygon([(10, 0), (5, 5), (10, 10)]),
    ])


def legitimate_hole() -> Polygon:
    """A donut — valid, and easily confused with hole_outside_shell by a sloppy rule."""
    return Polygon([(0, 0), (20, 0), (20, 20), (0, 20)],
                   [[(6, 6), (14, 6), (14, 14), (6, 14)]])


def very_thin_but_legitimate() -> Polygon:
    """A 200 m rail corridor, 3 m wide: thin, not a sliver."""
    return Polygon([(0, 0), (200, 0), (200, 3), (0, 3)])


def touching_at_a_point() -> MultiPolygon:
    """Two parts meeting at one vertex — valid, and a classic false-positive source."""
    return MultiPolygon([
        Polygon([(0, 0), (5, 0), (5, 5), (0, 5)]),
        Polygon([(5, 5), (10, 5), (10, 10), (5, 10)]),
    ])


def closed_but_not_simple_ring() -> LineString:
    """Closed ring that touches itself — is_ring is False even though it closes."""
    return LineString([(0, 0), (10, 0), (5, 5), (10, 10), (0, 10), (5, 5), (0, 0)])

Verification: run the entire rule set over the look-alikes and assert zero findings. This suite catches over-broad rules, which are far more damaging than missing ones — a sliver rule that deletes rail corridors destroys data, while a missing rule merely fails to find something.

Step 4 — Expose a catalogue

# tests/fixtures/catalogue.py
from tests.fixtures import invalid, valid_lookalikes

DEFECTS = {
    "self_intersection": (invalid.bowtie, "blocker"),
    "hole_outside_shell": (invalid.hole_outside_shell, "blocker"),
    "nested_holes": (invalid.nested_holes, "blocker"),
    "duplicate_rings": (invalid.duplicate_rings, "warning"),
    "degenerate_ring": (invalid.degenerate_ring, "blocker"),
    "unclosed_ring": (invalid.unclosed_ring, "blocker"),
    "non_simple_line": (invalid.self_touching_line, "warning"),
    "near_duplicate_vertex": (invalid.near_duplicate_vertices, "informational"),
}

LOOKALIKES = {
    "two_disjoint_triangles": valid_lookalikes.figure_of_eight_but_valid,
    "donut": valid_lookalikes.legitimate_hole,
    "rail_corridor": valid_lookalikes.very_thin_but_legitimate,
    "point_touching_parts": valid_lookalikes.touching_at_a_point,
}

Verification: the catalogue keys should read as the vocabulary your reports use. If a report says “self-intersection” and the catalogue says “bowtie”, one of the two is wrong and the mismatch will surface in every conversation about a finding.

Step 5 — Use the catalogue as a coverage gate

# tests/test_rule_coverage.py
import pytest

from rules.engine import evaluate_all
from tests.fixtures.catalogue import DEFECTS, LOOKALIKES


@pytest.mark.parametrize("name", sorted(DEFECTS))
def test_every_defect_is_detected_by_something(name):
    generator, expected_severity = DEFECTS[name]
    findings = evaluate_all([generator()])
    assert findings, f"no rule reports {name} — the defect class is invisible"
    assert any(f["severity"] == expected_severity for f in findings), (
        f"{name} detected, but not at severity {expected_severity}: "
        f"{[f['severity'] for f in findings]}")


@pytest.mark.parametrize("name", sorted(LOOKALIKES))
def test_no_rule_fires_on_a_valid_lookalike(name):
    findings = evaluate_all([LOOKALIKES[name]()])
    assert findings == [], f"false positive on {name}: {findings}"

Verification: add a new entry to DEFECTS before writing its rule. The coverage test fails immediately with a message naming the invisible defect class, which is exactly the workflow you want — the fixture comes first, the rule follows.

Interpreting Results

Test outcome What it tells you
Fixture integrity fails The fixture drifted; fix the coordinates before touching any rule
Coverage test fails for a new defect The rule set has a genuine hole — that is the point of the gate
Look-alike test fails A rule is over-broad; it will destroy legitimate data in production
Severity mismatch The rule fires but routes wrongly — a blocker landing in a batch queue
Everything passes but production disagrees The defect class in production is not in the catalogue; add it

The last row is the ongoing work. Every time production surfaces a defect the suite did not anticipate, the fix is two commits: add the fixture, then add or amend the rule. Over a year that turns the catalogue into an accurate description of what your data actually does wrong, which is far more valuable than a generic list of OGC violations.

The workflow when production shows a defect the catalogue lacksChain of four steps: a production finding reveals an unknown defect class, a minimal fixture is written and pinned to its reason string, the coverage gate fails because no rule detects it, and the rule is written to close the gap.Productionfindingunknown classMinimalfixturepinned to reasonCoveragegate failsno rule detects itRule writtengate passes
Fixture first, rule second — the failing coverage gate is the specification for the rule.

Gotchas & Edge Cases

Shapely repairs some defects on construction. Rings are closed automatically, and some constructors reorder coordinates. Any defect that lives in the serialisation — an unclosed ring, a malformed coordinate array, a mixed-dimension geometry — must be built as text or JSON and parsed, not constructed in memory.

Fixtures that cannot be built the obvious wayGrid of four defects that resist in-memory construction, with the reason and the workaround: unclosed rings, mixed-dimension geometry, near-duplicate vertices at tiny epsilons, and multi-defect geometries.WhyWorkaroundUnclosed ringShapely closes rings on constructionbuild as WKT and parseMixed dimensionsconstructors normaliseauthor the serialised formTiny epsilon verticesnot representable at large coordinatesscale epsilon to magnitudeMulti-defect shapeGEOS reports only the firstkeep one defect per fixtureEach of these is worth knowing in itself: a defect that cannot exist in memory can only ever be encountered at the parsing boundary.
Four fixtures that need care — and each one teaches something about where the defect really lives.

GEOS reason strings are version-dependent. The wording has changed between releases, and matching an exact string makes the suite brittle across environments. Match a distinctive fragment, and pin the GEOS version in the test environment so a change is a deliberate upgrade rather than a surprise.

buffer(0) is not a neutral operation in a fixture. It is a repair, and using it to normalise a fixture can silently turn an invalid geometry valid before the rule ever sees it. Keep repairs out of fixture code entirely.

Very small epsilons stop being representable. A near-duplicate vertex at 1e-15 of a coordinate near 400,000 does not exist in double precision — the two vertices are literally the same point. Scale the epsilon to the coordinate magnitude, or the fixture tests floating-point arithmetic rather than your rule.

Multi-defect fixtures are hard to reason about. A polygon that is both self-intersecting and has a hole outside its shell will report only the first defect GEOS encounters. Keep one defect per fixture and compose them only in a deliberate “multiple defects” case.

When to Escalate

  • A defect class you cannot construct synthetically usually means it is not a geometry defect but a data or process defect — a wrong CRS, a missing attribute, an ordering assumption. Those belong to different rule families and different fixtures.
  • A rule that cannot be made to pass the look-alike suite is a rule with an unresolved definition. Take it back to the data steward and pin down the boundary case explicitly, as described in Defining Spatial Data Quality Policies.
  • Reason strings differing between the development environment and production means the two run different GEOS builds. That difference will also change validation results, so resolve it before trusting either.

Frequently Asked Questions

Why not just use real defective data as fixtures?

Real data is large, often confidential, and rarely contains the exact defect a new rule targets. It is also unstable: the file that demonstrated a defect gets fixed upstream and the test silently stops testing anything. Synthetic fixtures are minimal, reviewable in a diff, and permanent. Keep one or two real samples for integration confidence and build everything else.

How do I make sure a fixture still demonstrates the defect I think it does?

Assert the reason string, not just is_valid being false. A fixture intended to be a bowtie that has drifted into an unclosed ring will still fail validity, so the test still passes while no longer testing the case. Asserting that is_valid_reason contains "Self-intersection" pins the fixture to its purpose.

Can Shapely even construct an unclosed ring?

Not directly — Shapely closes polygon rings for you, which is why an unclosed-ring fixture has to be built at the serialised level, in WKT or GeoJSON coordinates, and read back. That is worth knowing in itself: an unclosed ring is a file-format defect rather than an in-memory one, and it will only ever be encountered at the parsing boundary.

Should generators randomise their output?

Not in the catalogue. Deterministic fixtures make failures reproducible and diffs meaningful. Randomisation belongs in the property-based tier, where the generator is deliberately searching for counterexamples and the framework records any it finds. Mixing the two gives a unit suite that fails intermittently and nobody trusts.


Related

Back to Testing Spatial Validation Code