CLI testing¶
CliRunner runs a Click CLI in-process: the command object is imported, its callback executes inside the test process, and its streams are captured in memory. It is a drop-in replacement for click.testing.CliRunner, and what the runner and invoke fixtures hand to a test.
Tip
For the black-box counterpart, which spawns the command as a subprocess and never imports it, see test suites. Both render a run with the same execution trace.
Invoking a command¶
Take a CLI reporting the temperature of each city it is given:
from click_extra import argument, command, echo, option
@command
@option("--unit", default="celsius")
@argument("cities", nargs=-1)
def forecast(unit, cities):
"""Print the temperature of each city."""
if not cities:
echo("No city to report on.", err=True)
for city in cities:
echo(f"{city}: 21 {unit}")
Hand it to a runner. The command is the first positional argument, and everything after it is a command-line argument:
from click_extra.testing import CliRunner
runner = CliRunner()
result = runner.invoke(forecast, "--unit", "fahrenheit", "Oslo", "Lisbon")
assert result.exit_code == 0
assert result.stdout == "Oslo: 21 fahrenheit\nLisbon: 21 fahrenheit\n"
$ forecast --unit fahrenheit Oslo Lisbon
<output> stream:
Oslo: 21 fahrenheit
Lisbon: 21 fahrenheit
<stdout> stream:
Oslo: 21 fahrenheit
Lisbon: 21 fahrenheit
<exit_code>: 0
Passing the arguments as a list under an args keyword, the way vanilla Click expects them, works too and can be mixed with the positional form.
Every invocation prints the execution trace reproduced above, pass or fail. It is what a failing test shows in its captured output, so a broken assertion comes with the session that produced it instead of a bare exit code.
Composing arguments¶
Positional arguments are flattened, so a nested structure of lists and tuples is spelled out as-is. None values are dropped, and every remaining item is cast to a string, which lets a Path travel unconverted:
from pathlib import Path
result = runner.invoke(forecast, ["Oslo", None, ["Lisbon", (Path("Kyoto"),)]])
assert result.exit_code == 0
assert result.stdout == "Oslo: 21 celsius\nLisbon: 21 celsius\nKyoto: 21 celsius\n"
$ forecast Oslo Lisbon Kyoto
<output> stream:
Oslo: 21 celsius
Lisbon: 21 celsius
Kyoto: 21 celsius
<stdout> stream:
Oslo: 21 celsius
Lisbon: 21 celsius
Kyoto: 21 celsius
<exit_code>: 0
That is args_cleanup() doing the work, and it is what makes a parametrized test readable: each case contributes its own fragment of the command line, and an optional fragment collapses to None instead of forcing the test to assemble the list itself.
test_forecast.py¶import pytest
@pytest.mark.parametrize("unit_flag", (None, ("--unit", "fahrenheit")))
@pytest.mark.parametrize("cities", (("Oslo",), ("Oslo", "Lisbon")))
def test_forecast(invoke, unit_flag, cities):
result = invoke(forecast, unit_flag, cities)
assert result.exit_code == 0
Reading the result¶
Result carries the exit code, the captured streams and whatever exception escaped the callback:
Attribute |
Content |
|---|---|
|
Process exit status. |
|
Standard output alone. |
|
Standard error alone. |
|
Both streams, interleaved in the order they were written. |
|
The exception that escaped the callback, if any. |
|
Its full traceback, as a string, or |
The two streams stay addressable separately, which is how a test pins a diagnostic to the stream it belongs on:
result = runner.invoke(forecast)
assert result.exit_code == 0
assert result.stdout == ""
assert result.stderr == "No city to report on.\n"
$ forecast
<output> stream:
No city to report on.
<stderr> stream:
No city to report on.
<exit_code>: 0
When the callback raises, the runner catches the exception, sets a non-zero exit_code, and prints the traceback below the trace. formatted_exception keeps that traceback available, and repr(result) embeds it, so an assert result.exit_code == 0 that fails reports where the CLI died rather than which exception class it was. Pass catch_exceptions=False to let the exception propagate into the test instead.
Colors¶
ANSI codes survive the trip only when asked for. color accepts one more value than Click’s:
|
Captured streams |
|
|---|---|---|
|
Stripped |
|
|
Stripped |
|
|
Kept |
|
|
Kept |
|
color=True keeps the codes in the captured output, but the invoked CLI still sees an uncolored context and takes its uncolored branch. color="forced" covers both: it keeps the codes and initializes Context.color to True, which vanilla Click cannot express because the two meanings collide on one parameter name (pallets/click#2110). Click Extra routes the second one through a patched main() call, so any other Context keyword named like an invoke() parameter gets through as well.
import click
from click_extra import style
@click.command
@click.pass_context
def report(ctx):
click.echo(style("Sunny", fg="yellow") + " in Lisbon.")
click.echo(f"Context.color is {ctx.color!r}")
result = runner.invoke(report, color="forced")
assert "\x1b[33mSunny\x1b[0m in Lisbon.\n" in result.stdout
assert "Context.color is True\n" in result.stdout
$ report
<output> stream:
Sunny in Lisbon.
Context.color is True
<stdout> stream:
Sunny in Lisbon.
Context.color is True
<exit_code>: 0
Setting color=False goes one step further than Click’s stripping and scrubs the result bytes, so a CLI writing raw escape sequences past Click’s own machinery still yields clean text. To keep the codes on every invocation of a suite without touching each call, set the force_color class attribute on CliRunner: it pins every run to the color=True row above.
Note
The command above is a plain Click one on purpose: nothing but the runner decides its colors. A Click Extra command owns that decision itself, through its --color/--no-color options and the NO_COLOR and FORCE_COLOR environment variables, which have the last word over whatever the runner was told.
Rendering a run¶
render_cli_run() produces the trace on its own, from either an in-process click.testing.Result or a subprocess.CompletedProcess. Both are normalized into a StreamView first, so a black-box run and an in-process one read identically:
import subprocess
import sys
from click_extra.testing import render_cli_run
process = subprocess.run(
(sys.executable, "-c", "print('Lisbon: 21 celsius')"),
capture_output=True,
text=True,
encoding="utf-8",
)
print(render_cli_run(("forecast", "Lisbon"), process, env={"FORECAST_UNIT": "celsius"}))
$ FORECAST_UNIT=celsius forecast Lisbon
<stdout> stream:
Lisbon: 21 celsius
<exit_code>: 0
The first line is the command as the user would have typed it, environment assignments included. Each captured stream then gets its own labelled, indented block, and the exit code closes the trace. A stream that captured nothing is left out entirely: a subprocess run with stderr=STDOUT reports a single interleaved <output> stream, while separate streams give the <stdout> and <stderr> pair.
Matching output against a regex¶
Comparing a wall of terminal output to an expected string reports the whole wall on failure, and leaves the reader to find the offending character. regex_fullmatch_line_by_line() matches the pattern one line at a time and reports the first line that disagrees:
from click_extra.testing import RegexLineMismatch, regex_fullmatch_line_by_line
try:
regex_fullmatch_line_by_line(
r"Lisbon: \d+ celsius\nOslo: \d+ fahrenheit\n",
"Lisbon: 21 celsius\nOslo: 9 celsius\n",
)
except RegexLineMismatch as ex:
print(ex)
Line #2 does not match.
Regex : 'Oslo: \\d+ fahrenheit\\n'
Output: 'Oslo: 9 celsius\n'
The pattern is split on its \n tokens, so it is written as one raw string mirroring the expected output, not as a list of per-line patterns. A pattern matching in full short-circuits the loop, and only a mismatch pays for the line-by-line pass.
The reported pattern goes through unescape_regex(), the inverse of re.escape(), which strips the backslashes an escaped literal is littered with so the two sides of the report line up visually:
from click_extra.testing import unescape_regex
assert unescape_regex(r"Usage: forecast \[OPTIONS\] \[CITIES\]\.\.\.") == (
"Usage: forecast [OPTIONS] [CITIES]..."
)
print(unescape_regex(r"Usage: forecast \[OPTIONS\] \[CITIES\]\.\.\."))
Usage: forecast [OPTIONS] [CITIES]...
The assert_output_regex fixture wraps that comparison into an assertion carrying a character-level diff, and the ready-made patterns shipped with it cover the help screen and debug logs Click Extra adds to every CLI.
click_extra.testing API¶
classDiagram
AssertionError <|-- RegexLineMismatch
CliRunner <|-- CliRunner
Result <|-- Result
CLI testing and simulation of their execution.
- click_extra.testing.OUTPUT_LABEL = '<output>'
Label for the merged stream, where stdout and stderr are interleaved.
- click_extra.testing.STDOUT_LABEL = '<stdout>'
Label for the standard output stream.
- click_extra.testing.STDERR_LABEL = '<stderr>'
Label for the standard error stream.
- click_extra.testing.EXIT_CODE_LABEL = '<exit_code>'
Label for the process exit code.
- click_extra.testing.STREAM_FIELDS = {'output_': ('<output>', 'output'), 'stderr_': ('<stderr>', 'stderr'), 'stdout_': ('<stdout>', 'stdout')}
Maps a test-case field prefix to its stream label and
StreamViewattribute.output_*directives target the merged stream;stdout_*andstderr_*target the separate streams. Bothrender_cli_run()andclick_extra.test_suite.CLITestCase.run_cli_test()read this single table so the rendered trace and the assertion loop agree on labels and stream selection.
- class click_extra.testing.StreamView(stdout='', stderr='', output='', exit_code=None)[source]
Bases:
objectNormalized view of a CLI run’s captured streams and exit code.
Both runners produce one of these so the renderer and the assertion loop read a single shape, regardless of whether the run was driven in-process (Click’s
click.testing.Result) or as a black-box subprocess (subprocess.CompletedProcess).A run captures either the merged stream (
output) or the separatestdoutandstderrstreams, never both: the unused fields stay empty.- stdout: str = ''
Captured standard output, or empty when the merged stream was captured.
- stderr: str = ''
Captured standard error, or empty when the merged stream was captured.
- output: str = ''
Captured merged stream (stdout and stderr interleaved), or empty when the separate streams were captured.
- classmethod from_result(result)[source]
Build a view from an in-process
click.testing.Result.Click always exposes
stdout,stderrand the interleavedoutputtogether, so all three are carried over verbatim.- Return type:
- classmethod from_completed_process(result)[source]
Build a view from a black-box
subprocess.CompletedProcess.A subprocess run with stderr merged into stdout (
stderr=STDOUT) reportsresult.stderrasNone: that case is rendered as the interleavedoutputstream. Otherwise the two streams are kept separate.- Return type:
- click_extra.testing.render_cli_run(args, result, env=None)[source]
Generates the full simulation of CLI execution, including output.
Mostly used to print debug traces to user or in test results.
- Return type:
- click_extra.testing.INVOKE_ARGS = {'args', 'catch_exceptions', 'cli', 'color', 'env', 'input', 'self'}
Parameter IDs of
click.testing.CliRunner.invoke().We need to collect them to help us identify which extra parameters passed to
invoke()collides with its original signature.Warning
This has been reported upstream to Click project but has been rejected and not considered an issue worth fixing.
- class click_extra.testing.Result(runner, stdout_bytes, stderr_bytes, output_bytes, return_value, exit_code, exception, exc_info=None)[source]
Bases:
ResultA
Resultsubclass with automatic traceback formatting.Enhances
__repr__so that pytest assertion failures show the full traceback instead of just the exception type.
- class click_extra.testing.CliRunner(charset='utf-8', env=None, echo_stdin=False, catch_exceptions=True, capture='sys')[source]
Bases:
CliRunnerAugment
click.testing.CliRunnerwith extra features and bug fixes.- force_color: bool = False
Global class attribute to override the
colorparameter ininvoke.
- invoke(cli, *args, input=None, env=None, catch_exceptions=True, color=None, **extra)[source]
Same as
click.testing.CliRunner.invoke()with extra features.The first positional parameter is the CLI to invoke. The remaining positional parameters of the function are the CLI arguments. All other parameters are required to be named.
The CLI arguments can be nested iterables of arbitrary depth. This is useful for argument composition of test cases with @pytest.mark.parametrize.
Allow forcing of the
colorproperty at the class-level viaforce_colorattribute.Adds a special case in the form of
color="forced"parameter, which allows colored output to be kept, while forcing the initialization ofContext.color = True. This is not allowed in current implementation ofclick.testing.CliRunner.invoke()because of colliding parameters.Strips all ANSI codes from results if
colorwas explicitly set toFalse.Always prints a simulation of the CLI execution as the user would see it in its terminal. Including colors.
Pretty-prints a formatted exception traceback if the command fails.
- Parameters:
cli (
Command) – CLI to invoke.args (
str|Path|None|Iterable[str|Path|None|Iterable[Iterable[str|Path|None|Iterable[TNestedArgs]]]]) – can be nested iterables composed ofstr,pathlib.Pathobjects andNonevalues. The nested structure will be flattened andNonevalues will be filtered out. Then all elements will be cast tostr. Seeargs_cleanup()for details.input (
str|bytes|IO|None) – same asclick.testing.CliRunner.invoke().env (
Mapping[str,str|None] |None) – same asclick.testing.CliRunner.invoke().catch_exceptions (
bool) – same asclick.testing.CliRunner.invoke().color (
bool|Literal['forced'] |None) – If a boolean, the parameter will be passed as-is toclick.testing.CliRunner.isolation(). If"forced", the parameter will be passed asTruetoclick.testing.CliRunner.isolation()and an extracolor=Trueparameter will be passed to the invoked CLI.extra (
Any) – same asclick.testing.CliRunner.invoke(), but colliding parameters are allowed and properly passed on to the invoked CLI.
- Return type:
- click_extra.testing.unescape_regex(text)[source]
De-obfuscate a regex for better readability.
This is like the reverse of
re.escape().- Return type:
- exception click_extra.testing.RegexLineMismatch(regex_line, content_line, line_number)[source]
Bases:
AssertionErrorRaised when a regex line does not match the corresponding content line.
- click_extra.testing.REGEX_NEWLINE = '\\n'
Newline token used to split a multi-line regex pattern for line-by-line matching.
- click_extra.testing.regex_fullmatch_line_by_line(regex, content)[source]
Check that the
contentmatches the givenregex.If the
regexdoes not fully match thecontent, raise anAssertionError, with a message showing the first mismatching line.This is useful when comparing large walls of text, such as CLI output.
- Return type: