Skip to main content

Testing Python CLI Tools: Unit Tests & Integration Tests

Untested CLI tools break silently and surprise users in production. Testing CLIs requires special techniques because you're simulating terminal interactions. This article covers unit testing individual commands and integration testing entire workflows.

Testing Setup with pytest and CliRunner

Typer provides CliRunner for testing, which simulates running your CLI from the command line:

pip install pytest typer

Here's your first test file:

from typer.testing import CliRunner
import typer

app = typer.Typer()

@app.command()
def hello(name: str):
"""Greet someone."""
typer.echo(f"Hello, {name}!")

runner = CliRunner()

def test_hello():
result = runner.invoke(app, ["hello", "Alice"])
assert result.exit_code == 0
assert "Hello, Alice!" in result.stdout

def test_hello_no_args():
result = runner.invoke(app, ["hello"])
assert result.exit_code != 0

Run with: pytest test_cli.py. The CliRunner executes your CLI as if a user ran it from the terminal, capturing output and exit codes.

Testing Commands with Arguments and Options

Test various argument combinations:

from typer.testing import CliRunner
import typer

app = typer.Typer()

@app.command()
def greet(name: str, count: int = typer.Option(1)):
"""Greet someone."""
for _ in range(count):
typer.echo(f"Hello, {name}!")

runner = CliRunner()

def test_default_options():
result = runner.invoke(app, ["greet", "Bob"])
assert result.exit_code == 0
assert result.stdout.count("Hello, Bob!") == 1

def test_custom_options():
result = runner.invoke(app, ["greet", "Bob", "--count", "3"])
assert result.exit_code == 0
assert result.stdout.count("Hello, Bob!") == 3

def test_invalid_option_type():
result = runner.invoke(app, ["greet", "Bob", "--count", "abc"])
assert result.exit_code != 0

Each test simulates a different user input scenario. The result object contains exit_code, stdout, stderr, and exception for detailed assertions.

Testing Subcommands

Test each subcommand and command group:

from typer.testing import CliRunner
import typer

app = typer.Typer()

@app.command()
def create(name: str):
"""Create a resource."""
typer.echo(f"Created: {name}")

@app.command()
def delete(name: str):
"""Delete a resource."""
typer.echo(f"Deleted: {name}")

runner = CliRunner()

def test_create_command():
result = runner.invoke(app, ["create", "myresource"])
assert result.exit_code == 0
assert "Created: myresource" in result.stdout

def test_delete_command():
result = runner.invoke(app, ["delete", "myresource"])
assert result.exit_code == 0
assert "Deleted: myresource" in result.stdout

def test_unknown_command():
result = runner.invoke(app, ["unknown"])
assert result.exit_code != 0

Test each command independently. The test structure mirrors your CLI structure.

Testing File Operations

Mock or use temporary files in tests:

from typer.testing import CliRunner
import typer
from pathlib import Path
import tempfile

app = typer.Typer()

@app.command()
def process_file(input_file: Path):
"""Process a file."""
if not input_file.exists():
typer.echo("File not found", err=True)
raise typer.Exit(code=1)

content = input_file.read_text()
typer.echo(f"Processed {len(content)} bytes")

runner = CliRunner()

def test_process_file_success():
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
f.write("test content")
temp_path = f.name

try:
result = runner.invoke(app, ["process_file", temp_path])
assert result.exit_code == 0
assert "Processed 12 bytes" in result.stdout
finally:
Path(temp_path).unlink()

def test_process_file_not_found():
result = runner.invoke(app, ["process_file", "/nonexistent/file.txt"])
assert result.exit_code == 1
assert "File not found" in result.stdout

Use tempfile for temporary files, cleaning them up after the test. Test both success and failure paths.

Testing with Mocked Dependencies

Mock external calls (API, database) to keep tests fast:

from typer.testing import CliRunner
import typer
from unittest.mock import patch

app = typer.Typer()

@app.command()
def fetch_user(user_id: int):
"""Fetch a user from API."""
import requests
response = requests.get(f"https://api.example.com/users/{user_id}")
if response.status_code == 404:
typer.echo("User not found", err=True)
raise typer.Exit(code=1)
user = response.json()
typer.echo(f"User: {user['name']}")

runner = CliRunner()

@patch('requests.get')
def test_fetch_user_success(mock_get):
mock_response = mock_get.return_value
mock_response.status_code = 200
mock_response.json.return_value = {"id": 1, "name": "Alice"}

result = runner.invoke(app, ["fetch_user", "1"])
assert result.exit_code == 0
assert "User: Alice" in result.stdout

@patch('requests.get')
def test_fetch_user_not_found(mock_get):
mock_response = mock_get.return_value
mock_response.status_code = 404

result = runner.invoke(app, ["fetch_user", "999"])
assert result.exit_code == 1
assert "User not found" in result.stdout

Mocking isolates your CLI logic from external services, making tests fast and reliable.

Integration Testing: Full Workflows

Test complete workflows involving multiple commands:

from typer.testing import CliRunner
import typer
from pathlib import Path
import tempfile

app = typer.Typer()

@app.command()
def init(project: str):
"""Initialize a project."""
Path(project).mkdir(exist_ok=True)
Path(project) / "config.json" >> create file
typer.echo(f"Initialized: {project}")

@app.command()
def status(project: str):
"""Check project status."""
config_path = Path(project) / "config.json"
if config_path.exists():
typer.echo(f"Status: OK")
else:
typer.echo(f"Status: Not initialized", err=True)
raise typer.Exit(code=1)

runner = CliRunner()

def test_full_workflow():
with tempfile.TemporaryDirectory() as tmpdir:
# Create project
result = runner.invoke(app, ["init", tmpdir])
assert result.exit_code == 0

# Check status
result = runner.invoke(app, ["status", tmpdir])
assert result.exit_code == 0
assert "Status: OK" in result.stdout

Integration tests verify that commands work together correctly and produce expected results across the full user experience.

Comparison Table: Testing Strategies

StrategyScopeSpeedCoverage
Unit testsSingle commandFastSpecific
Integration testsMulti-command workflowsMediumComplete
Mocked testsExternal dependenciesFastIsolated
Real file testsFile operationsMediumRealistic

Key Takeaways

  • Use CliRunner.invoke() to simulate CLI execution and capture output.
  • Assert result.exit_code, result.stdout, and result.stderr to verify correct behavior.
  • Test both success and failure paths for each command.
  • Mock external dependencies (APIs, databases) to keep tests fast and deterministic.
  • Use temporary files and directories for file operation tests.
  • Integration tests verify complete workflows; unit tests verify individual commands.

Frequently Asked Questions

How do I test environment variable handling?

Pass env={"VAR_NAME": "value"} to runner.invoke(): runner.invoke(app, ["cmd"], env={"DEBUG": "1"}).

Can I test interactive prompts?

Yes. Use input="value\n" in invoke: runner.invoke(app, ["cmd"], input="y\n"). This simulates the user typing and pressing Enter.

How do I test commands that take a long time?

Use isolation and mocking. Mock slow operations to keep tests under 1 second. If the slow operation is intentional, mark the test @pytest.mark.slow and run it separately.

What's the difference between result.stdout and result.output?

result.output is the combined stdout and stderr. Use result.stdout and result.stderr to distinguish them.

How do I measure test coverage for CLI code?

Run pytest --cov=myapp to measure which lines are tested. Aim for 80%+ coverage, prioritizing critical paths (error handling, main workflows).

Further Reading