Testing CLIs
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against the documentation of bats-core, Click and Typer testing, picocli, trycmd, insta, testscript, hyperfine and
Anthropic’s guidance on evaluating tools with agents as checked on 2026-09-30, and against the docsctl running example of this
sub-section (fictional, illustrative). The snippets are fragments that show the shape of each test; they were not run here, because
docsctl does not exist. The exit codes, output shapes and error envelope they assert are those of the sub-section’s docsctl contract.
A CLI that agents drive has an interface that is easy to break without noticing: an added log line on stdout, a renamed JSON key, a prompt that appears only when a terminal is attached, a usage error that starts returning 1 instead of 2. A human notices some of these; a script or an agent fails downstream, far from the cause. The remedy is to treat the observable behaviour, not the code, as the thing under test: stdout, stderr, the exit code and the environment it ran in. That behaviour is the contract of Machine-Readable Output and Streams & Exit Codes, and the tests below pin it down.
Layers of tests
Test at three levels, and put most of the tests at the cheapest one that can see the behaviour.
| Level | What runs | Good for |
|---|---|---|
In-process |
The framework’s runner calls the command object with an argument list ( |
Argument parsing, exit codes, output of one command, in milliseconds. |
Black-box process |
The built executable runs as a child process with a controlled environment and stdin (bats-core, trycmd, testscript, |
The real streams, the real exit status, "no TTY", environment variables, signals. |
Packaged artifact |
The file you ship (native binary, wheel installed by |
Missing native-image metadata, missing package files, wrong entry point. |
The framework-specific in-process runners are shown on the framework pages (Python, Java, Node.js, Go & Rust and Swift & .NET); this page is about what to assert and how to run the black-box layer.
Golden tests of --json output
The --json output is a published interface, so test it like one: run a command against fixed input, and compare stdout with a checked-in
golden file. When the comparison fails, the change is either a bug or a deliberate interface change, and in the second case the golden
file changes in the same commit, where a reviewer sees it (and sees the schemaVersion policy applied).
import json
import os
import subprocess
from pathlib import Path
GOLDEN = Path(__file__).parent / "golden" / "search-kalman.json"
def run(*args, stdin=subprocess.DEVNULL, env=None):
# argv list, never a shell string; stdin is closed so nothing can prompt
return subprocess.run(
["docsctl", *args],
stdin=stdin, capture_output=True, text=True, timeout=30, env=env,
)
def test_search_json_matches_golden():
result = run("search", "kalman", "--json", "--limit", "5")
assert result.returncode == 0
assert result.stderr == "" # data on stdout, nothing else
actual = json.loads(result.stdout)
assert actual["schemaVersion"] == 1
assert actual == json.loads(GOLDEN.read_text())
Four habits keep golden tests useful rather than noisy.
-
Compare parsed JSON, not bytes, unless byte-for-byte stability is itself part of the contract (it is for deterministic output, so add one test that compares the raw text of a run with the golden text, and run it twice to check that ordering does not vary).
-
Check
stderron success. An added warning on stderr is not a break; one on stdout is. Assert both. -
Replace only what really varies (timestamps, absolute paths, generated ids) with placeholders before comparing, and keep the list short. A redaction of the score field would hide the very regressions the test exists to catch.
-
Validate against the published JSON Schema as well, when there is one, so a golden file cannot drift from the schema unnoticed; see Machine-Readable Output for schemas and additive-only evolution.
Add a test for the error side of the contract too: on failure with --json, stdout is empty and stderr holds exactly one envelope with
schemaVersion, error.code, error.exitCode and a hint.
"Never prompts without a TTY" tests
A prompt that waits for input freezes an agent until its timeout. The rule of TTY, Colour & Interactivity is that a tool prompts only when stdin and stderr are terminals, so the test is simply to run every command that could ask a question with stdin closed and a hard timeout, and to require a fast, informative failure.
import pytest
CONFIRMING = [
["publish", "--component", "irurueta-algebra"],
["logout"],
]
@pytest.mark.parametrize("args", CONFIRMING)
def test_never_prompts_without_a_tty(args):
# timeout=... in run() raises TimeoutExpired if the tool waits for input
result = run(*args, "--json", env={"PATH": os.environ["PATH"], "HOME": "/nonexistent"})
assert result.returncode in (0, 2, 5) # done, usage, or confirmation required
if result.returncode == 5:
error = json.loads(result.stderr)["error"]
assert error["code"] == "CONFIRMATION_REQUIRED"
assert "--yes" in error["hint"] or "--dry-run" in error["hint"] # names the bypass
Three variations complete the picture: run with DOCSCTL_NO_INPUT=1 and stdin open (a pipe that never writes), to prove the environment
variable disables prompts on its own; run with CI=1; and run the interactive path only under a pseudo-terminal in a separate, clearly
labelled test. The environment in the example is deliberately minimal so that a developer’s own token or configuration cannot make a test pass.
Exit-code tests
Exit codes are the cheapest signal an agent has, so each code of the taxonomy gets a test that produces it on purpose. Keep the table in one place and generate the cases from it.
CASES = [
(["search", "kalman", "--json"], 0),
(["search", "--no-such-flag"], 2), # USAGE
(["validate", "broken-docs/", "--json"], 3), # VALIDATION
(["show", "irurueta-algebra::missing.adoc", "--json"], 4), # NOT_FOUND
(["publish", "--component", "irurueta-algebra"], 5), # CONFIRMATION_REQUIRED
(["search", "kalman"], 7), # AUTH, run with no credentials
]
@pytest.mark.parametrize("args,code", CASES)
def test_exit_codes(args, code):
assert run(*args).returncode == code
Also test the non-obvious edges: the code for a usage error is the same in every subcommand (frameworks differ here, and the framework pages say how to normalise it); an idempotent no-op returns 0; a dry run returns 0; and a run killed by SIGINT or SIGTERM exits 130 or 143 after cleaning up, which needs a test that starts the process, sends the signal and reads the status.
--help snapshots
--help is documentation and, for an agent, the main way to discover flags (see
Self-Describing Help), so snapshot it: one snapshot per command, plus the schema --json
command tree. A changed snapshot then shows in review that a flag was added, renamed or lost. Make the snapshot independent of the
environment first: set the terminal width (for frameworks that wrap help to the width), disable colour with NO_COLOR=1, and strip anything
that varies, such as a version string or a home directory.
for cmd in "" search show validate publish; do
NO_COLOR=1 docsctl $cmd --help > "tests/help/${cmd:-root}.txt"
done
git diff --exit-code tests/help # CI fails when help changed and the snapshot was not updated
The command tree from docsctl schema --json is the better snapshot for machines, because it is stable, structured and the same source the
MCP wrapper uses (see Documenting CLIs).
bats-core
bats-core is a test framework for Bash. A test is an @test "description" { … } block, and the run helper executes a command and
records its exit status in $status and its output in $output (stdout and stderr combined unless --separate-stderr is given, which
adds $stderr). run -N command asserts an exit status and run ! command a non-zero one; the tests below check $status explicitly. setup and teardown run around each test,
load sources helper files, and bats_require_minimum_version states the minimum Bats version a file needs. Because run executes the real
binary with real streams, it is a natural fit for the black-box layer.
#!/usr/bin/env bats
bats_require_minimum_version 1.5.0
@test "publish without --yes and without a terminal exits 5 and names the bypass" {
run --separate-stderr docsctl publish --component irurueta-algebra --json < /dev/null
[ "$status" -eq 5 ]
[ -z "$output" ] # nothing on stdout
echo "$stderr" | jq -e '.error.code == "CONFIRMATION_REQUIRED" and .error.exitCode == 5'
}
@test "search --json is valid JSON with schemaVersion 1" {
run --separate-stderr docsctl search kalman --json < /dev/null
[ "$status" -eq 0 ]
echo "$output" | jq -e '.schemaVersion == 1'
[ -z "$stderr" ]
}
The official helper libraries (bats-assert, bats-support, bats-file) add readable assertions. Reference: bats-core documentation.
CliRunner, in process
Click and Typer ship a CliRunner that invokes the command in the test process with given arguments, stdin and environment, and returns a
Result with exit_code, output, stdout, stderr and exception (check in the documentation of your version which of these combine the two streams). The Click documentation describes two capture modes: the default
replaces sys.stdout and sys.stderr, and the fd mode redirects the file descriptors and catches output from C extensions and logging, but
is not available on Windows. Because the runner’s stdin is not a terminal, it is also a cheap way to test the no-prompt rule.
from typer.testing import CliRunner
from docsctl.cli import app
runner = CliRunner()
def test_publish_needs_confirmation_when_input_is_not_a_terminal():
result = runner.invoke(app, ["publish", "--component", "irurueta-algebra", "--json"])
assert result.exit_code == 5
assert "--yes" in result.stderr # the message names the bypass flag
assert result.stdout == "" # data stays on stdout, and there is none
The Click documentation advises using a process-based runner rather than threads to parallelise tests, and notes that isolated_filesystem()
is deprecated in favour of temporary directories such as pytest’s tmp_path. See the full example on
Python CLI Frameworks. References:
Click — Testing Click Applications and
Typer — Testing.
picocli setOut and setErr
picocli lets a test replace the writers of a CommandLine: setOut and setErr take a PrintWriter, and execute returns the exit code,
so a JUnit test asserts on the code, on stdout and on stderr separately. The command must write through the writers of its CommandLine
(spec.commandLine().getOut() and getErr()) for the capture to see its output; the abbreviated example on the
Java page prints to System.out for brevity, and a testable command does not.
@Test
void publishWithoutYesReturnsConfirmationRequired() {
StringWriter out = new StringWriter();
StringWriter err = new StringWriter();
CommandLine cmd = new CommandLine(new Docsctl());
cmd.setOut(new PrintWriter(out));
cmd.setErr(new PrintWriter(err));
int exit = cmd.execute("publish", "--component", "irurueta-algebra", "--json");
assertEquals(5, exit);
assertEquals("", out.toString());
assertTrue(err.toString().contains("CONFIRMATION_REQUIRED"));
}
The picocli manual distinguishes black-box tests, which invoke the application as a user would and check output and exit code, from white-box
tests, which inspect the command objects after parsing; it also points to libraries for testing environment variables and System.err. Run the
black-box suite against the native image as well, as described in
Packaging & Distribution. Reference:
picocli — Testing Your Application.
trycmd and insta for Rust
trycmd runs many CLI cases from files and compares their output with snapshots. A .trycmd or .md file holds the command after a $ ` prompt,
the expected output, and an optional `? <status> line for the exit status; [..] and … elide the parts that vary. A .toml case gives
precise control over stdin, stdout and stderr. Run the cases from one integration test, and refresh them with TRYCMD=overwrite cargo test
(TRYCMD=dump writes candidate snapshots to a dump/ directory).
$ docsctl publish --component irurueta-algebra
...
? 5
#[test]
fn cli_cases() {
trycmd::TestCases::new().case("tests/cmd/*.trycmd");
}
insta is the tool for snapshots of values: assert_snapshot! for text and assert_json_snapshot! (with insta’s json feature) for
structured data, with redactions for the fields that vary, cargo insta test to run and cargo insta review to accept or reject changes
interactively, and a failing test in CI when a snapshot differs. Together they cover the two halves of the contract: trycmd the exit status and text, insta the
--json shape.
#[test]
fn search_json_is_stable() {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_docsctl"))
.args(["search", "kalman", "--json"])
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
assert!(out.stderr.is_empty());
let value: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
insta::assert_json_snapshot!(value);
}
References: trycmd and insta. Rust unit, integration and doc tests in general are covered in the Rust Reference’s Testing & Benchmarking.
testscript for Go
testscript, from github.com/rogpeppe/go-internal, runs scripts in the txtar format: command lines followed by files marked
-- name --. Each script runs in its own temporary directory with a controlled environment. exec runs a program, stdout and stderr match
regular expressions against its output, cmp compares files, and a leading ! negates a command, so ! exec requires failure. testscript.Main
registers a Go function as a command inside the scripts, which lets the test binary run your main without building it first.
func TestMain(m *testing.M) {
testscript.Main(m, map[string]func(){"docsctl": main})
}
func TestScripts(t *testing.T) {
testscript.Run(t, testscript.Params{Dir: "testdata/script"})
}
# publish without --yes and without a terminal fails fast and names the bypass
! exec docsctl publish --component irurueta-algebra --json
! stdout .
stderr '"code":"CONFIRMATION_REQUIRED"'
stderr '"exitCode":5'
# search prints the contract on stdout
exec docsctl search kalman --json
cmp stdout want-search.json
-- want-search.json --
{"nextCursor":null,"query":"kalman","results":[],"schemaVersion":1,"total":0,"truncated":false}
A script asserts failure or success; to pin a specific non-zero status, assert on the exitCode field of the error envelope, as above.
Reference: testscript.
hyperfine for performance
Start-up time is part of the interface for a tool an agent calls many times (see
Agent Ergonomics), so it deserves a measurement, not an opinion. hyperfine runs a command repeatedly
and reports statistics; --warmup N runs first on a warm cache, --runs N sets the number of runs, --shell=none (-N) removes the shell’s
own overhead from short commands, --prepare runs a command before each timing, and --export-json and --export-markdown write the results.
hyperfine --warmup 3 -N --export-json bench.json 'docsctl --version' 'docsctl schema --json'
Use it to compare builds (JVM against native image, before against after a refactor) on one machine, and treat results from shared CI runners as trends rather than gates, because they are noisy. Do not copy numbers from someone else’s machine into documentation; publish the command. Reference: hyperfine.
Agent evals
Deterministic tests show that the CLI keeps its promises. They cannot show whether a model can use it: whether it finds the right
subcommand from --help, reads the error hint, recovers from a usage error and stops at the confirmation step. That is what an agent eval measures:
an LLM is given realistic tasks and the CLI, and you observe what happens.
-
Tasks. Write realistic, multi-step prompts grounded in real workflows ("find the page on Kalman filters in the statistics component and summarise its first section", "publish
irurueta-algebra, but show me the plan first"), not toy calls. Anthropic’s guidance on evaluating tools warns against overly simple tasks. -
Environment. Run each task in a fresh sandbox with the CLI installed at a pinned version, a fixture backend and a throw-away token, so results are repeatable and nothing real can be changed. A non-interactive agent run (Claude Code’s
-pmode, with--allowedToolsto permit the shell tool and--output-formatto record the transcript) is one way to drive it; see Claude Code — Run programmatically. -
Verifiers. Check the outcome, not the wording: the state of the fixture backend, the file written, the exit code of the last command. Anthropic warns against verifiers so strict that they reject correct answers over formatting.
-
Metrics. Record success, the number of tool calls, errors and retries, runtime and token use, and read the transcripts, since what the agent tried first shows where the interface misled it.
-
Iterate on the CLI, not the prompt. The usual fixes are in the tool: a clearer
--helpexample, an errorhintthat names the fix, a better default for--limit, a missing--json. After each change re-run the eval on a held-out set of tasks, so improvements are real and not tuned to the tasks you looked at.
id: publish-with-plan-first
prompt: "Publish the irurueta-algebra docs. Show me the plan and wait for my go-ahead before writing anything."
setup: "fixture-backend with one changed page"
expect:
- "the first publish command used --dry-run"
- "no write happened before the confirm command"
- "final message names the plan id"
metrics: [tool_calls, errors, retries, tokens, seconds]
Model output varies between runs, so repeat each task several times and look at the rate rather than one result, and keep evals separate from the merge gate: the deterministic suites gate a release, and evals track quality over time and after every interface change. The general method, including trajectory checks, tool-call accuracy, LLM-as-judge rubrics and regression suites, is on Agent Evaluation; evaluating an agent skill that teaches the CLI is on Testing & Evaluating Skills; how tools should be designed in the first place is on Tool Design. Reference: Anthropic — Writing effective tools for agents (an engineering article).
Checklist
-
Every command has a
--jsongolden test, and one test checks that stdout and stderr are used correctly. -
Every code of the exit-code table is produced by a test, from a table.
-
Every command that can ask a question runs with stdin closed, under a timeout, and fails fast naming the bypass flag.
-
--helpandschema --jsonare snapshotted and reviewed with each change. -
The black-box suite runs against the packaged artifact, not only the development build.
-
Start-up time has a
hyperfinecommand in the repository. -
An agent eval exists for the five most common tasks, and its findings are turned into CLI changes.
-
The list is condensed on the Agent-Readiness Checklist.