Store the rendered --help output and each error message as a checked-in text file, normalise the parts that legitimately vary β terminal width, temporary paths, version numbers β and assert equality. Help text is the one artefact where a stored expectation is right, because the exact wording is the contract users read. It builds on the Testing Geospatial CLI Tools guide, part of the broader CLI Architecture & Design Patterns reference.
Prerequisites
- Python 3.10 or later
pip install pytest typer(orclick)- A CLI with at least one subcommand group, as described in CLI Subcommand Organization
What Varies, and What Must Not
A snapshot is only useful if it fails when the interface changes and passes otherwise. Four things in a rendered help page vary for reasons that have nothing to do with your code, and each needs normalising before comparison.
Everything else β option names, argument order, the wording of a validation failure, the exit code shown in the epilogue β is exactly what the snapshot exists to pin.
Complete Working Implementation
# tests/snapshots.py
import os
import re
from pathlib import Path
SNAPSHOT_DIR = Path(__file__).parent / "snapshots"
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
def normalise(text: str, tmp_path: Path | None = None, version: str | None = None) -> str:
out = _ANSI.sub("", text)
if tmp_path is not None:
out = out.replace(str(tmp_path), "<TMP>")
if version is not None:
out = out.replace(version, "<VERSION>")
out = re.sub(r"[ \t]+$", "", out, flags=re.M) # trailing whitespace
return out.rstrip("\n") + "\n"
def assert_snapshot(name: str, actual: str) -> None:
"""Compare against tests/snapshots/<name>.txt.
Set UPDATE_SNAPSHOTS=1 to rewrite them after an intentional change.
"""
path = SNAPSHOT_DIR / f"{name}.txt"
if os.environ.get("UPDATE_SNAPSHOTS"):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(actual, encoding="utf-8")
return
assert path.exists(), (
f"no snapshot at {path}; run UPDATE_SNAPSHOTS=1 pytest to create it"
)
expected = path.read_text(encoding="utf-8")
assert actual == expected, (
f"{name} changed.\n"
f"If the change is intended: UPDATE_SNAPSHOTS=1 pytest -k {name}"
)
And the tests themselves stay short:
# tests/test_help_snapshots.py
import pytest
from typer.testing import CliRunner
from gistools import __version__
from gistools.cli import app
from tests.snapshots import assert_snapshot, normalise
runner = CliRunner()
@pytest.fixture(autouse=True)
def fixed_width(monkeypatch):
monkeypatch.setenv("COLUMNS", "100")
monkeypatch.setenv("NO_COLOR", "1")
@pytest.mark.parametrize("argv,name", [
([], "help_root"),
(["raster", "--help"], "help_raster"),
(["raster", "warp", "--help"], "help_raster_warp"),
(["vector", "--help"], "help_vector"),
])
def test_help_text(argv, name):
result = runner.invoke(app, argv + (["--help"] if not argv else []))
assert result.exit_code == 0
assert_snapshot(name, normalise(result.output, version=__version__))
def test_invalid_crs_message(raster_path, tmp_path):
result = runner.invoke(app, ["raster", "warp", str(raster_path),
str(tmp_path / "out.tif"), "--dst-crs", "EPSG:4362"])
assert result.exit_code == 2
assert_snapshot("error_invalid_crs",
normalise(result.output, tmp_path=tmp_path))
Snapshots suit a narrow band of output, and knowing where that band starts and stops prevents the technique spreading into places it makes tests worse.
The third row is the one people get wrong most often. Snapshotting a JSON payload works until someone adds a key, at which point every snapshot in the file needs regenerating and the diff is too large to review β which is precisely when a real regression slips through unnoticed.
Keeping Snapshots Reviewable
A snapshot suite is only as good as the diffs it produces, and three habits keep those diffs readable a year later.
Split by command rather than by test file. One snapshot per help page means renaming an option in the raster group produces a one-file diff, and a reviewer can see the whole before-and-after without scrolling. A single combined snapshot of every help page produces a diff whose size is unrelated to the size of the change.
Keep the files free of anything a human would not write. Trailing whitespace, in particular, is
invisible in review and produces diffs that appear empty; the normalise helper above strips it for
exactly that reason. The same argument applies to a trailing newline: normalising to exactly one
means an editor that adds or removes it does not create a spurious change.
Finally, treat a snapshot change as an interface change in the commit message. Update snapshots is not a useful log entry; rename --target-crs to --dst-crs is, and the snapshot diff below it is the evidence. Six months later, when someone is trying to work out when a flag changed name, that commit is the answer.
There is one more benefit that emerges once the snapshots exist: they document the CLI without
anyone maintaining documentation. A new contributor reading tests/snapshots/help_raster.txt gets
the current, accurate list of what the raster group does, generated by the code rather than written
alongside it. That is not a reason to snapshot on its own, but it is a pleasant consequence, and it
is why keeping the files as plain text rather than a serialised blob repays itself.
Step Annotations
NO_COLORrather than a runner flag. Setting the environment variable exercises the same detection path production uses, described in Detecting Non-TTY Output and Disabling Rich Color. A runner-level override would bypass it and leave that logic untested.COLUMNS=100, not 80. Eighty characters wraps most geospatial option names badly, producing snapshots that are hard to read in review. A hundred is wide enough to keep one option per line and narrow enough to fit a diff view.- Parametrising over commands, not writing one test each. Adding a new subcommand becomes a one-line change plus one
UPDATE_SNAPSHOTSrun, which is low enough friction that people actually do it. - The failure message contains the fix command. A maintainer who has just renamed an option should not have to search the repository to find out how to accept the change.
- Error messages are snapshotted too, and are the more valuable half. Help text changes when someone edits a docstring; error wording changes when someone alters behaviour, which is exactly when a reviewer should be looking.
One Named Gotcha: An Accepted Snapshot Hides a Real Regression
UPDATE_SNAPSHOTS=1 is a loaded gun. Run it while several tests are failing and you commit whatever the code currently does, including the bug you were about to find. The safeguard is procedural rather than technical, and it is worth writing into the contributing guide: update snapshots in their own commit, with nothing else in it, so a reviewer sees the diff of the interface on its own.
It also helps to make the snapshot directory conspicuous in review. Keeping the files as plain .txt under a single tests/snapshots/ directory means a diff shows the before and after text inline, rather than a binary blob or an unreadable single-line JSON string.
The workflow around a snapshot change has one shape that keeps reviews honest, and it is worth making explicit because the tempting shortcut is right there in the failure message.
That CI check is three lines of shell against the diff of changed paths, and it is the only mechanical defence against the lower path. Everything else is a habit.
Verification
Confirm the snapshots are load-bearing by making a harmless-looking change and checking that exactly one fails:
# rename an option's help text by one word, then:
pytest tests/test_help_snapshots.py -q
# expect: 1 failed, the rest passed, and the diff shows the changed line
Then confirm the normalisation is doing its job by running the same suite twice from different directories and at a different terminal width:
(cd /tmp && COLUMNS=40 pytest tests/test_help_snapshots.py -q)
Both runs should pass. If the second fails, something environment-dependent is leaking into the captured output and needs adding to normalise.
Snapshots as a Deprecation Trail
Because a snapshot records exactly what users saw at a point in time, the fileβs history becomes a usable record of how the interface has changed. That turns out to be worth more than it sounds when a support question arrives about behaviour someone remembers from an older release.
The practical use is checking a deprecation actually reached users before the removal did. A flag that was deprecated in one release and removed in the next should show a deprecation notice in the intermediate snapshot; if it does not, the notice was never rendered and the removal will be a surprise. Grepping the snapshot directory at release time β for the word deprecated, and for the names of anything scheduled for removal β is a cheap check that the warnings people planned actually shipped.
The same record helps with the opposite question. When a user reports that a command used to accept some option, the snapshot history answers it in seconds and without guesswork. Compare that with reconstructing the parser from an old tag, which requires checking out the tag, installing its dependencies, and running it.
None of this justifies snapshotting output you would not otherwise pin. It is a by-product of
storing help text as plain files in version control rather than asserting on substrings, and it is
one more reason to prefer the whole page over assert "--dst-crs" in result.output. A substring
assertion tells you the flag exists; it tells you nothing about how it was described, which group it
appeared under, or whether the surrounding text still made sense after a refactor moved it.
Related
- Testing Geospatial CLI Tools β where snapshot tests sit relative to the rest of the suite.
- Asserting Raster Outputs Without Golden Files β the opposite case, where a stored expectation is the wrong tool.