How to Test a GIS Script That Reads and Writes Files
On this page
Problem statement
Most GIS code is file-handling code, and most of it is written to be untestable:
def clean_register():
gdf = gpd.read_file("/data/raw/food_register.gpkg")
gdf = gdf[gdf.geometry.is_valid]
gdf.to_crs(27700).to_file("/data/clean/food_register.gpkg", driver="GPKG")
There is no way to test this without the real path existing. Run it in CI and it fails; run it on a colleague's laptop and it fails; run it twice and the second run silently overwrites the first result.
The naive test is worse than none:
def test_clean_register():
clean_register()
result = gpd.read_file("/data/clean/food_register.gpkg")
assert len(result) > 0
It depends on a machine, mutates shared state, cannot run in parallel with itself, and leaves the output behind for the next test to find. And len(result) > 0 would pass on the bug this page keeps meeting: the Food Standards Agency's register of Rutland's 463 food businesses publishes 124 of them β mostly home caterers β with no coordinates, points_from_xy turns those into POINT (NaN NaN), which is neither null nor empty; reprojected it becomes POINT (Infinity Infinity), and written to a GeoPackage unprojected it comes back as POINT EMPTY.
The fix has two halves: structure the code so the I/O is at the edges, and use tmp_path for the I/O that remains.
Quick answer
Separate the transformation from the file handling, then test each on its own. The two businesses below are real rows from the register β one located, one published without a location:
%%writefile test_quick.py
import geopandas as gpd
def clean(gdf): # testable without any file
located = gdf.geometry.is_valid & ~gdf.geometry.is_empty
return gdf[located].to_crs(27700)
def clean_file(src, dst, driver="GPKG"): # a thin shell around it
gdf = gpd.read_file(src)
out = clean(gdf)
out.to_file(dst, driver=driver)
return len(gdf), len(out)
def two_businesses():
return gpd.GeoDataFrame(
{"BusinessName": ["10 Dining Street", "Ali Green Ltd"]},
geometry=gpd.points_from_xy([-0.7316409, float("nan")], [52.6695241, float("nan")]),
crs=4326)
def test_clean_is_pure():
assert clean(two_businesses())["BusinessName"].tolist() == ["10 Dining Street"]
def test_clean_file_round_trip(tmp_path):
src, dst = tmp_path / "in.gpkg", tmp_path / "out.gpkg"
two_businesses().to_file(src, driver="GPKG")
assert clean_file(src, dst) == (2, 1)
back = gpd.read_file(dst)
assert back.crs.to_epsg() == 27700
assert back.geometry.iloc[0].equals(clean(two_businesses()).geometry.iloc[0])
!{sys.executable} -m pytest -q -p no:cacheprovider --color=no --disable-warnings test_quick.py
.. [100%]
2 passed in 0.31s
The first test needs no file at all. The second is the one that knows about formats, and it earned its place on the first run: the first version of clean() kept only valid geometries, the pure test passed, and the round trip failed with assert (2, 2) == (2, 1). In memory the unlocated business is POINT (NaN NaN), which is invalid; a GeoPackage encodes an empty point as NaN coordinates, so it came back as POINT EMPTY β which is valid. clean() now drops empty geometries as well.
| Pattern | Effect |
|---|---|
| pure function on a GeoDataFrame | most tests need no file at all |
| paths as arguments, never constants | the test decides where things go |
tmp_path fixture |
a fresh directory per test, cleaned up |
| write to a temp name, then rename | a crash never leaves a half-file |
Paths as arguments, tmp_path, round trips, atomic writes
1. Take paths as arguments
A hard-coded path makes a function untestable and unreusable in one stroke. The module below takes every path as an argument, normalises it once, creates the output directory, and puts the path into any read error β GDAL does not always do that itself, as step 5 shows:
%%writefile fsa.py
"""The FSA food hygiene register for one local authority: load, clean, write."""
import json
from pathlib import Path
import geopandas as gpd
import pandas as pd
import requests
COLUMNS = ["FHRSID", "BusinessName", "BusinessType", "RatingValue", "RatingDate"]
def load_register(path):
"""The FSA open-data JSON as a GeoDataFrame in EPSG:4326, one row per business."""
records = json.loads(Path(path).read_text())["FHRSEstablishment"]["EstablishmentCollection"]
df = pd.json_normalize(records)
x = pd.to_numeric(df["Geocode.Longitude"], errors="coerce")
y = pd.to_numeric(df["Geocode.Latitude"], errors="coerce")
return gpd.GeoDataFrame(df[COLUMNS], geometry=gpd.points_from_xy(x, y), crs=4326)
def clean(gdf):
"""Located businesses only, a numeric rating, real dates, British National Grid."""
located = gdf[gdf.geometry.is_valid & ~gdf.geometry.is_empty] # None, NaN and EMPTY all go
return (located.assign(rating=pd.to_numeric(located["RatingValue"], errors="coerce").astype("Int64"),
RatingDate=pd.to_datetime(located["RatingDate"], errors="coerce"))
.drop(columns="RatingValue")
.to_crs(27700))
def write_atomic(gdf, dst, driver="GPKG"):
"""Write to a temporary name in the same directory, then rename."""
dst = Path(dst)
dst.parent.mkdir(parents=True, exist_ok=True)
tmp = dst.with_name(f"{dst.stem}.partial{dst.suffix}")
try:
gdf.to_file(tmp, driver=driver, layer=dst.stem)
tmp.replace(dst) # atomic on the same filesystem
finally:
if tmp.exists():
tmp.unlink()
return dst
def clean_file(src, dst, *, driver="GPKG"):
src, dst = Path(src), Path(dst)
if not src.exists():
raise FileNotFoundError(f"input not found: {src}")
try:
gdf = gpd.read_file(src)
except Exception as exc:
raise ValueError(f"cannot read {src}: {exc}") from exc
out = clean(gdf)
write_atomic(out, dst, driver=driver)
return len(gdf), len(out)
def download(url, dest):
Path(dest).write_bytes(requests.get(url, timeout=120).content)
def fetch_and_clean(url, workdir):
"""Download once into workdir, then clean; a second call reuses the download."""
workdir = Path(workdir)
raw = workdir / "register.json"
if not raw.exists():
download(url, raw)
source = workdir / "register.gpkg"
load_register(raw).to_file(source, driver="GPKG")
return clean_file(source, workdir / "clean.gpkg")
dst.parent.mkdir(parents=True, exist_ok=True) is worth having: without it, the function fails when the output directory does not exist yet, which in a test is a distracting failure and in production is a 3 a.m. one.
2. Use tmp_path, not a fixed temp directory
tmp_path is a pathlib.Path to a fresh, per-test directory. pytest creates it, keeps the directories of the last three runs for inspection, and deletes older ones. Nothing to clean up, and two tests cannot collide.
| Fixture | Scope | Use |
|---|---|---|
tmp_path |
one test | the default |
tmp_path_factory |
session | a shared read-only input built once |
tmpdir |
one test | the legacy py.path version β prefer tmp_path |
The fixtures for this page's tests read the real register once per session and hand each test its own files:
%%writefile tests/conftest.py
from pathlib import Path
import pandas as pd
import pytest
from fsa import load_register
REGISTER = Path("data/food.json").resolve()
def pytest_addoption(parser):
parser.addoption("--regenerate-golden", action="store_true",
help="overwrite golden files with current output")
@pytest.fixture(scope="session")
def register():
"""All 462 Rutland businesses, read once. Tests must not modify it."""
return load_register(REGISTER)
@pytest.fixture
def sample(register):
"""Ten located businesses and two published without a location."""
located = register.geometry.is_valid
return pd.concat([register[located].head(10), register[~located].head(2)])
@pytest.fixture
def raw_file(tmp_path, sample):
path = tmp_path / "raw.gpkg"
sample.to_file(path, driver="GPKG")
return path
@pytest.fixture
def input_folder(tmp_path, register):
"""One GeoPackage per business type for the five commonest types, plus a text file."""
folder = tmp_path / "inputs"
folder.mkdir()
for kind in register["BusinessType"].value_counts().index[:5]:
name = kind.split("/")[0].split(" - ")[0].lower().replace(" ", "_")
register[register["BusinessType"] == kind].to_file(folder / f"{name}.gpkg", driver="GPKG")
(folder / "readme.txt").write_text("FSA food hygiene register, Rutland, split by business type")
return folder
@pytest.fixture
def corrupt_file(tmp_path):
"""A valid SQLite header and nothing else: what a truncated download looks like."""
path = tmp_path / "corrupt.gpkg"
path.write_bytes(b"SQLite format 3\x00" + b"\x00" * 200)
return path
A session-scoped fixture is worth it when building the input is slow. The discipline it demands is that no test mutates it β one that does corrupts every later test in a way that depends on execution order.
3. Test the round trip, not just that a file appeared
dst.exists() proves a file was created, not that it is right. Formats change things, and the only way to find out what is to write and read back:
%%writefile tests/test_round_trip.py
import geopandas as gpd
import pandas as pd
import pytest
from fsa import clean
ATTRS = ["FHRSID", "BusinessName", "BusinessType", "rating", "RatingDate"]
def comparable(gdf):
"""Attributes only, a fresh index, and one datetime unit (formats disagree on it)."""
return gdf[ATTRS].reset_index(drop=True).astype({"RatingDate": "datetime64[us]"})
def write_and_read(gdf, path):
if path.suffix == ".parquet":
gdf.to_parquet(path)
return gpd.read_parquet(path)
gdf.to_file(path)
return gpd.read_file(path)
@pytest.mark.parametrize("suffix", [".gpkg", ".geojson", ".parquet"])
def test_round_trip_keeps_values(tmp_path, register, suffix):
original = clean(register)
back = write_and_read(original, tmp_path / f"out{suffix}")
assert back.crs == original.crs
pd.testing.assert_frame_equal(comparable(back), comparable(original), check_dtype=False)
assert back.geometry.reset_index(drop=True).geom_equals_exact(
original.geometry.reset_index(drop=True), tolerance=0).all()
@pytest.mark.parametrize("suffix,column,dtype", [
(".gpkg", "rating", "float64"), # nullable Int64 comes back as float
(".geojson", "FHRSID", "int32"), # int64 comes back narrower
(".parquet", "rating", "Int64"), # kept
])
def test_what_each_format_does_to_dtypes(tmp_path, register, suffix, column, dtype):
"""Recorded so a library upgrade that changes it is noticed."""
back = write_and_read(clean(register), tmp_path / f"out{suffix}")
assert str(back[column].dtype) == dtype
def test_shapefile_truncates_names_and_dates(tmp_path, register):
path = tmp_path / "out.shp"
with pytest.warns(UserWarning, match="truncated"):
clean(register).to_file(path)
back = gpd.read_file(path)
assert "BusinessNa" in back.columns and "BusinessName" not in back.columns
assert back["RatingDate"].dtype == "str"
!{sys.executable} -m pytest -q -p no:cacheprovider --color=no --disable-warnings tests/test_round_trip.py
....... [100%]
7 passed, 3 warnings in 0.21s
All three formats keep every value of the 338 located businesses, and the CRS β GeoJSON included, because GDAL writes the legacy crs member for a non-WGS 84 layer unless told to follow RFC 7946. What they do not keep is the dtype: a nullable integer rating comes back from GeoPackage as float64, and GeoJSON narrows FHRSID to int32. The value test compares with check_dtype=False so it tests what matters, and the dtype test records the changes so an upgrade that alters them fails loudly instead of quietly. comparable() is there because of a trap found on the first run: GeoPackage and GeoJSON return the dates as datetime64[ms] against the original's [us], and with a missing date in the column, pandas 3.0.6's assert_frame_equal(check_dtype=False) compares the raw integers β 1741132800000 against 1741132800000000 β and reports 88% of identical dates as different. The shapefile test uses pytest.warns rather than suppressing the warning: it asserts that the warning happens, and it pins down that the date column comes back as text. See why are my shapefile column names truncated.
4. Write atomically, and test that you do
A half-written output is worse than none, because it looks complete. write_atomic() in fsa.py writes to a temporary name in the same directory and renames it into place. The test makes the write fail half-way:
%%writefile tests/test_atomic.py
from pathlib import Path
import geopandas as gpd
import pytest
from fsa import clean, write_atomic
def test_failed_write_leaves_no_partial_file(tmp_path, monkeypatch, register):
dst = tmp_path / "out.gpkg"
def exploding_to_file(self, path, *args, **kwargs):
Path(path).write_bytes(b"partial") # a partial write, then the disk fills
raise OSError("disk full")
monkeypatch.setattr(gpd.GeoDataFrame, "to_file", exploding_to_file)
with pytest.raises(OSError, match="disk full"):
write_atomic(clean(register), dst)
assert not dst.exists(), "a failed write must not leave an output file"
assert list(tmp_path.iterdir()) == [], "and must not leave a temp file"
def test_successful_write_replaces_the_old_file(tmp_path, register):
dst = tmp_path / "out.gpkg"
write_atomic(clean(register).head(5), dst)
write_atomic(clean(register), dst)
assert len(gpd.read_file(dst)) == 338
assert [p.name for p in tmp_path.iterdir()] == ["out.gpkg"]
!{sys.executable} -m pytest -q -p no:cacheprovider --color=no --disable-warnings tests/test_atomic.py
.. [100%]
2 passed in 0.16s
Path.replace is atomic within one filesystem, so a reader either sees the old file or the complete new one, never a partial. tmp deliberately lives in the same directory as dst β a rename across filesystems is a copy, and not atomic.
5. Test error paths, not only the happy one
%%writefile tests/test_errors.py
import geopandas as gpd
import pytest
from fsa import clean_file
def test_missing_input_raises_clearly(tmp_path):
with pytest.raises(FileNotFoundError, match="input not found"):
clean_file(tmp_path / "nope.gpkg", tmp_path / "out.gpkg")
def test_gdal_does_not_always_name_the_file(corrupt_file):
with pytest.raises(Exception) as exc:
gpd.read_file(corrupt_file)
assert "corrupt.gpkg" not in str(exc.value) # a bare SQLite error
def test_corrupt_input_fails_with_the_path_in_the_message(tmp_path, corrupt_file):
with pytest.raises(ValueError, match="corrupt.gpkg"):
clean_file(corrupt_file, tmp_path / "out.gpkg")
def test_unlocated_businesses_do_not_survive(tmp_path, raw_file):
raw = gpd.read_file(raw_file)
assert raw.geometry.notna().all() and raw.geometry.is_empty.sum() == 2 # EMPTY, not null
assert clean_file(raw_file, tmp_path / "out.gpkg") == (12, 10)
def test_empty_input_produces_empty_output(tmp_path, sample):
src, dst = tmp_path / "empty.gpkg", tmp_path / "out.gpkg"
sample.iloc[:0].to_file(src, driver="GPKG")
assert clean_file(src, dst) == (0, 0)
back = gpd.read_file(dst)
assert back.empty and back.crs.to_epsg() == 27700
def test_output_directory_is_created(tmp_path, raw_file):
dst = tmp_path / "nested" / "deeper" / "out.gpkg"
clean_file(raw_file, dst)
assert dst.exists()
!{sys.executable} -m pytest -q -p no:cacheprovider --color=no --disable-warnings tests/test_errors.py
...... [100%]
6 passed, 3 warnings in 0.15s
The truncated file is the case that justifies the wrapper. GDAL opens it, fails inside SQLite, and raises an error whose message is a SQL statement with no file name in it β in a batch of a hundred files, useless. The second test pins that behaviour down, and the third shows the wrapper fixing it. The unlocated-business test is the one that matters most for this data: read back from the GeoPackage, the two businesses with no location are not null, they are POINT (Infinity Infinity), and only the validity check removes them. An empty layer, by contrast, is not a problem here: GeoPandas 1.1 writes and reads an empty GeoPackage with its CRS intact.
6. Avoid mocking the file system
unittest.mock on gpd.read_file and to_file is tempting and usually a mistake. Here is what it misses. The function below forgets the validity check, so it passes the unlocated businesses to the writer; a mocked test and a real one each exercise it:
%%writefile tests/test_mocking.py
from unittest import mock
import geopandas as gpd
def clean_register_file(src, dst):
"""The bug: missing geometries are dropped, empty ones are not."""
gdf = gpd.read_file(src)
gdf[gdf.geometry.notna()].to_crs(27700).to_file(dst, driver="ESRI Shapefile")
@mock.patch("geopandas.GeoDataFrame.to_file")
@mock.patch("geopandas.read_file")
def test_mocked(mock_read, mock_write, sample):
mock_read.return_value = sample
clean_register_file("anything.gpkg", "out.shp")
mock_write.assert_called_once()
def test_real_files(tmp_path, sample):
sample.to_file(tmp_path / "in.gpkg")
clean_register_file(tmp_path / "in.gpkg", tmp_path / "out.shp")
assert len(gpd.read_file(tmp_path / "out.shp")) == 10
!{sys.executable} -m pytest -q -p no:cacheprovider --color=no --disable-warnings tests/test_mocking.py 2>&1 | grep -E "passed|failed|Error"
E AssertionError: assert 12 == 10
tests/test_mocking.py:23: AssertionError
FAILED tests/test_mocking.py::test_real_files - AssertionError: assert 12 == 10
1 failed, 1 passed, 5 warnings in 0.17s
The mocked test passes because nothing was written. The real one fails: the shapefile holds 12 rows for 10 located businesses, because the two read back from the GeoPackage as POINT EMPTY went straight through. Real temporary files are cheap enough that there is rarely a reason to avoid them:
import time
import geopandas as gpd
from fsa import clean, load_register
cleaned = clean(load_register("data/food.json"))
for suffix in (".gpkg", ".geojson", ".shp"):
start = time.perf_counter()
cleaned.drop(columns="RatingDate").to_file(f"timing{suffix}")
print(f"{suffix:9} {len(cleaned)} businesses written in {(time.perf_counter() - start) * 1000:.0f} ms")
.gpkg 338 businesses written in 24 ms
.geojson 338 businesses written in 3 ms
.shp 338 businesses written in 3 ms
Mock only what is genuinely external and slow: a network call, a database, a cloud bucket. fetch_and_clean() downloads the register, and its test replaces only the download:
%%writefile tests/test_download.py
import shutil
import fsa
from conftest import REGISTER
def test_uses_cached_download(tmp_path, monkeypatch):
calls = []
def fake_download(url, dest):
calls.append(url)
shutil.copy(REGISTER, dest)
monkeypatch.setattr(fsa, "download", fake_download)
url = "https://ratings.food.gov.uk/api/open-data-files/FHRS879en-GB.json"
assert fsa.fetch_and_clean(url, tmp_path) == (462, 338)
assert fsa.fetch_and_clean(url, tmp_path) == (462, 338)
assert len(calls) == 1, "the second call should use the cache"
!{sys.executable} -m pytest -q -p no:cacheprovider --color=no --disable-warnings tests/test_download.py
. [100%]
1 passed in 0.17s
monkeypatch is preferable to mock.patch in pytest because it undoes itself at the end of the test automatically.
Folder jobs and golden files on Rutland hygiene data
The data used on this page
The Food Standards Agency's food hygiene rating data for Rutland County Council (Open Government Licence), downloaded once and read by every test.
import sys
from pathlib import Path
import requests
Path("data").mkdir(exist_ok=True)
Path("tests").mkdir(exist_ok=True)
if not Path("data/food.json").exists():
Path("data/food.json").write_bytes(requests.get(
# the FSA register file for Rutland as saved on 9 October 2026 (updated daily); live:
# ratings.food.gov.uk/api/open-data-files/FHRS879en-GB.json
"https://data.spatialworkflow.io/fhrs-879-2026-10-09.json", timeout=120).content)
print(Path("data/food.json").stat().st_size // 1024, "KB")
295 KB
Example 1: testing a folder-processing job
%%writefile batch.py
from pathlib import Path
import geopandas as gpd
from fsa import clean, write_atomic
def process_folder(src_dir, dst_dir, *, pattern="*.gpkg", on_error="report"):
"""Process every matching file; returns a per-file report."""
src_dir, dst_dir = Path(src_dir), Path(dst_dir)
dst_dir.mkdir(parents=True, exist_ok=True)
results = []
for path in sorted(src_dir.glob(pattern)):
try:
gdf = gpd.read_file(path)
out = clean(gdf)
write_atomic(out, dst_dir / path.name)
results.append({"file": path.name, "status": "ok", "rows_in": len(gdf), "rows_out": len(out)})
except Exception as exc:
if on_error == "raise":
raise
results.append({"file": path.name, "status": "failed",
"error": f"{type(exc).__name__}: {exc}"[:120]})
return results
%%writefile tests/test_batch.py
import hashlib
import shutil
import pytest
from batch import process_folder
def test_processes_every_matching_file(input_folder, tmp_path):
results = process_folder(input_folder, tmp_path / "out")
assert len(results) == 5, "readme.txt must not be picked up by *.gpkg"
assert all(r["status"] == "ok" for r in results)
assert sum(r["rows_in"] for r in results) == 341
def test_one_bad_file_does_not_stop_the_run(input_folder, tmp_path, corrupt_file):
shutil.copy(corrupt_file, input_folder / "bad.gpkg")
results = process_folder(input_folder, tmp_path / "out")
failed = [r for r in results if r["status"] == "failed"]
assert [r["file"] for r in failed] == ["bad.gpkg"]
assert sum(r["status"] == "ok" for r in results) == 5
assert "bad.gpkg" not in {p.name for p in (tmp_path / "out").iterdir()}
def test_on_error_raise_stops_immediately(input_folder, tmp_path, corrupt_file):
shutil.copy(corrupt_file, input_folder / "aaa_bad.gpkg") # sorts first
with pytest.raises(Exception):
process_folder(input_folder, tmp_path / "out", on_error="raise")
assert list((tmp_path / "out").glob("*.gpkg")) == []
def test_empty_folder_is_not_an_error(tmp_path):
(tmp_path / "empty").mkdir()
assert process_folder(tmp_path / "empty", tmp_path / "out") == []
def test_rerunning_gives_the_same_data(input_folder, tmp_path):
out = tmp_path / "out"
first = process_folder(input_folder, out)
sizes = {p.name: p.stat().st_size for p in out.glob("*.gpkg")}
digests = {p.name: hashlib.md5(p.read_bytes()).hexdigest() for p in out.glob("*.gpkg")}
second = process_folder(input_folder, out)
assert first == second
assert {p.name: p.stat().st_size for p in out.glob("*.gpkg")} == sizes
changed = [p.name for p in out.glob("*.gpkg") if hashlib.md5(p.read_bytes()).hexdigest() != digests[p.name]]
print("files whose bytes changed on the re-run:", len(changed), "of", len(digests))
!{sys.executable} -m pytest -q -p no:cacheprovider --color=no --disable-warnings -s tests/test_batch.py
....files whose bytes changed on the re-run: 5 of 5
.
5 passed, 2 warnings in 0.33s
Five tests covering the behaviours that matter for a batch job: it processes what it should and skips readme.txt, one bad file does not sink the run, the strict mode really is strict, an empty folder is not an error, and re-running gives the same result. That last one is idempotency, and the printed line shows why the test compares reports and sizes rather than bytes: a GeoPackage records when it was written, so a re-run of identical data produces a file with different bytes.
aaa_bad.gpkg is named to sort first so the raise test genuinely stops before any successful output, rather than passing by accident.
Example 2: a golden-file test with a regeneration switch
Some outputs are easier to compare against a known-good file than to describe in assertions. The --regenerate-golden option is registered in conftest.py above:
%%writefile tests/test_golden.py
from pathlib import Path
import geopandas as gpd
import pandas as pd
import pytest
from fsa import clean_file
GOLDEN = Path(__file__).parent / "golden" / "rutland_clean.gpkg"
def test_output_matches_golden(tmp_path, raw_file, request):
out = tmp_path / "result.gpkg"
clean_file(raw_file, out)
result = gpd.read_file(out)
if request.config.getoption("--regenerate-golden"):
GOLDEN.parent.mkdir(parents=True, exist_ok=True)
result.to_file(GOLDEN, driver="GPKG")
pytest.skip(f"regenerated {GOLDEN.name}")
if not GOLDEN.exists():
pytest.fail(f"{GOLDEN.name} missing: run pytest --regenerate-golden")
expected = gpd.read_file(GOLDEN)
assert result.crs == expected.crs
pd.testing.assert_frame_equal(result.drop(columns="geometry"), expected.drop(columns="geometry"))
assert result.geometry.geom_equals_exact(expected.geometry, tolerance=1e-6).all()
for flags in ("", "--regenerate-golden", ""):
print(f"$ pytest tests/test_golden.py {flags}".rstrip())
!{sys.executable} -m pytest -q -p no:cacheprovider --color=no --disable-warnings -rs tests/test_golden.py {flags} 2>&1 | grep -E "passed|failed|skipped|SKIPPED|missing" | head -2
$ pytest tests/test_golden.py
> pytest.fail(f"{GOLDEN.name} missing: run pytest --regenerate-golden")
E Failed: rutland_clean.gpkg missing: run pytest --regenerate-golden
$ pytest tests/test_golden.py --regenerate-golden
SKIPPED [1] tests/test_golden.py:20: regenerated rutland_clean.gpkg
1 skipped in 0.13s
$ pytest tests/test_golden.py
1 passed in 0.13s
The three runs are the life of a golden file: it fails until one exists, the flag writes it and skips, and after that the test compares against it. Golden files catch changes no hand-written assertion would think to check β a column reordered, a CRS subtly altered, a geometry type changed. The risk is that regenerating becomes reflexive, so the file records whatever the code does rather than what it should do.
Two things keep that honest. Regeneration requires an explicit flag, so it cannot happen by accident. And the golden file is committed, so the diff appears in code review β which is where a reviewer can ask whether the change was intended. That review step is the entire value; without it a golden test is a way of writing down bugs.
Keep golden files small. The ten businesses here are a reviewable artefact; a 200 MB file is a binary blob nobody will look at.
Example 3: the whole suite
!{sys.executable} -m pytest -q -p no:cacheprovider --color=no --disable-warnings tests --deselect tests/test_mocking.py::test_real_files 2>&1 | tail -1
23 passed, 1 deselected, 8 warnings in 0.51s
test_real_files is deselected because it exists to fail: it is the demonstration of what the mocked test misses, not a test of fsa.py.
File handling is a design problem
Testing file-handling code is really a design problem, and the tests are the thing that reveals it.
Code that reads a fixed path, transforms, and writes a fixed path has no seams. There is nothing to call with different inputs, no return value to inspect, and no way to run it without the paths existing. The commonest response β mocking the file system β tests the calls rather than the behaviour, and, as step 6 showed, passes when the output could never have been written.
The better response is hexagonal: push I/O to the edges and keep a pure core. clean(gdf) -> gdf is a function you can call with any input, that returns something you can assert on, that has no side effects and no environment dependencies. Most of the interesting logic belongs there, and most tests then need no file at all. clean_file(src, dst) becomes a thin shell whose remaining behaviour β reading, writing, creating directories, handling failure β is genuinely about files and worth testing separately.
tmp_path is what makes the remaining file tests safe. Each test gets a fresh directory, so nothing collides, nothing leaks between tests, and tests can run in parallel. The alternative β a fixed /tmp/test_output β produces tests that pass in isolation, fail in sequence, and fail differently depending on order.
Round-trip tests earn their place because file formats change things. On this register a shapefile truncated three column names and turned the rating date into text; a GeoPackage turned a nullable integer into a float and a missing location into POINT EMPTY; GeoJSON narrowed a 64-bit ID to 32 bits. None of this is visible in memory, and all of it is visible after a write and a read β the comparison in GIS vector file formats compared.
Atomic writes deserve a test of their own, because the failure they prevent is silent. A process killed mid-write leaves a file that exists, has a plausible size, and may open β and downstream code will read it as if it were complete. Writing to a temporary name in the same directory and renaming makes the output appear only when it is finished. Testing it requires simulating a failure, which is what monkeypatch is genuinely good for: not replacing the file system, but making one specific operation fail on demand.
Finally, the error paths matter more here than in most code, because file I/O is where the environment intrudes. Missing inputs, truncated downloads, unwritable directories and records with no location are all normal conditions in a scheduled job, and each one has a right answer that only a test can pin down. A pipeline whose happy path is tested and whose failure modes are not is a pipeline that works until the first bad day β which is the argument made at greater length in what to test in a GIS pipeline.
tmp_path, cross-filesystem replace and layer names
tmp_pathis apathlib.Path;tmpdiris the legacypy.path. Prefertmp_path.tmp_path_factorygives session scope, for an expensive input built once β but nothing may mutate it.Path.replaceis atomic only within one filesystem. Keep the temp file in the destination directory.- Name the layer when writing to a temporary file. GDAL takes a GeoPackage's layer name from the file name, and the first version of
write_atomic(), writing to.out.gpkg.tmp, failed with "The layer name may not contain special characters or spaces". Passinglayer=dst.stemalso keeps the final layer named after the final file. write_atomic()is for single-file formats. A shapefile is three or more files, and renaming the.shpleaves its.dbfand.prjbehind under the temporary name.- Empty inputs are worth a test even when they work. GeoPandas 1.1 with pyogrio writes an empty GeoPackage with its columns and CRS; older stacks did not always, and the test is what tells you when that changes.
- Shapefiles write several files.
dst.exists()on the.shpsays nothing about the.prj; check that too. points_from_xywith missing coordinates makesPOINT (NaN NaN), notNone. It is not null and not empty, only invalid. Reprojecting it givesPOINT (Infinity Infinity); a GeoPackage returns it asPOINT EMPTY, which is valid; a shapefile refuses it outright with "Coordinates with non-finite values are not allowed".pytest.warnsasserts a warning happens β better than suppressing it, since removing the warning then fails the test.monkeypatchundoes itself;mock.patchas a bare call does not.- Golden files must be committed and small, or the review step that justifies them does not happen.
--basetempoverrides wheretmp_pathlives, useful when the default temp directory is small or slow.- Parallel runs with
pytest-xdistneed per-test temp directories, whichtmp_pathalready provides.
Internal links
- How to test a GIS pipeline with pytest β the surrounding setup
- Test fixtures for GIS code β building the data these tests read
- How to assert on geometry in pytest β comparing what came back
- What to test in a GIS pipeline (and what you cannot) β choosing which behaviours matter
- GIS vector file formats compared β what each format loses in a round trip
- Why are my shapefile column names truncated? β a round-trip failure worth asserting
- Idempotency explained: why a GIS job must be safe to re-run β the property Example 2 tests
- How to validate pipeline inputs and outputs automatically β the runtime version of these checks
FAQ
How do I test a function with a hard-coded path?
You cannot β change it to take the path as an argument. That is the fix, and it makes the function reusable as a side effect.
Should I mock read_file and to_file?
No. Mocks test that you called a function, not that the file is correct. Real temporary files write in milliseconds and catch driver, CRS and schema problems that mocks cannot.
What is tmp_path?
A pytest fixture giving each test a fresh pathlib.Path directory. pytest creates it, retains the last few runs for inspection, and cleans up older ones. Two tests can never collide.
How do I test that a failed write leaves no output?
Write to a temporary name and rename on success, then use monkeypatch to make the write raise and assert that neither the output nor the temp file exists.
Do I need to test the round trip through a file?
Yes, if the format matters. Formats change things β a shapefile truncates column names and turns dates into text, a GeoPackage turns a nullable integer into a float β and none of it is visible until you write and read back.
Are golden-file tests a good idea?
Yes if they are small, committed, and regenerated only behind an explicit flag, so the change appears in code review. Otherwise they become a record of whatever the code currently does.
How do I test a folder-processing job?
Build a fixture folder containing the good files, a non-matching file and a corrupt one, then assert what gets processed, what gets skipped, what gets reported, and that re-running changes nothing.