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.

Fixtures against factories, on the axes that matter for geometry A seven-row by two-column comparison of shared pytest fixtures against factory functions for spatial test data. Rows are whether the input is visible on the failing line, the cost of expensive setup, mutation safety, the blast radius of a schema change, how naturally shapes can be parametrised, how readable a failure is six months later, and what each approach suits. Fixtures win on setup cost and schema blast radius; factories win on input visibility, mutation safety and parametrisation. Fixtures against factories, on the axes that matter for geometry 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 Blast radius of a schema change one definition every call site Parametrising over shapes awkward, needs indirect natural Reading a failure six months later archaeology reads as prose Best suited to containers, PROJ, HTTP geometry, codes, dates advantage workable disadvantage
Neither wins outright, and the split is clean: fixtures suit scenery whose details no assertion depends on — containers, PROJ checks, recorded responses — while factories suit the subject, which for a spatial test is almost always the geometry.

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.

Suite outcomes in three execution orders Stacked bar chart of test outcomes for the same 240-test suite run in three orders, under two fixture styles. With a module-scoped GeoDataFrame shared without copying, the forward order passes all 240, the reverse order fails 11, and a randomised order fails 7 — the same tests, different results. With a deep copy per test, all three orders pass 240. The order dependence is entirely an artefact of shared mutable state. Suite outcomes in three execution orders 0 100 200 300 222 shared, no copy 240 deep copy per test Tests (240 in the suite) failed in randomised order failed in reverse order passed in every order Same tests, same code; only the fixture sharing differs.
The left group is the same suite giving three different answers. Order-dependent spatial failures are invisible in a fixed order, which is why running the suite reversed or randomised in CI is worth more than any single assertion — and why the deep copy costs microseconds and removes the class entirely.

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.

Three suite smells and the seam that fixes each A three-row by three-column matrix of test-suite problems. Rows are a test that passes alone and fails in the suite, a failure whose input nobody can identify, and thirty call sites broken by a schema change. Columns are the cause, the fix, and what prevents a recurrence. The first is shared mutable state fixed by a deep copy and a structural test. The second is fixed by rewriting the test with a factory call before debugging it. The third is fixed at the factory signature by adding the field with a default. Three suite smells and the seam that fixes each cause fix prevents recurrence Passes alone, fails in the suite shared mutable GeoDataFrame deep copy at the boundary structural test on scope Failure input unidentifiable input hidden in a fixture rewrite with a factory call factories for the subject 30 call sites broken by a schema change factory has no defaults add the field with a default every argument defaults clean process the cause
Each row has a single seam. The second is worth emphasising: rewriting the test with a factory call before debugging it usually reveals the bug on its own, and costs less than tracing a fixture chain through three files.

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.