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.

Table 1. Where each kind of CLI test runs
Level What runs Good for

In-process

The framework’s runner calls the command object with an argument list (CliRunner, picocli execute, commander’s exitOverride).

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, subprocess).

The real streams, the real exit status, "no TTY", environment variables, signals.

Packaged artifact

The file you ship (native binary, wheel installed by pipx, container image) runs the black-box suite.

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.

flowchart LR U["In-process tests: parsing, exit codes"] --> K["Black-box suite on the built executable"] K --> P["Same suite on the packaged artifact"] P --> E["Agent evals on a schedule"] E --> F["Findings change the CLI, then tests are added"] F --> U
Figure 1. Test levels and the release pipeline

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).

Golden test with pytest and subprocess (tests/test_search_contract.py)
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 stderr on 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.

Every confirming command fails fast without a terminal
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.

A table-driven exit-code test
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.

Snapshot one help page per command
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.

tests/cli.bats
#!/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.

A no-prompt test with Typer’s CliRunner
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.

A picocli contract test (JUnit 5)
@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).

tests/cmd/publish-needs-confirmation.trycmd
$ docsctl publish --component irurueta-algebra
...
? 5
tests/cli.rs
#[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.

A --json golden test with insta
#[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.

main_test.go
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"})
}
testdata/script/publish-confirmation.txtar
# 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.

Measuring the cheapest and a typical command
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 -p mode, with --allowedTools to permit the shell tool and --output-format to 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 --help example, an error hint that 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.

An eval case (illustrative format)
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 --json golden 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.

  • --help and schema --json are 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 hyperfine command 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.