How to Build a Test GeoPackage Fixture in pytest

Write the features out by hand, build the container once per session, assert the feature count inside the fixture itself, and copy the file per test for…

Write the features out by hand, build the container once per session, assert the feature count inside the fixture itself, and copy the file per test for anything that writes. That combination gives a fixture whose contents you can reason about, a suite that does not rebuild the same file forty times, and a guarantee that a fixture which silently produced nothing fails immediately rather than making every assertion pass trivially.

This page belongs to the Testing & CI for Spatial Pipelines guide.

Why This Matters

Two failure modes dominate spatial test suites, and both come from the fixture.

The first is the empty fixture. A writer’s with block raised, the file exists, the layer exists, and it holds no features — at which point “no invalid geometries”, “no CRS mismatches” and “every geometry decodes” are all true and all meaningless. A suite in this state is green and tests nothing.

The second is cost. Building a container through GDAL costs driver registration, a file create, and a transaction; doing it per test turns a suite that should run in seconds into one that runs in minutes, and slow suites get run less.

Prerequisites

  • Python 3.9+ with pytest 7+
  • fiona 1.9+ or pyogrio, and GDAL/OGR 3.4+ with the GPKG driver
  • pytest’s tmp_path and tmp_path_factory fixtures
  • A clear separation between tests that read and tests that write

Primary Method

python
# conftest.py — a deterministic, session-scoped fixture with a self-check
import shutil
from pathlib import Path

import pytest
import fiona
from fiona.crs import CRS

SCHEMA = {
    "geometry": "MultiPolygon",
    "properties": {"parcel_ref": "str:32", "area_m2": "float", "surveyed": "int"},
}

FEATURES = [
    {
        "geometry": {"type": "MultiPolygon",
                     "coordinates": [[[(0, 0), (0, 10), (10, 10), (10, 0), (0, 0)]]]},
        "properties": {"parcel_ref": "P-001", "area_m2": 100.0, "surveyed": 1},
    },
    {
        "geometry": {"type": "MultiPolygon",
                     "coordinates": [[[(20, 0), (20, 5), (25, 5), (25, 0), (20, 0)]]]},
        "properties": {"parcel_ref": "P-002", "area_m2": 25.0, "surveyed": 0},
    },
    {   # a polygon with a hole — the case a square fixture never exercises
        "geometry": {"type": "MultiPolygon", "coordinates": [[
            [(40, 0), (40, 20), (60, 20), (60, 0), (40, 0)],
            [(45, 5), (45, 15), (55, 15), (55, 5), (45, 5)],
        ]]},
        "properties": {"parcel_ref": "P-003", "area_m2": 300.0, "surveyed": 1},
    },
]


@pytest.fixture(scope="session")
def parcels_master(tmp_path_factory) -> Path:
    """Built once per session. Read-only — never hand this to a writing test."""
    path = tmp_path_factory.mktemp("fixtures") / "parcels.gpkg"

    with fiona.open(
        str(path), "w", driver="GPKG", layer="parcels",
        schema=SCHEMA, crs=CRS.from_epsg(27700),
    ) as dst:
        dst.writerecords(FEATURES)

    # The fixture asserts its own contents, so an empty build fails here
    with fiona.open(str(path), layer="parcels") as src:
        assert len(src) == len(FEATURES), (
            f"fixture wrote {len(src)} features, expected {len(FEATURES)}"
        )
    return path


@pytest.fixture
def parcels(parcels_master, tmp_path) -> Path:
    """A private copy per test. Safe to write to."""
    path = tmp_path / "parcels.gpkg"
    shutil.copy(parcels_master, path)
    return path

The two-fixture split is the whole design. The session-scoped one pays the build cost once; the function-scoped one is a file copy, which for a small container is sub-millisecond. Tests ask for parcels and never think about it.

Session-scoped build, function-scoped copyThe master container is built once per session through the driver, which is the expensive step, and its feature count is asserted immediately. Each test then receives a private copy made with a file copy, which is orders of magnitude cheaper. Tests that write mutate their own copy, so no test can affect another, and the build cost is paid once regardless of how many tests run.built once per sessionthrough the drivercount asserted heretest A — its own copy, may write freelytest B — its own copy, may write freelytest C — its own copy, may write freely
A file copy is cheap enough that per-test isolation costs nothing measurable, which removes the usual reason to share a mutable fixture.

Step-by-Step Walkthrough

1. Hand-write the features

Two or three features is usually right, and every one should exist for a reason. The third feature above carries a hole precisely because a fixture of squares never exercises interior rings — and interior rings are where a large share of geometry bugs live.

What to avoid is a generated fixture. A loop producing five hundred random polygons gives a slow build, a container nobody can reason about, and failures whose cause depends on a seed.

2. Make the fixture assert its own contents

python
with fiona.open(str(path), layer="parcels") as src:
    assert len(src) == len(FEATURES)
    assert src.crs.to_epsg() == 27700
    assert src.schema["geometry"] == "MultiPolygon"

Three lines, and they convert the empty-fixture failure from “every test passes and none of them mean anything” into “the fixture errored, here is why”. This is the single highest-value line in a spatial conftest.py.

3. Scope by whether the test writes

python
def test_reads_do_not_need_a_copy(parcels_master):
    """Read-only: use the session fixture directly, no copy."""
    with fiona.open(str(parcels_master), layer="parcels") as src:
        assert len(src) == 3


def test_writes_get_their_own(parcels):
    """Writing: use the per-test copy."""
    with fiona.open(str(parcels), "a", driver="GPKG", layer="parcels") as dst:
        dst.writerecords([FEATURES[0]])
    with fiona.open(str(parcels), layer="parcels") as src:
        assert len(src) == 4

A test that takes parcels_master and writes to it corrupts every later test in unpredictable order, and pytest gives no help diagnosing that. Naming the read-only fixture something that says so — _master, _readonly — makes the mistake visible in review.

4. Add a second layer only when a test needs one

Multi-layer behaviour — layer enumeration, cross-layer joins, append semantics — needs a second layer, and everything else does not. Build it as a separate fixture that extends the first, so tests that do not need it do not pay for it.

python
@pytest.fixture(scope="session")
def two_layer_master(tmp_path_factory, parcels_master) -> Path:
    path = tmp_path_factory.mktemp("fixtures2") / "two_layer.gpkg"
    shutil.copy(parcels_master, path)

    with fiona.open(
        str(path), "a", driver="GPKG", layer="observations",
        schema={"geometry": "Point", "properties": {"obs_id": "int"}},
        crs=CRS.from_epsg(27700),
    ) as dst:
        dst.writerecords([
            {"geometry": {"type": "Point", "coordinates": (5, 5)},
             "properties": {"obs_id": 1}},
        ])
    return path
What each fixture feature is there to exerciseThe first feature is a plain square, the ordinary case. The second is a smaller rectangle with a different attribute value, so filtering and aggregation have something to distinguish. The third carries an interior ring, which is the case a fixture of squares never exercises and where a disproportionate share of geometry bugs appear. Nothing in the fixture is there without a reason.Three features, three reasonsP-001 — plain squarethe ordinary casearea known exactlyassertions can be exactP-002 — different valuessmaller, surveyed = 0filters have something to doaggregations are non-trivialP-003 — has a holean interior ringring order, area subtractionwhere the bugs area fixture of five hundred generated polygons exercises the first column three hundred timesand may never produce the third at all
Coverage in a spatial fixture comes from which shapes are present, not from how many.

5. Keep a large fixture separate and marked

Index behaviour and streaming tests need volume, and volume does not belong in the fast suite. Build it once, keep it out of the default run, and generate it from a committed script so it is reproducible.

python
@pytest.fixture(scope="session")
def large_parcels(tmp_path_factory) -> Path:
    """100k features. Marked slow; excluded from the default run."""
    path = tmp_path_factory.mktemp("big") / "large.gpkg"
    build_large_container(path, n=100_000)   # a committed generator script
    return path


@pytest.mark.slow
def test_index_is_used_on_a_large_layer(large_parcels):
    ...

Verification

The fixture needs its own test — one that confirms the self-assertion actually fires.

python
def test_fixture_selfcheck_catches_an_empty_build(tmp_path):
    """If the fixture ever writes nothing, the count assertion must fail."""
    path = tmp_path / "empty.gpkg"
    with fiona.open(
        str(path), "w", driver="GPKG", layer="parcels",
        schema=SCHEMA, crs=CRS.from_epsg(27700),
    ) as dst:
        pass                                  # deliberately write no features

    with fiona.open(str(path), layer="parcels") as src:
        assert len(src) == 0                  # the state the guard exists to catch

And confirm the copy is genuinely isolated, since that is the property every writing test depends on:

python
def test_copies_are_independent(parcels_master, tmp_path):
    a, b = tmp_path / "a.gpkg", tmp_path / "b.gpkg"
    shutil.copy(parcels_master, a)
    shutil.copy(parcels_master, b)

    with fiona.open(str(a), "a", driver="GPKG", layer="parcels") as dst:
        dst.writerecords([FEATURES[0]])

    with fiona.open(str(b), layer="parcels") as src:
        assert len(src) == 3, "writing to one copy affected another"
Which kind of fixture a test should ask forA test that only reads should take the session-scoped master directly, paying nothing. A test that writes should take the per-test copy, which costs a file copy. A test about SQL, registry structure or geometry functions should use an in-memory database, which is faster still. A test about index behaviour or streaming should take the large committed fixture and be marked slow.reads onlythe session master, directlycosts nothing at allwritesa per-test copycosts one file copySQL, registry, functionsan in-memory databasefaster, behaves identicallyindex and streamingthe large committed fixturemarked slow, run separately
Most tests belong in the top-left or bottom-left box, and most suites put everything in the top-right one.

Alternative Approaches or Edge Cases

In-memory containers. For anything testing SQL, registry structure or geometry functions, sqlite3.connect(":memory:") with the extension loaded is dramatically faster than a file and behaves identically. Reserve real files for what genuinely needs them: journal modes, sidecar files, locking, atomic publication and permission bits.

Committed binary fixtures. Appropriate for large containers and for reproducing a specific bug report, and they need a committed regeneration script or they rot the first time the schema changes. A committed fixture with no way to rebuild it becomes a file nobody dares touch.

Parameterising over both container formats. Where the code claims to support SpatiaLite as well as GeoPackage, parameterise the fixture over both. The registry names, index naming and geometry envelope all differ, and a suite that only builds one will not notice code that hard-codes those.

Troubleshooting

DriverError: unable to open on the fixture path

Cause: tmp_path_factory.mktemp returns a directory; the fixture must append a filename. Fix: Check the path has a .gpkg suffix and that the parent directory exists — pytest creates the directory, not the file.

The fixture is rebuilt for every test despite being session-scoped

Cause: It depends on a function-scoped fixture, which forces its scope down. tmp_path is function-scoped; tmp_path_factory is session-scoped. Fix: Use tmp_path_factory in any session-scoped fixture.

Tests pass individually and fail when run together

Cause: A test is writing to the shared master fixture. Fix: Audit which tests take parcels_master rather than parcels; pytest’s -p no:randomly will make the ordering deterministic while you find it.

Frequently Asked Questions

Should the fixture include an invalid geometry?

In a separate fixture, yes. Repair and validation code needs something invalid to act on, and a bow-tie polygon built in three lines is the cleanest available. Keep it out of the main fixture, though — a container that contains an invalid geometry by design makes every other test’s “no invalid geometries” assertion wrong.

How do I test code that takes a connection rather than a path?

Add a third fixture that opens the per-test copy and yields the connection, closing it on teardown. Keeping the path fixture underneath means both styles of interface are testable from the same fixture chain, and a test that needs both — write through the connection, then verify by reopening the path — has them.

Is it worth asserting the container conforms in the fixture?

Once, in a dedicated test rather than in the fixture itself. Conformance of the fixture is a property of the driver, not of your code, so checking it on every session start pays for something that only changes when the driver does. A single test asserting the fixture is conforming documents the assumption without paying for it repeatedly.