Testing with CliRunner
Click's CliRunner gives you an isolated harness for invoking CLI commands without touching stdin, stdout, or sys.exit().
- Explain what Click's CliRunner does and what it isolates from the real environment
- Describe the attributes on the result object (exit_code, output, exception)
- Understand mix_stderr=False for testing stdout and stderr separately
Testing a command-line program the naive way means shelling out, capturing
terminal output, and inspecting it as a string. That is slow, fragile, and
impossible to run in parallel. Click's CliRunner solves this by invoking your
commands inside the current Python process — no subprocess, no real terminal,
no sys.exit().
What CliRunner does
CliRunner provides a method, invoke(), that:
- Sets up a fake stdin, stdout, and stderr in memory.
- Calls your Click command function directly.
- Catches
SystemExit(the call that would normally terminate the process). - Returns a
Resultobject with everything the command produced.
You get a clean, isolated execution every time. Parallel tests cannot interfere with each other's I/O, and there is no subprocess overhead.
The Result object
from click.testing import CliRunner
from myapp import cli
runner = CliRunner()
result = runner.invoke(cli, ["--name", "Alice"])| Attribute | Type | What it contains |
|---|---|---|
result.exit_code | int | The exit code (0 for success, non-zero for error) |
result.output | str | Everything written to stdout (and stderr, by default) |
result.exception | Exception or None | Any unhandled exception raised during the command |
A well-written test asserts on all three when they are relevant. An exit code of 0 with unexpected output is a bug; an exit code of 1 with no exception is a handled error; an exit code of 1 with an exception is a crash.
mix_stderr=False
By default, CliRunner merges stderr into stdout, so result.output contains
both. To assert on them independently, pass mix_stderr=False when creating
the runner:
runner = CliRunner(mix_stderr=False)
result = runner.invoke(cli, ["bad-arg"])
assert result.exit_code == 1
assert "error" in result.output.lower() # stdout
assert "traceback" not in result.stderr # nothing leaked to stderrThis is important for tools that write clean results to stdout (for piping) and diagnostic messages to stderr. A test that checks only the combined output cannot tell whether an error message went to the right stream.
Why this matters for testing CLIs
Standard unittest and pytest testing patterns test functions. A CLI
command is a function — but one that communicates through stdin/stdout/exit
codes rather than return values. CliRunner bridges that gap: it lets you
test CLI commands with the same patterns you use for any other function.
The alternative — testing by spawning subprocesses — works but is an order of magnitude slower and makes it impossible to use mocking, monkeypatching, or temporary directories managed by pytest.
CliRunner also provides a catch_exceptions=False option. By default, it
catches all exceptions and stores them in result.exception so the test can
inspect them. With catch_exceptions=False, exceptions propagate normally —
useful when you want pytest's full exception traceback rather than a silent
failure.
Where to go next
Next: CliRunner in practice — writing real pytest assertions against a Click command's happy path, error path, and output content.