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 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
pytest7+ fiona1.9+ orpyogrio, and GDAL/OGR 3.4+ with theGPKGdriverpytest’stmp_pathandtmp_path_factoryfixtures- A clear separation between tests that read and tests that write
Primary Method
# 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.
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
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
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.
@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
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.
@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.
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:
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"
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.
Related
- Testing & CI for Spatial Pipelines — parent guide: the four bands of spatial test
- Asserting OGC Compliance in Continuous Integration — the structural assertions this fixture feeds
- Running Spatial Tests Without mod_spatialite — keeping the suite useful on a restricted runner
- How to Append Layers to an Existing GeoPackage with Fiona — the append semantics the two-layer fixture relies on