Pytest¶
click_extra.pytest is a Pytest plugin registered through the pytest11 entry point. Installing it is all the wiring there is: its fixtures are available to every test, with nothing to import and no conftest.py to write. Beside them, the module exposes plain helpers a test module imports directly.
Important
For these helpers to work, you need to install click_extra’s additional dependencies from the pytest extra group:
$ uv pip install click-extra[pytest]
Utility functions¶
Covering every decorator¶
Click, Cloup and Click Extra each provide their own command, group, option and argument decorators, usable with or without parenthesis. A behavior meant to hold everywhere has to be checked against all of them, and command_decorators() and option_decorators() expand that matrix into ready-made Pytest parameters:
from click_extra.pytest import command_decorators
for param in command_decorators():
print(param.id)
click.command
click.command()
cloup.command
cloup.command()
click_extra.command
click_extra.command()
click.group
click.group()
cloup.group
cloup.group()
click_extra.group
click_extra.group()
Each keyword carves the matrix down to the cells a test cares about:
Keyword |
Effect |
|---|---|
|
Drop the |
|
Drop the |
|
Drop one framework’s row. |
|
Emit the |
|
Pair each decorator with a set of tags, so a test can branch on what it was handed. |
from click_extra.pytest import option_decorators
for param in option_decorators(no_arguments=True, with_parenthesis=False, with_types=True):
decorator, tags = param.values
print(f"{param.id:20} {sorted(tags)}")
click.option ['click', 'option']
cloup.option ['cloup', 'option']
click_extra.option ['extra', 'option']
Feed either one to parametrize and build the CLI inside the test body, from the decorator the parameter carries:
test_forecast.py¶import click
import pytest
from click_extra.pytest import command_decorators
@pytest.mark.parametrize("cmd_decorator", command_decorators(no_groups=True))
def test_unit_option(invoke, cmd_decorator):
@cmd_decorator
@click.option("--unit", default="celsius")
def forecast(unit):
click.echo(f"Temperature in {unit}.")
result = invoke(forecast, "--unit", "fahrenheit")
assert result.exit_code == 0
assert result.stdout == "Temperature in fahrenheit.\n"
Cases are named after the decorator they carry (click.command, cloup.command(), …), so a failure points at the framework and the calling convention that broke. The bare Cloup variants come pre-marked with skip_naked, since Cloup does not support parenthesis-less decorators: a run reports those cases as skipped instead of failing.
Ready-made patterns¶
Every Click Extra command inherits a long list of options and, under --verbosity DEBUG, a fixed preamble of log lines. Asserting on a help screen or a debug trace therefore means transcribing output nobody wrote, and re-transcribing it whenever the defaults move. The module ships that boilerplate as regular expressions instead:
Pattern |
Matches |
|---|---|
|
The default options block closing the help screen. |
|
The two lines raising the logger levels. |
|
The configuration-file search trace. |
|
The dump of the version-string template variables. |
|
The three above, concatenated: everything a |
|
The two lines restoring the logger levels on exit. |
|
The line |
|
The same for |
Each has a colored twin under the same name (default_options_colored_help, default_debug_colored_config, …), carrying the ANSI codes the same output has when colors are on.
They are fragments, so a test concatenates the part it wrote itself with the part it inherited:
from click_extra import command, echo, option
from click_extra.pytest import default_options_uncolored_help
from click_extra.testing import CliRunner, regex_fullmatch_line_by_line
@command
@option("--unit", default="celsius")
def forecast(unit):
"""Print the temperature of each city."""
echo(f"Temperature in {unit}.")
result = CliRunner().invoke(forecast, "--help", color=False)
regex_fullmatch_line_by_line(
r"Usage: forecast \[OPTIONS\]\n"
r"\n"
r" Print the temperature of each city\.\n"
r"\n"
r"Options:\n"
r" --unit TEXT \[default: celsius\]\n" + default_options_uncolored_help,
result.stdout,
)
That block runs on every documentation build: only the four lines the CLI actually owns are written out, and the rest of the screen is asserted by reference. Match the colored twin against a run keeping its ANSI codes, described in Colors.
Fixtures¶
Fixture |
Provides |
|---|---|
A |
|
Shorthand for that runner’s |
|
A callable writing a configuration file into |
|
A fresh, empty directory standing in for the host configuration folder. |
|
An assert-like callable comparing output to a regular expression. |
runner and invoke¶
runner() yields a CliRunner from inside an isolated filesystem, with HOME and its platform equivalents (USERPROFILE, XDG_CONFIG_HOME, APPDATA, LOCALAPPDATA) pointed at an empty subdirectory of it. A test therefore gets the same answers on a developer’s machine and on a hermetic builder, both for the working directory the CLI writes into and for the configuration paths it derives from the home directory.
invoke() is that runner’s invoke method, which is the one most tests want:
test_forecast.py¶def test_default_unit(invoke):
result = invoke(forecast)
assert result.exit_code == 0
assert result.stdout == "Temperature in celsius.\n"
See CLI testing for what invoke accepts and what it gives back.
Warning
The home directory is pinned per test, but a module-global cache filled from inside one is not. A cache first populated during a test records what a home-less environment answered, and keeps serving that for the rest of the worker’s session. Seed such a cache from a session-scoped fixture, which runs before the first test and outside this isolation.
create_config¶
create_config() writes a configuration file under tmp_path and returns its path, creating any missing parent directory along the way. Hand that path to --config to exercise a CLI against a controlled file:
test_forecast.py¶def test_unit_from_config(invoke, create_config):
config = create_config("forecast.toml", '[forecast]\nunit = "fahrenheit"\n')
result = invoke(forecast, "--config", config)
assert result.exit_code == 0
assert result.stdout == "Temperature in fahrenheit.\n"
The path travels unconverted because invoke casts every argument to a string for you, as described in Composing arguments.
isolated_app_dir¶
Without --config, a Click Extra CLI searches the host configuration folder: ~/Library/Application Support/<app> on macOS, ~/.config/<app> on Unix, %APPDATA%\<app> on Windows. Any file sitting there bleeds into every in-process invocation, so a suite passes or fails on the developer’s own configuration.
isolated_app_dir() repoints click.get_app_dir() at a per-test temporary directory, whatever application name is asked for, and returns it. Plant a file in it to exercise the default search against a controlled one:
test_forecast.py¶def test_config_autodiscovery(invoke, isolated_app_dir):
(isolated_app_dir / "forecast.toml").write_text(
'[forecast]\nunit = "fahrenheit"\n', encoding="utf-8"
)
result = invoke(forecast)
assert result.stdout == "Temperature in fahrenheit.\n"
To make a whole suite hermetic, alias it to an autouse fixture:
conftest.py¶import pytest
@pytest.fixture(autouse=True)
def isolate_user_config(isolated_app_dir):
return isolated_app_dir
assert_output_regex¶
assert_output_regex() compares output to a pattern line by line, and reports the first line that disagrees with a difflib.ndiff() diff pointing at the offending characters. It is the assertion form of regex_fullmatch_line_by_line(), and the natural consumer of the ready-made patterns:
test_forecast.py¶from click_extra.pytest import default_options_uncolored_help
def test_help_screen(invoke, assert_output_regex):
result = invoke(forecast, "--help", color=False)
assert_output_regex(
result.stdout,
r"Usage: forecast \[OPTIONS\]\n"
r"\n"
r"Options:\n"
r" --unit TEXT \[default: celsius\]\n"
+ default_options_uncolored_help,
)
click_extra.pytest API¶
Pytest fixtures and marks to help testing Click CLIs.
- click_extra.pytest.runner()[source]
Runner fixture for
click_extra.testing.CliRunner.Pins
HOME(and its platform-specific equivalents) to a subdirectory of the runner’s isolated filesystem so configuration-file discovery is deterministic and independent of the ambient environment. Without this, the handful of tests asserting on the config-search debug output depend on the caller’sHOME: hermetic builders setHOME=/homeless-shelter, which would otherwise leak into those assertions.The environment is patched directly (via
temporary_env()) rather than through themonkeypatchfixture on purpose: depending onmonkeypatchhere would tear it down afterisolated_filesystemremoved the working directory it tries to restore, breaking unrelated tests thatchdirwithin the runner.Warning
The pinning is scoped to each test requesting this fixture, but a cache filled from inside one is not: a module-global populated lazily during a test records what a home-less environment answered, and keeps serving that for the rest of the worker’s session. A binary resolved through a
$HOME-dependent shim is the usual victim, answering an error rather than a version, after which every later test on that worker sees the tool as missing. Seed such a cache from a session-scoped fixture, which runs before the first test and outside this isolation.
- click_extra.pytest.invoke(runner)[source]
Invoke fixture shorthand for
click_extra.testing.CliRunner.invoke().
- click_extra.pytest.isolated_app_dir(monkeypatch, tmp_path)[source]
Repoint configuration-file discovery at a fresh, empty directory.
The default
--configsearch pattern derives fromclick.get_app_dir(), which resolves to the host configuration folder (~/Library/Application Support/<app>on macOS,~/.config/<app>on Unix,%APPDATA%\<app>on Windows). Any configuration file living there bleeds into every in-process CLI invocation, making a test suite pass or fail depending on the developer’s personal configuration.This fixture repoints
get_app_dir(bothclick’s and the reference bound into click-extra’s config machinery) at a per-test temporary directory, whatever application name is requested. It returns that directory, so a test can also plant a configuration file in it to exercise the default discovery against a controlled file.An explicit
--config <path>bypasses the default search pattern and is left unaffected.Note
The
runner()fixture pinsHOMEand its platform equivalents inside an isolated filesystem, which also redirects the discovery paths built from the home directory — but only for tests requesting that fixture. This one interceptsget_app_dirdirectly, covering any in-process invocation, without touchingHOME(a suite may need the real one elsewhere) and without leaking into subprocesses.To make a whole suite hermetic, alias it to an
autousefixture in yourconftest.py:import pytest @pytest.fixture(autouse=True) def isolate_user_config(isolated_app_dir): return isolated_app_dir
- click_extra.pytest.command_decorators(no_commands=False, no_groups=False, no_click=False, no_cloup=False, no_extra=False, with_parenthesis=True, with_types=False)[source]
Returns collection of Pytest parameters to test all command-like decorators.
- Return type:
- Returns:
Pytest parameters covering each command-like decorator variant:
click.commandclick.command()cloup.commandcloup.command()click_extra.commandclick_extra.command()click.groupclick.group()cloup.groupcloup.group()click_extra.groupclick_extra.group()
- click_extra.pytest.option_decorators(no_options=False, no_arguments=False, no_click=False, no_cloup=False, no_extra=False, with_parenthesis=True, with_types=False)[source]
Returns collection of Pytest parameters to test all parameter-like decorators.
- Return type:
- Returns:
Pytest parameters covering each parameter-like decorator variant:
click.optionclick.option()cloup.optioncloup.option()click_extra.optionclick_extra.option()click.argumentclick.argument()cloup.argumentcloup.argument()click_extra.argumentclick_extra.argument()
- click_extra.pytest.create_config(tmp_path)[source]
A generic fixture to produce a temporary configuration file.
- click_extra.pytest.assert_output_regex()[source]
An assert-like utility for Pytest to compare CLI output against the regex.
Designed for the regexes defined above.