Testing the CLI
Table of contents
Introduction
CLI commands read environment variables, touch the filesystem and call docker and git. You test them with Click’s CliRunner, pytest’s tmp_path and unittest.mock.patch. Every command reads its context through the service layer, so pointing WORKING_DIR at a temporary folder redirects a whole command there, with no Docker.
cd /workspace/splent_cli
pytest tests/ -v
Running the tests
pytest tests/unit/commands/product/test_product_status.py -v
pytest tests/unit/commands/env/test_env_list.py::TestFilter::test_filter_by_prefix -v
pytest tests/ --cov=splent_cli --cov-report=term-missing
Tests live under tests/unit/ (services/, utils/, commands/<group>/) and tests/integration/, with the shared fixtures in tests/conftest.py.
Testing pure logic
from splent_cli.services.compose import project_name
def test_project_name_replaces_special_chars():
assert project_name("splent_io/notes@v1.0", "prod") == "splent_io_notes_v1_0_prod"
Testing a command that writes files
from click.testing import CliRunner
from splent_cli.commands.cache.cache_status import cache_status
def test_cache_shows_versioned_feature(tmp_path, monkeypatch):
monkeypatch.setenv("WORKING_DIR", str(tmp_path))
cache = tmp_path / ".splent_cache" / "features" / "splent_io" / "splent_feature_notes@v1.0.0"
cache.mkdir(parents=True)
result = CliRunner().invoke(cache_status, [])
assert result.exit_code == 0
assert "v1.0.0" in result.output
Testing a command that runs processes
Mock subprocess.run with a MagicMock of the right shape.
import json
from unittest.mock import patch, MagicMock
from click.testing import CliRunner
from splent_cli.commands.product.product_containers import product_docker
def test_shows_running_containers(product_workspace):
out = json.dumps({"Service": "web", "State": "running", "Publishers": []})
with patch("subprocess.run", return_value=MagicMock(returncode=0, stdout=out, stderr="")):
result = CliRunner().invoke(product_docker, ["--dev"])
assert result.exit_code == 0
assert "running" in result.output
Using the shared fixtures
| Fixture | Gives |
|---|---|
runner |
A CliRunner(mix_stderr=False), so stderr is in result.stderr |
workspace |
A tmp_path set as WORKING_DIR, with SPLENT_APP and SPLENT_ENV cleared |
product_workspace |
A workspace with a test_app product selected in dev, its pyproject.toml and compose files |
tests/conftest.py also has helpers such as make_env_file, make_cache_entry, make_spl_working_copy, make_spl_cache_entry and write_product_spl_pin.
make_cache_entry(workspace, "splent_io", "splent_feature_notes", "v1.0.0")
What every test gets
Autouse fixtures keep the developer’s machine out of every test.
| Fixture | Does |
|---|---|
forge_env_is_clean |
Removes every SPLENT_FORGE_* variable, then sets SPLENT_FORGE_HOST=github.com, whose URL shapes the suite asserts |
splent_env_is_not_inherited |
Removes SPLENT_ENV |
pypi_is_not_asked |
Answers “could not ask” for every PyPI check |
no_builtin_spl_pins |
Empties the CLI’s built-in SPL pins |
contract_maps_are_not_shared |
Clears the memoised contract maps |
GITHUB_TOKENandGITHUB_USERare not removed. Unset them in your shell before you run the suite.
Asserting on a result
result = runner.invoke(my_command, ["--flag"])
assert result.exit_code == 0, result.output
Assert exit_code first. result.output holds stdout, and result.exception the exception when a command crashed.
Test the error paths too, such as a missing SPLENT_APP, flags that exclude each other, or a missing file.
def test_requires_splent_app(runner, workspace):
result = runner.invoke(product_up, ["--dev"])
assert result.exit_code == 1
assert "SPLENT_APP" in result.output
When you assert that a name does not appear, pick names that are not substrings of each other.
"SET_VAR"is inside"UNSET_VAR".
Adding a command
- Create
tests/unit/commands/<group>/test_<command>.py, and import the command function directly. - Use
workspace,product_workspaceortmp_path, and mock any external tool. - Test flag validation, the happy path and at least one error path.
- Write its reference page under
splent_cli/in the docs repository, titled with the command itself (title: feature:foo). It then appears in the Command index. A page with a prose title names its commands in the front matter, as incommand: doctororcommand: [locust, "locust:stop"].
The docs test tests/test_command_index.py fails when a command has no page, or a page documents a command the CLI lacks.