Detecting Flaky Spatial Tests with Rerun Statistics
A flaky test passes and fails against unchanged code and unchanged data. This guide sits beneath spatial test observability and metrics and treats flakiness as a measurable property rather than an annoyance to be retried away: recording rerun outcomes as a metric, separating the causes that are specific to spatial work from ordinary timing instability, and running a quarantine policy that keeps the suite’s verdict meaningful while the cause is found.
Spatial suites are unusually prone to a specific kind of flakiness, and it is not the timing kind. The dominant causes are result ordering and engine version, and neither is fixed by a retry — a retry simply reruns until the ordering happens to be the one the assertion expected, which converts a deterministic bug into an intermittent one.
Root cause: retrying hides the signal it was meant to manage
An automatic retry is attractive because it removes an immediate obstruction. It also destroys the information that would have identified the cause, and it does so silently: the second run’s success replaces the first run’s failure, and nothing records that a failure happened at all.
The consequence compounds. A suite with retries enabled reports a pass rate that is not the pass rate of any individual run, so the trend is invisible; a test that fails one run in three looks identical to one that has never failed. By the time the instability is bad enough to survive the retries, it has usually been present for months.
Cause reference
| Cause | Signature | Retry helps? | Fix |
|---|---|---|---|
| Result ordering | Fails on set comparison; passes when sorted | Only by accident | Compare sets, or sort by a stable key |
| Engine version | Fails on one runner image, passes on another | No | Pin the engine; assert the version |
| Shared state under parallelism | Fails only with -n > 1; moves between workers |
Masks it | Per-worker schema, per-worker temp path |
| Missing grid file | Fails on a subset of runners consistently | No | Guard at start-up |
| Timing or resource | Fails under load; passes when the runner is idle | Yes, genuinely | Raise the timeout, or reduce the work |
| Unseeded randomness | Fails unpredictably, never reproduces | Masks it | Seed the generator; record the seed |
Only one row in that table is a case where a retry is a legitimate remedy rather than a concealment, and it is the least common in spatial suites.
Step-by-step implementation
The instrumentation targets pytest 7+ with a rerun plugin, and records rather than hides.
Step 1 — Record every attempt, not the final outcome
The report hook fires per attempt, which is exactly the granularity needed.
# conftest.py
import json, os
from collections import defaultdict
ATTEMPTS = defaultdict(list)
def pytest_runtest_logreport(report):
if report.when == "call":
ATTEMPTS[report.nodeid].append("pass" if report.passed else "fail")
def pytest_sessionfinish(session, exitstatus):
unstable = {nid: outcomes for nid, outcomes in ATTEMPTS.items()
if len(set(outcomes)) > 1}
Path(os.environ.get("FLAKE_REPORT", "flakes.json")).write_text(
json.dumps({"unstable": unstable, "total": len(ATTEMPTS)}, indent=2)
)
A node whose attempt list contains both outcomes is unstable by definition — no heuristics, no thresholds.
Step 2 — Attach the environment to every record
Flakiness that correlates with a runner image or an engine version is not flakiness at all, and the correlation is only visible if the fingerprint is recorded alongside.
import shapely, pyproj
def environment_fingerprint() -> dict:
return {
"geos": shapely.geos_version_string,
"proj": pyproj.proj_version_str,
"image": os.environ.get("IMAGE_DIGEST", "unknown"),
"workers": os.environ.get("PYTEST_XDIST_WORKER_COUNT", "1"),
}
Step 3 — Reproduce before quarantining
The three spatial causes are all deterministic given the right conditions, so a reproduction attempt is short and usually conclusive.
# Ordering: does it pass when the result is sorted?
pytest -q tests/test_join.py::test_cardinality -p no:randomly
# Parallelism: does it only fail with workers?
pytest -q tests/test_join.py::test_cardinality # serial
pytest -q tests/test_join.py::test_cardinality -n 4 # parallel
# Engine: does it pass on the pinned image and fail on the candidate?
docker run --rm -v "$PWD:/w" -w /w gis-test:pinned pytest -q tests/test_join.py
Step 4 — Quarantine with an expiry, not indefinitely
A quarantined test that never leaves quarantine is a deleted test with extra steps. Attach an owner and a date.
import pytest
@pytest.mark.quarantine(owner="mapping-platform", until="2026-09-30",
reason="ordering under xdist; see INC-4812")
def test_join_cardinality(parcels, zones):
...
A collection hook that fails the run when a quarantine marker’s date has passed is what makes the expiry real rather than decorative.
Verify the fix
Run the suite repeatedly against unchanged input and confirm the report is empty:
for i in $(seq 1 10); do pytest -q tests/ -p no:randomly; done; jq '.unstable | keys' flakes.json
Ten identical runs producing an empty unstable list is weak evidence; ten runs with -n 4 producing an empty list is much stronger, because parallelism is where the spatial causes surface. Running both is the cheap version of a stability check.
A quarantine policy that keeps the suite honest
Quarantine exists so that one unstable test does not block everybody while its cause is found. It becomes corrosive when it is used as a disposal route, and three rules prevent that.
Quarantine excludes from the gate, not from the run. The test still executes and still records attempts; it simply does not fail the build. That preserves the very data needed to diagnose it, which a skip destroys.
Every quarantine has an owner and a date. Without both it is permanent. A collection-time check that fails the run when a date has passed is what converts the date from a comment into a commitment.
The quarantine list is reviewed, and its length is a metric. A list that grows quarter over quarter is a suite losing coverage, and the number is the honest summary of that. Watching it alongside the pass rate keeps the trade-off visible.
The complementary practice is worth stating: a test that has been stable for a long period and then becomes unstable is far more likely to indicate a real change than one that has always been marginal. Recording when instability began — which the attempt series gives free — separates the two, and the first case usually points at the environment or an upstream input rather than at the test.
Measuring the suite, not only the tests
Individual test stability is the obvious metric and not the most useful one. Two suite-level numbers say more about whether the gate can be trusted, and both fall out of the same attempt records.
First-attempt suite pass rate. The fraction of runs in which every test passed on its first attempt. This is the number that describes what an engineer actually experiences, and it degrades long before any individual test looks bad — twenty tests each failing one run in fifty produces a suite that fails roughly one run in three.
Time lost to instability. Attempts multiplied by their duration, summed over a period. Expressed in hours per month it converts a vague irritation into a figure that can be weighed against the effort of fixing the cause, which is usually the argument that gets the work scheduled.
Both are worth putting next to the pass rate on whatever page the team already looks at. The second in particular changes conversations: “the suite is a bit flaky” is easy to defer, and “instability cost eleven engineer-hours last month” is not.
Failure modes and edge cases
- Retries enabled by default. Every spatial cause is concealed and the pass rate becomes meaningless. If retries are used at all, record the attempts and alert on the first-attempt rate.
- Randomised test order without a recorded seed. Order-dependent failures become unreproducible. Randomise deliberately, print the seed, and make it settable.
- Quarantine implemented as skip. A skipped test records nothing, so the cause is never diagnosed and the test is silently dead.
- Judging stability from a serial run. Most spatial flakiness only appears under parallelism. A stability check that never runs with workers proves very little.
- Attributing engine flakiness to timing. A failure that correlates with an image digest is deterministic; treating it as timing produces a retry policy that fails one run in three forever.
- No expiry on the quarantine. The list becomes the place tests go to be forgotten, and the suite’s coverage declines invisibly.
Conclusion
Flakiness in a spatial suite is usually deterministic behaviour observed through a non-deterministic lens — an ordering, an engine, a shared resource — and a retry converts it from a diagnosable bug into a permanent tax. Recording every attempt with its environment fingerprint, triaging through the three spatial causes before reaching for timing, and quarantining with an owner and an expiry keeps the suite’s verdict meaningful, which is what spatial test observability and metrics is for.
Related
- Spatial Test Observability and Metrics — the parent layer and the absence alert that catches a quarantined rule.
- Parallelizing Spatial Tests with pytest-xdist — the shared-resource causes in detail.
- R-tree vs GiST Index Performance in Test Environments — why result ordering differs between index types.
- Containerized GIS Test Runtimes — pinning the engine that the image-correlated cause points at.