pytest fixtures vs factory functions for parcel test data
A test fails: test_overlay_preserves_area on the county_parcels fixture. Opening the test tells you nothing about the geometry, because the fixture is defined in a conftest.py two directories up, built from a GeoPackage, and mutated by three other fixtures that layer zoning and overlays onto it. Working out what shape actually failed takes twenty minutes. The same test written with a factory call — parcel(width=20, depth=200) — would have shown the input on the failing line. This guide compares the two for spatial test data, where the input is geometry and geometry is the thing you need to see. It is a practice note under testing spatial data pipelines.
Diagnosis: which problem do you have? jump to heading
The two approaches fail in opposite directions, and choosing badly is easy to spot after the fact.
Symptoms of over-using fixtures: tests whose bodies do not show their input; fixtures that depend on fixtures three deep; a test that passes alone and fails in a suite because something mutated a shared GeoDataFrame; assertions written against magic numbers (assert result.area == 1200) whose provenance is a fixture nobody reads.
Symptoms of over-using factories: the same twelve-line parcel construction copied into thirty tests; an expensive resource — a PostGIS container, a PROJ data check — rebuilt per test; a change to the parcel schema requiring thirty edits.
The dividing line is not style. It is whether the value is the subject of the test or scenery:
# The geometry IS the subject: it belongs on the failing line.
def test_negative_buffer_splits_a_flag_lot():
lot = parcel(shape="flag", stem_width=6, stem_depth=60, head=(34, 30))
pieces = setback_envelope(lot, front=25, side=8, rear=20)
assert len(pieces) == 2
# The database IS scenery: it belongs in a fixture, built once.
def test_upsert_is_idempotent(postgis): # session-scoped fixture
upsert(postgis, PARCEL_ROWS)
upsert(postgis, PARCEL_ROWS)
assert postgis.count("parcel_current") == len(PARCEL_ROWS)
Step-by-step implementation jump to heading
1. Write factories for geometry, with keyword arguments that read as prose jump to heading
from shapely.geometry import Polygon
FT = 0.3048
def parcel(*, width=20.0, depth=40.0, origin=(500_000.0, 4_400_000.0),
shape="rect", stem_width=6.0, stem_depth=60.0, head=(34.0, 30.0),
holes=(), clockwise=False, duplicate_vertex=False) -> Polygon:
"""Build one parcel. Every argument has a default, so a test names ONLY the
property it cares about — which is what makes the failing line informative."""
x, y = origin
if shape == "rect":
ring = [(x, y), (x + width, y), (x + width, y + depth), (x, y + depth)]
elif shape == "flag":
hw, hd = head
ring = [(x, y), (x + stem_width, y), (x + stem_width, y + stem_depth),
(x + hw, y + stem_depth), (x + hw, y + stem_depth + hd),
(x, y + stem_depth + hd)]
elif shape == "corner":
ring = [(x, y), (x + width, y), (x + width, y + depth / 2),
(x + width / 2, y + depth / 2), (x + width / 2, y + depth), (x, y + depth)]
else:
raise ValueError(f"unknown shape {shape!r}")
if duplicate_vertex:
ring.insert(1, ring[0])
if clockwise:
ring = ring[::-1]
return Polygon(ring, holes)
def parcels(n=3, *, gap=5.0, **kw) -> list[Polygon]:
"""A row of adjacent parcels. `gap=0` makes them share edges, which is what
overlay and noding tests need."""
out, x = [], kw.pop("origin", (500_000.0, 4_400_000.0))[0]
y = 4_400_000.0
width = kw.get("width", 20.0)
for _ in range(n):
out.append(parcel(origin=(x, y), **kw))
x += width + gap
return out
2. Reserve fixtures for expensive or genuinely shared setup jump to heading
@pytest.fixture(scope="session")
def postgis():
"""Expensive: one container for the whole session."""
with PostgisContainer() as db:
db.apply_migrations()
yield db
@pytest.fixture
def clean_db(postgis):
"""Cheap per-test isolation on top of the expensive resource. A transaction
rolled back after each test is faster and safer than truncating tables."""
with postgis.transaction() as tx:
yield tx
tx.rollback()
@pytest.fixture(scope="session")
def crs_ready():
"""A precondition, not data: assert the PROJ grids exist once per session."""
assert_required_grids_present()
3. Never share a mutable GeoDataFrame across tests jump to heading
# WRONG: geopandas operations mutate in place often enough that this leaks state
# between tests, and the failure appears in whichever test happens to run second.
@pytest.fixture(scope="module")
def county_gdf():
return gpd.read_file(FIXTURES / "county.gpkg")
# RIGHT: read once (expensive), hand out a copy (cheap).
@pytest.fixture(scope="module")
def _county_source():
return gpd.read_file(FIXTURES / "county.gpkg")
@pytest.fixture
def county_gdf(_county_source):
return _county_source.copy(deep=True)
Order-dependent spatial failures are almost always this. The .copy(deep=True) costs microseconds on a small fixture and removes an entire class of bug.
4. Compare them on the axes that matter here jump to heading
| Axis | Shared fixture | Factory function |
|---|---|---|
| Input visible on the failing line | no — defined elsewhere | yes |
| Cost of expensive setup | paid once per scope | paid per call |
| Mutation safety | risky unless copied | safe by construction |
| Schema change blast radius | one definition | every call site |
| Parametrising over shapes | awkward — needs indirect |
natural |
| Reading a failure six months later | requires archaeology | reads as prose |
| Suits | databases, containers, PROJ checks, HTTP recordings | geometry, codes, dates, thresholds |
5. Use both, deliberately jump to heading
@pytest.mark.parametrize("shape,expected_pieces", [
("rect", 1),
("flag", 2), # setbacks sever the stem
("corner", 1),
])
def test_setback_envelope_piece_count(clean_db, crs_ready, shape, expected_pieces):
"""Fixtures supply the expensive scenery; the factory supplies the subject, and
the parametrisation shows exactly which shape failed."""
lot = parcel(shape=shape)
pieces = setback_envelope(lot, front=25 * FT, side=8 * FT, rear=20 * FT)
assert len(as_list(pieces)) == expected_pieces
Verification & testing jump to heading
The suite’s own hygiene is testable, and two checks catch most of the drift.
def test_no_module_scoped_geodataframe_fixtures():
"""A module- or session-scoped fixture returning a GeoDataFrame is a state leak
waiting to happen. Enforced structurally rather than by review."""
import inspect, conftest
for name, fn in vars(conftest).items():
marker = getattr(fn, "_pytestfixturefunction", None)
if marker is None or marker.scope == "function":
continue
src = inspect.getsource(fn)
assert "GeoDataFrame" not in src or ".copy(" in src, (
f"fixture {name!r} is {marker.scope}-scoped and returns a GeoDataFrame "
f"without copying — tests will leak state into each other")
def test_running_the_suite_in_reverse_order_still_passes():
"""The cheapest detector for shared-state bugs: pytest -p no:randomly --reverse.
Wire it into CI as a second job rather than as an assertion here."""
Running the suite in a randomised or reversed order in CI is worth more than any single assertion: order-dependent spatial failures are invisible in a fixed order and obvious in two.
Failure recovery jump to heading
A test that passes alone and fails in the suite. Shared mutable state, almost always a GeoDataFrame from a broadly scoped fixture. Add .copy(deep=True) at the boundary and the symptom disappears; then add the structural test above so it does not come back.
A failure whose input nobody can identify. Rewrite that test with a factory call before debugging it. Making the input visible usually reveals the bug on its own, and it costs less than tracing the fixture chain.
Thirty call sites broken by a schema change. The factory’s signature is the seam. Add the new field with a default so existing calls keep working, and only update the tests that care about it — which is the same reason every factory argument here has a default.
Frequently asked questions jump to heading
Is it wrong to use fixtures for geometry?
Not wrong, but it hides the thing under test. When a spatial assertion fails, the first question is always “what shape was it?” — and a fixture answers that in another file while a factory call answers it on the failing line. Reserve fixtures for setup whose details are irrelevant to the assertion: databases, containers, recorded HTTP responses, environment preconditions.
Why is a session-scoped GeoDataFrame fixture dangerous?
Because several geopandas and shapely operations modify frames in place, so one test’s normalisation silently becomes the next test’s input. The failure then depends on execution order, appears in an innocent test, and survives every attempt to reproduce it in isolation. Read once for cost, hand out a deep copy for safety.
Do factories not duplicate a lot of code?
Only if they take no defaults. A factory where every argument defaults means a test names just the one property it is about — parcel(shape=“flag”) — which is less code than a fixture reference plus the comment explaining what the fixture contains. The duplication people fear comes from constructing coordinate lists inline, which is what the factory exists to remove.
Which should hold the expensive PROJ or database setup?
A session-scoped fixture, every time. Those are pure scenery: no assertion is about the container, and rebuilding one per test makes the suite too slow to run. Layer a function-scoped transaction fixture on top for isolation, so tests share the expensive resource without sharing its state.
Related jump to heading
- Parent topic: Testing Spatial Data Pipelines
- Section overview: Municipal Zoning Data Architecture & Compliance Frameworks
- Property-based testing for geometry normalization — generators are factories with a search attached
- Creating minimal parcel fixtures that reproduce topology bugs — where a shrunken fixture belongs
- Testing CRS handling without a live projection database — the session-scoped precondition pattern