To test a Click-based geospatial CLI command, drive it with click.testing.CliRunner: call runner.invoke(cmd, [...]) inside runner.isolated_filesystem(), then assert on result.exit_code, result.output, and result.stderr rather than calling the command function directly. Feeding it a small in-memory rasterio fixture lets you exercise both the success and CRS-mismatch paths without writing files. This page is part of the Click vs Typer for Geospatial Workflows guide.
Prerequisites
- Python 3.10 or later
pip install "click>=8.1" pytest rasterio numpy pyproj- GDAL 3.4+ (rasterio ships manylinux wheels, so no separate system GDAL is required for the raster fixture below)
If you are still choosing a framework, the parent Click vs Typer for Geospatial Workflows comparison covers the trade-offs; Typer users get an equivalent harness through the Argument Parsing with Typer guide, since typer.testing.CliRunner wraps this same Click object.
How CliRunner Isolates a Command
CliRunner never spawns a subprocess. It calls your command’s callback in-process, redirects the standard streams into buffers, and traps the SystemExit that Click raises so it can record the numeric exit code on a Result object. The diagram below traces one runner.invoke() call from fixture to assertion.
The Command Under Test
Assume a small conversion tool that reads a raster, checks its CRS against a requested target, and reprojects it. The command exits 0 on success, 2 on a usage error (Click handles that automatically), and the domain code 10 when the source CRS cannot be reconciled with the target.
# gistools/reproject.py
from pathlib import Path
import click
import rasterio
from rasterio.warp import calculate_default_transform, reproject, Resampling
from pyproj import CRS
@click.command()
@click.argument("src", type=click.Path(exists=True, path_type=Path))
@click.argument("dst", type=click.Path(path_type=Path))
@click.option("--target", "target_epsg", required=True, help="Target CRS, e.g. EPSG:3857")
@click.option("--allow-geographic/--no-allow-geographic", default=False)
def reproject_cmd(src: Path, dst: Path, target_epsg: str, allow_geographic: bool) -> None:
"""Reproject SRC to DST in the --target CRS."""
target = CRS.from_user_input(target_epsg)
with rasterio.open(src) as ds:
source = ds.crs
if source is None:
click.echo(f"error: {src} has no CRS", err=True)
raise click.exceptions.Exit(10)
# Domain rule: refuse a geographic->projected warp unless explicitly allowed.
if source.is_geographic and target.is_projected and not allow_geographic:
click.echo(
f"error: refusing geographic {source.to_epsg()} to projected "
f"{target.to_epsg()} without --allow-geographic",
err=True,
)
raise click.exceptions.Exit(10)
transform, width, height = calculate_default_transform(
ds.crs, target, ds.width, ds.height, *ds.bounds
)
profile = ds.profile.copy()
profile.update(crs=target, transform=transform, width=width, height=height)
with rasterio.open(dst, "w", **profile) as out:
for band in range(1, ds.count + 1):
reproject(
source=rasterio.band(ds, band),
destination=rasterio.band(out, band),
resampling=Resampling.nearest,
)
click.echo(f"reprojected {src.name} to EPSG:{target.to_epsg()}")
The pytest Module
This is the centrepiece: a fixture that builds an EPSG:4326 raster in a rasterio MemoryFile, plus two tests that drive the command with CliRunner and assert on the Result.
# tests/test_reproject_cli.py
import numpy as np
import pytest
import rasterio
from rasterio.io import MemoryFile
from rasterio.transform import from_bounds
from click.testing import CliRunner
from gistools.reproject import reproject_cmd
@pytest.fixture
def wgs84_raster_bytes() -> bytes:
"""A 4x4 single-band EPSG:4326 GeoTIFF materialised entirely in memory."""
data = np.arange(16, dtype="uint8").reshape(1, 4, 4)
transform = from_bounds(west=-1.0, south=-1.0, east=1.0, north=1.0, width=4, height=4)
with MemoryFile() as mem:
with mem.open(
driver="GTiff",
height=4,
width=4,
count=1,
dtype="uint8",
crs="EPSG:4326",
transform=transform,
) as ds:
ds.write(data)
return mem.read() # raw GeoTIFF bytes, no file on disk yet
@pytest.fixture
def runner() -> CliRunner:
# mix_stderr=False keeps result.output (stdout) separate from result.stderr.
# On Click >= 8.2 the streams are always split and this kwarg is removed.
return CliRunner(mix_stderr=False)
def test_reproject_success(runner, wgs84_raster_bytes):
with runner.isolated_filesystem():
# Only write to disk because the command opens a *filename*, not a dataset.
with open("in.tif", "wb") as fh:
fh.write(wgs84_raster_bytes)
result = runner.invoke(
reproject_cmd,
["in.tif", "out.tif", "--target", "EPSG:3857", "--allow-geographic"],
)
assert result.exit_code == 0, result.output
assert "reprojected in.tif to EPSG:3857" in result.output
with rasterio.open("out.tif") as out:
assert out.crs.to_epsg() == 3857
def test_reproject_crs_mismatch(runner, wgs84_raster_bytes):
with runner.isolated_filesystem():
with open("in.tif", "wb") as fh:
fh.write(wgs84_raster_bytes)
# No --allow-geographic: the geographic source triggers domain code 10.
result = runner.invoke(
reproject_cmd,
["in.tif", "out.tif", "--target", "EPSG:3857"],
)
assert result.exit_code == 10
assert "refusing geographic 4326 to projected 3857" in result.stderr
def test_missing_target_is_usage_error(runner, wgs84_raster_bytes):
with runner.isolated_filesystem():
with open("in.tif", "wb") as fh:
fh.write(wgs84_raster_bytes)
result = runner.invoke(reproject_cmd, ["in.tif", "out.tif"])
# Click emits its own exit code 2 for a missing required option.
assert result.exit_code == 2
assert "Missing option" in result.stderr
Step Annotations
-
MemoryFilefixture returns bytes, not a path — building the raster through GDAL’s/vsimem/virtual filesystem means the test never allocates a temp file. Returningmem.read()hands the caller raw GeoTIFF bytes it can write wherever the command expects them, which keeps the fixture reusable across tests that need different filenames. -
from_bounds(...)for a real affine transform — a raster without a valid transform makescalculate_default_transformraise, so the fixture pins a genuine 2x2 degree footprint. Using real coordinates keeps the test exercising the same code path as production data. -
CliRunner(mix_stderr=False)— this splitsresult.output(stdout) fromresult.stderr. Without it, theerr=Truemessages from the command land inresult.outputand your stderr assertions silently pass against merged text. On Click 8.2+ the split is the default and the kwarg is gone. -
runner.isolated_filesystem()— this context managerchdirs into a fresh temporary directory and cleans it up afterwards, so theout.tifwritten by the command cannot collide with a real file or leak between tests. It is the reason the tests can use bare relative filenames. -
assert result.exit_code == 0, result.output— attachingresult.outputas the assertion message means a failing run prints the captured stdout, turning an opaqueAssertionError: 0 != 1into an actionable traceback from inside the command. -
raise click.exceptions.Exit(10)— Click converts this into aSystemExit(10)thatCliRunnertraps and records asresult.exit_code. Using the domain code10for a CRS mismatch keeps the exit-code contract consistent with the rest of the toolchain, distinct from Click’s own2for usage errors.
Named Gotcha: Wrapping runner.invoke() in try/except SystemExit
The most common mistake is asserting on a caught SystemExit instead of on result.exit_code. Because the command calls raise click.exceptions.Exit(10), developers reach for:
# WRONG — this except block never runs.
try:
runner.invoke(reproject_cmd, ["in.tif", "out.tif", "--target", "EPSG:3857"])
except SystemExit as exc:
assert exc.code == 10 # unreachable
CliRunner.invoke() already catches SystemExit internally and stores the code on the returned object. The except block is dead code, the assertion never executes, and the test passes no matter what the command does. The fix is to drop the try/except entirely and read result.exit_code, exactly as the working tests above do. If a genuinely unexpected exception escaped the command, inspect result.exception and result.exc_info — Click stores the original traceback there rather than re-raising it.
Verification
Run the module and confirm all three paths are green:
pytest tests/test_reproject_cli.py -v
# test_reproject_success PASSED
# test_reproject_crs_mismatch PASSED
# test_missing_target_is_usage_error PASSED
To prove the failure test is not a false positive, temporarily flip the assertion to result.exit_code == 0 and confirm it fails — a test that cannot fail is not testing anything. These same tests belong in the pipeline described in the Packaging & CI/CD guide, where they run against the built wheel on every push.
FAQ
Does CliRunner capture logging output as well as stdout?
No. By default CliRunner captures click.echo() and anything written to sys.stdout, but the logging module writes to its own handlers, which are not redirected. Attach a caplog fixture or route diagnostics through click.echo(err=True) if you want to assert on them via result.output.
How do I separate stdout from stderr in a CliRunner result?
On Click 8.2 and later, CliRunner keeps the two streams apart automatically and result.output holds only stdout while result.stderr holds stderr. On older releases pass mix_stderr=False to the CliRunner constructor, otherwise both streams are merged into result.output.
Should I assert on result.exit_code or catch SystemExit?
Always assert on result.exit_code. CliRunner traps the SystemExit raised by Click internally and records the numeric code on the result object, so a try/except SystemExit around runner.invoke() never fires and would mask the real assertion.
Why use a rasterio MemoryFile instead of a temporary GeoTIFF on disk?
A MemoryFile builds the raster in RAM through GDAL’s /vsimem/ virtual filesystem, so the test runs faster, leaves no artifacts, and works in read-only CI containers. Write the bytes to a path inside isolated_filesystem() only when the command opens a filename rather than a dataset object.
Related
- Click vs Typer for Geospatial Workflows — parent guide comparing both frameworks for CRS-aware command design and error handling
- Migrating a Click Geospatial CLI to Typer — how the same test harness carries over once you move commands to Typer