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.

A thin read shell, a pure transformation core, and a thin write shell. push the file handling to the edgesread_file(src)thin shellclean(gdf) -> gdfpure, testablewrite_atomic(dst)thin shellMost tests then need no file at all spatialworkflow.io
Push the file handling to the edges and the interesting code becomes testable without files at all.
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
The sample fixture: ten located Rutland food businesses and two published without a location. the sample fixture, from the real registerBusinessNameRatingValueRatingDategeometry10 Dining Street52025-03-05POINTAhimsa Dairy Foundation12026-08-19POINT… 8 more locatedPOINTAli Green Ltd52023-11-07NaN NaNAll You Can TreatAwaiting…NoneNaN NaNnot null, not empty: invalida rating that is not a number and a missing date, as publishedReal rows carry the awkward cases a made-up fixture forgets spatialworkflow.io
Every fixture in this article is built from these columns.

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.

What a write-and-read round trip did to 339 Rutland food businesses in each format. round trip of the cleaned registerShapefileGeoJSONGeoPackagecolumn namesβœ•cut to 10 charsβœ“keptβœ“keptthe CRSβœ“kept (.prj)βœ“kept (27700)βœ“keptrating (Int64)!float64!float64!float64RatingDateβœ•text!datetime [ms]!datetime [ms]FHRSID (int64)βœ“int64!int32βœ“int64Assert the round trip, not that a file appeared spatialworkflow.io
Measured by the tests in step 3: none of it is visible in memory, all of it after a write and a read.

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.

The failure paths worth a test, because file I/O is where the environment intrudes. the unhappy paths are the ones production runsA missing input fileFileNotFoundError, with the pathA truncated GeoPackageGDAL's error has no file nameBusinesses with no locationcome back as POINT EMPTYAn empty layerwritten and read with its CRSAn output folder not there yetcreated, not a crashNone of these are exotic β€” they are what a scheduled job meets on its first bad morning spatialworkflow.io
Each one is a two-line test and each one has cost somebody a night.

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.

Re-running the batch job on five real input files: the bytes change, the data does not. what "the re-run changed nothing" should meanCompare bytesβœ•md5 of each output fileβœ•5 of 5 files changedβœ•GeoPackage stores a write timeβœ•the test fails on identical dataCompare dataβœ“per-file report and row countsβœ“identical on both runsβœ“file sizes identical tooβœ“the test fails only on a real changeIdempotent means the same data, not the same bytes spatialworkflow.io
The printed line from the test run: identical data, five files 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

Writing to a temporary name and renaming so a crash leaves nothing partial. the output appears only when complete1Write to out.partial.gpkgsame folder, layer="out"2Crash here leaves only the tempno false result3Path.replace(tmp, dst)atomic4Remove the temp in a finallyalwaysRename across filesystems is a copy, and not atomic spatialworkflow.io
The output appears only when it is complete. A crash leaves nothing to mistake for a result.

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

What happens to a point built from missing coordinates at each step of a GeoPandas pipeline. one missing location, four behavioursWhere it goesWhat it becomespoints_from_xy(NaN, NaN)POINT (NaN NaN): not null, invalidto_crs(27700)POINT (Infinity Infinity)GeoPackage, read backPOINT EMPTY, which is validShapefilerefused: non-finite coordinatesDrop rows that are invalid or empty, before anything writes them spatialworkflow.io
The two unlocated businesses in the fixture, followed through the pipeline.
  • tmp_path is a pathlib.Path; tmpdir is the legacy py.path. Prefer tmp_path.
  • tmp_path_factory gives session scope, for an expensive input built once β€” but nothing may mutate it.
  • Path.replace is 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". Passing layer=dst.stem also 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 .shp leaves its .dbf and .prj behind 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 .shp says nothing about the .prj; check that too.
  • points_from_xy with missing coordinates makes POINT (NaN NaN), not None. It is not null and not empty, only invalid. Reprojecting it gives POINT (Infinity Infinity); a GeoPackage returns it as POINT EMPTY, which is valid; a shapefile refuses it outright with "Coordinates with non-finite values are not allowed".
  • pytest.warns asserts a warning happens β€” better than suppressing it, since removing the warning then fails the test.
  • monkeypatch undoes itself; mock.patch as a bare call does not.
  • Golden files must be committed and small, or the review step that justifies them does not happen.
  • --basetemp overrides where tmp_path lives, useful when the default temp directory is small or slow.
  • Parallel runs with pytest-xdist need per-test temp directories, which tmp_path already provides.

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.