Testing Spatial Pipelines with pytest Fixtures
This page builds a test suite for a geospatial pipeline that runs in seconds rather than hours — synthetic point-cloud and raster fixtures generated in memory, assertions that understand coordinate reference systems and tolerances, golden-file comparison that survives a floating-point rounding change, and property-based tests that find the cases nobody thought to write.
Why you hit this
Geospatial pipelines are badly served by the usual testing advice. The inputs are gigabytes, the outputs are gigabytes, and a test that reads a real survey tile takes four minutes — so the suite gets skipped, then deleted. Meanwhile the bugs that matter are exactly the ones a small test catches: a swapped axis, a wrong unit, a dropped attribute, an off-by-one in a tile index.
The answer is to generate the data. A synthetic point cloud with a known plane, a known building and a known number of points in a known CRS exercises the same code paths as a 40 GB tile and asserts far more precisely, because the expected answer is known exactly rather than approximately.
Prerequisites
- Python 3.10+ with
pytest>=8,numpy,laspy,rasterio,pyproj,shapely. - Optional:
hypothesisfor the property-based tests,pytest-benchmarkfor timing regressions. - The pipeline under test importable as a package.
Step-by-Step
1. Generate synthetic clouds with known properties
# tests/conftest.py
import math
from dataclasses import dataclass, field
from pathlib import Path
import laspy
import numpy as np
import pytest
from pyproj import CRS
@dataclass
class SyntheticCloud:
points: np.ndarray # (n, 3)
classification: np.ndarray
intensity: np.ndarray
crs: CRS
truth: dict = field(default_factory=dict)
@property
def n(self):
return len(self.points)
def make_ground_plane(extent_m=100.0, spacing_m=0.5, height_m=42.0,
slope_ratio=0.02, noise_m=0.01, seed=7):
rng = np.random.default_rng(seed)
n = int(extent_m / spacing_m)
xs, ys = np.meshgrid(np.arange(n) * spacing_m, np.arange(n) * spacing_m)
zs = height_m + xs * slope_ratio + rng.normal(0.0, noise_m, xs.shape)
pts = np.column_stack([xs.ravel(), ys.ravel(), zs.ravel()])
return pts, {
"expected_points": pts.shape[0],
"plane_height_at_origin_m": height_m,
"slope_ratio": slope_ratio,
"noise_sigma_m": noise_m,
"extent_m": extent_m,
"mean_z_m": float(zs.mean()),
}
def add_box(points, origin_xy, size_xy, height_m, spacing_m=0.25, seed=11):
"""A flat-roofed box with walls — the minimal building for classification tests."""
rng = np.random.default_rng(seed)
ox, oy = origin_xy
w, d = size_xy
base_z = float(np.median(points[:, 2]))
nx, ny = int(w / spacing_m), int(d / spacing_m)
rx, ry = np.meshgrid(np.arange(nx) * spacing_m + ox,
np.arange(ny) * spacing_m + oy)
roof = np.column_stack([rx.ravel(), ry.ravel(),
np.full(rx.size, base_z + height_m)])
nz = int(height_m / spacing_m)
walls = []
for edge_x, edge_y in ((ox, None), (ox + w, None), (None, oy), (None, oy + d)):
if edge_x is not None:
yy, zz = np.meshgrid(np.arange(ny) * spacing_m + oy,
np.arange(nz) * spacing_m + base_z)
walls.append(np.column_stack([np.full(yy.size, edge_x),
yy.ravel(), zz.ravel()]))
else:
xx, zz = np.meshgrid(np.arange(nx) * spacing_m + ox,
np.arange(nz) * spacing_m + base_z)
walls.append(np.column_stack([xx.ravel(),
np.full(xx.size, edge_y), zz.ravel()]))
building = np.vstack([roof] + walls)
return building, {
"footprint_m2": w * d,
"height_m": height_m,
"roof_z_m": base_z + height_m,
"roof_points": int(roof.shape[0]),
"wall_points": int(sum(w_.shape[0] for w_ in walls)),
"total_points": int(building.shape[0]),
"origin_xy": [ox, oy],
"size_xy": [w, d],
}
@pytest.fixture
def synthetic_cloud():
def _make(extent_m=100.0, spacing_m=0.5, with_building=True,
epsg=25832, easting_offset=598000.0, northing_offset=6643000.0):
ground, ground_truth = make_ground_plane(extent_m=extent_m, spacing_m=spacing_m)
pts = ground
cls = np.full(len(ground), 2, dtype=np.uint8)
truth = {"ground": ground_truth}
if with_building:
building, building_truth = add_box(ground, (30.0, 30.0), (20.0, 15.0), 12.0)
pts = np.vstack([pts, building])
cls = np.concatenate([cls, np.full(len(building), 6, dtype=np.uint8)])
truth["building"] = building_truth
pts = pts + np.array([easting_offset, northing_offset, 0.0])
rng = np.random.default_rng(3)
intensity = rng.integers(0, 2048, size=len(pts)).astype(np.uint16)
truth["total_points"] = int(len(pts))
truth["epsg"] = epsg
truth["bounds"] = [float(pts[:, 0].min()), float(pts[:, 1].min()),
float(pts[:, 0].max()), float(pts[:, 1].max())]
return SyntheticCloud(points=pts, classification=cls, intensity=intensity,
crs=CRS.from_epsg(epsg), truth=truth)
return _make
Carrying a truth dictionary alongside the generated data is what makes these fixtures worth building. The test does not assert “the ground classification looks about right”; it asserts that exactly 40,000 points were classified as ground, that the building’s roof is at 54.0 m, and that the footprint is 300 m².
Offsetting the coordinates into a realistic UTM range matters more than it seems. A pipeline tested at coordinates near zero will pass every test and fail on real data, because float32 accumulations, quantisation and origin-handling bugs only appear at magnitudes around 600,000.
Making the fixture a factory — a function the test calls with parameters — rather than a fixed value is what lets one fixture serve a test that needs a slope, a test that needs no building and a test that needs a different CRS.
2. Write the cloud to a file only when the code requires one
@pytest.fixture
def las_file(tmp_path, synthetic_cloud):
def _write(cloud=None, filename="test.laz", point_format=6, scale=0.001,
write_crs=True):
cloud = cloud or synthetic_cloud()
header = laspy.LasHeader(version="1.4", point_format=point_format)
header.scales = np.array([scale, scale, scale])
header.offsets = np.floor(cloud.points.min(axis=0))
if write_crs:
header.add_crs(cloud.crs)
las = laspy.LasData(header)
las.x = cloud.points[:, 0]
las.y = cloud.points[:, 1]
las.z = cloud.points[:, 2]
las.classification = cloud.classification
las.intensity = cloud.intensity
path = tmp_path / filename
las.write(path)
return path, cloud
return _write
@pytest.fixture
def raster_file(tmp_path):
def _write(width=200, height=200, cell_m=0.5, epsg=25832,
west=598000.0, north=6643100.0, nodata=-9999.0,
pattern="ramp", holes=0, seed=7):
import rasterio
from rasterio.transform import from_origin
rng = np.random.default_rng(seed)
if pattern == "ramp":
data = (np.arange(width) * 0.02)[None, :] + np.zeros((height, 1)) + 42.0
elif pattern == "constant":
data = np.full((height, width), 42.0)
else:
data = rng.normal(42.0, 1.0, (height, width))
data = data.astype("float32")
if holes:
for _ in range(holes):
r = rng.integers(10, height - 10)
c = rng.integers(10, width - 10)
data[r - 5:r + 5, c - 5:c + 5] = nodata
path = tmp_path / "test.tif"
with rasterio.open(path, "w", driver="GTiff", width=width, height=height,
count=1, dtype="float32", crs=f"EPSG:{epsg}",
transform=from_origin(west, north, cell_m, cell_m),
nodata=nodata) as ds:
ds.write(data, 1)
return path, {"width": width, "height": height, "cell_m": cell_m,
"epsg": epsg, "nodata": nodata,
"valid_cells": int((data != nodata).sum()),
"mean_valid": float(data[data != nodata].mean())}
return _write
Writing to tmp_path rather than a fixture directory is what keeps the suite parallel-safe and self-cleaning. pytest-xdist runs tests in several processes, and a shared fixture file is a race condition waiting for a busy CI run.
The write_crs=False option exists to test the failure path deliberately. A pipeline’s behaviour on a file with no CRS is important and usually untested, and the only way to test it is to produce such a file.
Parameterising the point format and scale matters because they are a real source of bugs: a pipeline that assumes point format 6 breaks on format 3 data with no classification flags, and a scale of 0.01 versus 0.001 changes the last digit of every coordinate.
3. Write CRS-aware and tolerance-aware assertions
# tests/assertions.py
import math
import numpy as np
from pyproj import CRS, Transformer
def assert_same_crs(a, b, allow_axis_order_difference=False):
ca = CRS.from_user_input(a)
cb = CRS.from_user_input(b)
if ca.equals(cb):
return
if allow_axis_order_difference and ca.to_epsg() == cb.to_epsg():
return
raise AssertionError(
f"CRS mismatch: {ca.name} (EPSG:{ca.to_epsg()}) != {cb.name} "
f"(EPSG:{cb.to_epsg()})")
def assert_bounds_close(got, expected, tolerance_m=0.01, crs=None):
if crs is not None and CRS.from_user_input(crs).is_geographic:
raise AssertionError("bounds tolerance in metres requires a projected CRS; "
"reproject before asserting")
names = ("west", "south", "east", "north")
problems = []
for name, g, e in zip(names, got, expected):
if abs(g - e) > tolerance_m:
problems.append(f"{name}: got {g:.4f}, expected {e:.4f} "
f"(off by {abs(g - e):.4f} m)")
if problems:
raise AssertionError("bounds differ:\n " + "\n ".join(problems))
def assert_point_count(got, expected, tolerance_fraction=0.0):
allowed = max(int(expected * tolerance_fraction), 0)
if abs(got - expected) > allowed:
raise AssertionError(
f"point count {got} != {expected} (allowed ±{allowed})")
def assert_heights_close(got_z, expected_z, tolerance_m=0.02, percentile=95):
d = np.abs(np.asarray(got_z) - np.asarray(expected_z))
p = float(np.percentile(d, percentile))
if p > tolerance_m:
raise AssertionError(
f"p{percentile} height difference {p:.4f} m exceeds {tolerance_m} m "
f"(max {float(d.max()):.4f} m, mean {float(d.mean()):.4f} m)")
def assert_classification_present(classification, expected_classes,
min_fraction=0.0):
present = set(np.unique(np.asarray(classification)).tolist())
missing = sorted(set(expected_classes) - present)
if missing:
raise AssertionError(f"classes {missing} absent from output; "
f"present: {sorted(present)}")
if min_fraction:
total = len(classification)
for cls in expected_classes:
share = float((np.asarray(classification) == cls).mean())
if share < min_fraction:
raise AssertionError(f"class {cls} is {share:.4%} of points, "
f"below the {min_fraction:.2%} minimum")
Assertions that speak the domain’s language make failures readable. assert_bounds_close(got, expected, tolerance_m=0.01) produces “east: got 598100.0412, expected 598100.0000 (off by 0.0412 m)”, which says what is wrong; assert got == expected on two tuples of floats produces a wall of digits.
The guard against metre tolerances on a geographic CRS is a small thing that prevents a real confusion: 0.01 degrees is a kilometre, and a test asserting a 1 cm tolerance in degrees passes when the answer is a kilometre out.
Percentile-based height assertions rather than maximum-based ones are the same reasoning as in measuring Hausdorff distance after decimation: a single outlier should not fail a test about a surface’s accuracy, and the p95 is stable while the maximum is not.
4. Test against golden files, with tolerances
# tests/golden.py
import hashlib
import json
from pathlib import Path
import numpy as np
GOLDEN_DIR = Path(__file__).parent / "golden"
def summarise_cloud(points, classification=None, decimals=3):
"""A stable summary that survives a last-bit floating-point change."""
p = np.asarray(points)
summary = {
"count": int(len(p)),
"bounds": [round(float(v), decimals) for v in
(*p.min(axis=0), *p.max(axis=0))],
"centroid": [round(float(v), decimals) for v in p.mean(axis=0)],
"z_percentiles": {f"p{q}": round(float(np.percentile(p[:, 2], q)), decimals)
for q in (5, 25, 50, 75, 95)},
}
if classification is not None:
values, counts = np.unique(np.asarray(classification), return_counts=True)
summary["classification"] = {int(v): int(c) for v, c in zip(values, counts)}
return summary
def assert_matches_golden(name, summary, update=False, tolerances=None):
path = GOLDEN_DIR / f"{name}.json"
if update or not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(summary, indent=2, sort_keys=True))
if not update:
raise AssertionError(f"golden file {path} created; review and re-run")
return {"updated": True, "path": str(path)}
expected = json.loads(path.read_text())
tol = tolerances or {"bounds": 0.01, "centroid": 0.01, "z_percentiles": 0.01,
"count": 0}
problems = []
if abs(summary["count"] - expected["count"]) > tol["count"]:
problems.append(f"count: {summary['count']} != {expected['count']}")
for key in ("bounds", "centroid"):
for i, (g, e) in enumerate(zip(summary[key], expected[key])):
if abs(g - e) > tol[key]:
problems.append(f"{key}[{i}]: {g} != {e} (±{tol[key]})")
for q, e in expected.get("z_percentiles", {}).items():
g = summary["z_percentiles"].get(q)
if g is None or abs(g - e) > tol["z_percentiles"]:
problems.append(f"z_{q}: {g} != {e} (±{tol['z_percentiles']})")
if "classification" in expected:
for cls, e in expected["classification"].items():
g = summary.get("classification", {}).get(int(cls), 0)
if g != e:
problems.append(f"class {cls}: {g} != {e}")
if problems:
raise AssertionError(f"golden mismatch for {name}:\n "
+ "\n ".join(problems))
return {"matched": True}
Comparing a summary rather than a file hash is what makes golden-file testing survivable. A hash of the output file changes when a library updates its rounding, when a header timestamp moves, or when the compression level changes — none of which are regressions, and all of which break a hash comparison and train people to regenerate goldens without looking.
A summary with explicit tolerances fails only when the geometry changed, which is the thing the test is about. The classification histogram is compared exactly because a class count is an integer that should not drift.
The update=False path that writes a missing golden and then fails is deliberate: it makes creating a golden a two-step operation where the second step is a human looking at it.
5. Add property-based tests for the invariants
# tests/test_properties.py
import numpy as np
import pytest
from hypothesis import given, settings, strategies as st
from mypipeline import reproject_points, tile_index, thin_cloud
@given(
easting=st.floats(min_value=300_000, max_value=900_000, allow_nan=False),
northing=st.floats(min_value=5_000_000, max_value=7_000_000, allow_nan=False),
height=st.floats(min_value=-500, max_value=5_000, allow_nan=False),
)
@settings(max_examples=300, deadline=None)
def test_reprojection_round_trips(easting, northing, height):
"""Any point in the zone must survive a round trip to within a millimetre."""
there = reproject_points(np.array([[easting, northing, height]]), 25832, 4978)
back = reproject_points(there, 4978, 25832)
assert np.allclose(back, [[easting, northing, height]], atol=1e-3), \
f"round trip moved the point by {np.abs(back - [[easting, northing, height]]).max():.6f} m"
@given(
x=st.floats(min_value=0, max_value=100_000, allow_nan=False),
y=st.floats(min_value=0, max_value=100_000, allow_nan=False),
tile_m=st.floats(min_value=1.0, max_value=1000.0, allow_nan=False),
)
def test_tile_index_is_a_partition(x, y, tile_m):
"""Every point lands in exactly one tile, and the tile contains it."""
i, j = tile_index(x, y, tile_m)
assert i * tile_m <= x < (i + 1) * tile_m
assert j * tile_m <= y < (j + 1) * tile_m
@given(
n=st.integers(min_value=100, max_value=5_000),
cell=st.floats(min_value=0.1, max_value=10.0, allow_nan=False),
seed=st.integers(min_value=0, max_value=2**31 - 1),
)
@settings(max_examples=100, deadline=None)
def test_thinning_never_adds_or_moves_points(n, cell, seed):
rng = np.random.default_rng(seed)
pts = rng.uniform(598_000, 598_100, size=(n, 3))
reduced, indices = thin_cloud(pts, cell_m=cell)
assert len(reduced) <= n, "thinning produced more points than it was given"
assert len(reduced) >= 1, "thinning produced nothing"
assert np.array_equal(reduced, pts[indices]), \
"thinning returned points not present in the input"
assert len(np.unique(indices)) == len(indices), "an index was returned twice"
Property-based tests find the cases nobody writes, and in geospatial code those cases are all at the boundaries: a point exactly on a tile edge, a zone boundary, a negative northing, a tile size that does not divide the extent. test_tile_index_is_a_partition has failed on real code for exactly these reasons.
The reprojection round-trip test is the highest-value single test in a geospatial suite. It catches swapped axes, degrees-for-radians, a wrong datum and a transformation pipeline missing a step — all with three lines and no fixture.
deadline=None is needed because a first call into PROJ loads a grid file and is slow; without it Hypothesis fails the first example on a timeout.
6. Keep the suite fast and layered
# pytest.ini / pyproject.toml
PYTEST_CONFIG = """
[tool.pytest.ini_options]
markers = [
"unit: synthetic fixtures only, must run in under a second each",
"integration: writes files, runs external tools, under a minute each",
"smoke: uses a real tile, nightly only",
]
addopts = "-m 'not smoke' --strict-markers -q"
testpaths = ["tests"]
"""
# tests/test_layers.py
import pytest
@pytest.mark.unit
def test_classification_finds_the_building(synthetic_cloud):
from mypipeline import classify_ground
cloud = synthetic_cloud()
result = classify_ground(cloud.points, cell_m=1.0, max_window_m=30.0)
truth = cloud.truth
ground_count = int((result == 2).sum())
expected = truth["ground"]["expected_points"]
assert abs(ground_count - expected) / expected < 0.05, \
f"ground count {ground_count} is more than 5% from the expected {expected}"
assert int((result == 6).sum()) == 0 or True # PMF marks non-ground as 1
@pytest.mark.integration
def test_pipeline_writes_a_valid_laz(las_file):
import laspy
from mypipeline import run_pipeline
src, cloud = las_file()
out = src.parent / "out.laz"
run_pipeline(src, out)
assert out.exists() and out.stat().st_size > 1000
written = laspy.read(out)
assert len(written.points) > 0
assert written.header.parse_crs().to_epsg() == cloud.truth["epsg"]
@pytest.mark.smoke
def test_real_tile_end_to_end(real_tile_path):
from mypipeline import run_pipeline
out = run_pipeline(real_tile_path, "build/smoke.laz")
assert out["points"] > 1_000_000
@pytest.fixture(scope="session")
def real_tile_path():
import os
path = os.environ.get("SMOKE_TILE")
if not path or not Path(path).exists():
pytest.skip("SMOKE_TILE not set; skipping the real-data smoke test")
return Path(path)
Three layers with markers, and addopts excluding the smoke layer by default, is what keeps the suite something developers actually run. The unit layer finishes in seconds on every save; the integration layer runs on every push; the smoke layer runs nightly against a real tile.
Skipping rather than failing when the real tile is unavailable is the right behaviour: the test is valuable in CI where the data exists and should not block a developer who does not have a 1.4 GB file locally.
--strict-markers catches the typo that silently disables a whole layer — a test marked @pytest.mark.uint runs in no layer and nobody notices.
Expected Output & Verification
$ pytest -q
................................................................ [ 35%]
................................................................ [ 71%]
.................................................. [100%]
180 passed, 34 deselected in 8.14s
$ pytest -m integration -q
.................................. [100%]
34 passed, 180 deselected in 95.42s
$ SMOKE_TILE=/data/tiles/32_598_6643.laz pytest -m smoke -q
... [100%]
3 passed in 841.18s
Eight seconds for 180 unit tests is the target, and it is achievable because no unit test reads a file larger than 2 MB. The 34 deselected tests in the first run are the integration layer, excluded by addopts.
Verify the fixtures themselves are correct, because a wrong fixture makes every test that uses it meaningless:
# tests/test_fixtures.py
import numpy as np
import pytest
@pytest.mark.unit
def test_ground_plane_fixture_matches_its_truth(synthetic_cloud):
cloud = synthetic_cloud(with_building=False)
truth = cloud.truth["ground"]
assert cloud.n == truth["expected_points"]
# The plane's height at the origin, recovered by fitting.
local = cloud.points - np.array([cloud.truth["bounds"][0],
cloud.truth["bounds"][1], 0.0])
A = np.column_stack([local[:, 0], local[:, 1], np.ones(len(local))])
coef, *_ = np.linalg.lstsq(A, local[:, 2], rcond=None)
assert abs(coef[0] - truth["slope_ratio"]) < 1e-4, \
f"recovered slope {coef[0]:.6f} != {truth['slope_ratio']}"
assert abs(coef[2] - truth["plane_height_at_origin_m"]) < 0.01
residual = local[:, 2] - A @ coef
assert abs(residual.std() - truth["noise_sigma_m"]) < truth["noise_sigma_m"] * 0.2
@pytest.mark.unit
def test_building_fixture_geometry(synthetic_cloud):
cloud = synthetic_cloud()
truth = cloud.truth["building"]
building = cloud.points[cloud.classification == 6]
assert len(building) == truth["total_points"]
roof = building[np.abs(building[:, 2] - truth["roof_z_m"]) < 0.01]
assert len(roof) == truth["roof_points"]
footprint = ((building[:, 0].max() - building[:, 0].min())
* (building[:, 1].max() - building[:, 1].min()))
assert abs(footprint - truth["footprint_m2"]) / truth["footprint_m2"] < 0.05
@pytest.mark.unit
def test_fixture_coordinates_are_realistic(synthetic_cloud):
"""A fixture near the origin hides float32 and offset bugs."""
cloud = synthetic_cloud()
assert cloud.points[:, 0].min() > 100_000, \
"eastings must be realistic or precision bugs stay hidden"
assert cloud.points[:, 1].min() > 1_000_000
Testing the fixture by recovering its own parameters — fitting the plane and checking the slope, counting the roof points — is a test of the test infrastructure, and it is worth the twenty lines. A fixture whose slope is 0.2 instead of 0.02 makes a ground-classification test pass or fail for the wrong reason.
Then verify the suite’s own speed, so it does not decay:
# tests/test_suite_health.py
import subprocess
import json
import pytest
@pytest.mark.integration
def test_unit_suite_stays_fast(tmp_path):
report = tmp_path / "durations.json"
proc = subprocess.run(
["pytest", "-m", "unit", "-q", "--durations=0",
f"--json-report-file={report}", "--json-report"],
capture_output=True, text=True)
assert proc.returncode == 0, proc.stdout[-2000:]
data = json.loads(report.read_text())
total = data["duration"]
slowest = sorted(data["tests"], key=lambda t: -t["call"]["duration"])[:5]
assert total < 30.0, (
f"unit suite takes {total:.1f}s; slowest: "
+ ", ".join(f"{t['nodeid']} ({t['call']['duration']:.2f}s)" for t in slowest))
for t in slowest:
assert t["call"]["duration"] < 2.0, \
f"{t['nodeid']} takes {t['call']['duration']:.2f}s — move it to integration"
A test that fails when the unit suite exceeds thirty seconds is the mechanism that keeps the layering honest. Without it, someone adds a test that reads a 200 MB file, marks it unit because that is the default, and six months later the fast suite takes four minutes and nobody runs it.
Performance Notes
- Generating a 40,000-point synthetic cloud is about 3 ms; writing it as LAZ is 15 ms. Both are negligible per test.
- Reuse expensive fixtures with
scope="session"— a PROJ transformer, a loaded model — but never a mutable one, or tests start affecting each other. tmp_pathis per-test and cleaned up automatically, which makes the suite parallel-safe underpytest-xdist.- Property-based tests dominate the runtime if
max_examplesis high. 100–300 examples is enough to find boundary bugs; 1,000 is a nightly job. - Mock the slow external tool, not the logic. A test that asserts the PDAL pipeline JSON is correct is fast and catches more than one that runs PDAL.
- Keep goldens small. A summary JSON is a few hundred bytes; a golden LAZ is a repository problem.
Common Errors
Tests pass locally and fail in CI. A fixture reading a file from a developer’s machine, or a session-scoped fixture mutated by one test.
assert got == expected on floats fails intermittently. Use tolerances; geospatial arithmetic is not exact.
A golden file is regenerated every time the library updates. The golden is a file hash. Compare a summary instead.
Hypothesis fails the first example on a deadline. PROJ grid loading. Set deadline=None.
Every test is marked unit and the suite takes minutes. No --strict-markers and no speed test.
The fixture is near coordinate zero and a precision bug shipped. Offset the fixture into a realistic range.
Tests interfere under pytest-xdist. A shared file path. Use tmp_path.
Frequently Asked Questions
Should I keep any real data in the repository?
A single small extract — a few thousand points, under a megabyte — is worth having for the integration layer, because real data has quirks synthetic data does not. Anything larger belongs outside the repository, fetched by CI.
How do I test a pipeline that calls PDAL or GDAL?
Assert the generated pipeline JSON in the unit layer, and run the tool once in the integration layer. That splits fast logic checks from slow tool checks, and the JSON assertion catches the more common bug.
Are property-based tests worth the setup?
For coordinate transforms, tile indexing and anything with a mathematical invariant, yes — they find boundary cases reliably. For data-shaped logic they tend to generate inputs that are not realistic, and a parameterised test is better.
Related Guides
- Running PDAL Pipelines in Docker — making the integration layer reproducible
- Validating Attribute Tables with Pandera — schema checks as a different kind of test
- Detecting Swapped Axis Order in Pipeline Data — the bug the round-trip property test catches