Code of the Day
IntermediateTesting CLIs

Testing with CliRunner

Click's CliRunner gives you an isolated harness for invoking CLI commands without touching stdin, stdout, or sys.exit().

UtilitiesIntermediate6 min read
By the end of this lesson you will be able to:
  • 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:

  1. Sets up a fake stdin, stdout, and stderr in memory.
  2. Calls your Click command function directly.
  3. Catches SystemExit (the call that would normally terminate the process).
  4. Returns a Result object 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"])
AttributeTypeWhat it contains
result.exit_codeintThe exit code (0 for success, non-zero for error)
result.outputstrEverything written to stdout (and stderr, by default)
result.exceptionException or NoneAny 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 stderr

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

Finished reading? Mark it complete to track your progress.

On this page