Skip to content
shellhacks.dev
Python

How to Test Python CLI Scripts With pytest Step by Step

Test a Python command line script with pytest: call main() directly, use capsys and tmp_path, check exit codes, and run the real entry point with subprocess.

6 min read
Summarize in AI
Illustration for "How to Test Python CLI Scripts With pytest Step by Step": a shell script in an editor, test results, an exit status badge.

A command line script is hard to test when everything happens at import time and the code calls sys.exit in the middle. Small changes in structure make it as testable as any library, and the result is a suite that runs in milliseconds. The shell-side version of the idea is in testing a CLI with subprocess. The examples assume Python 3.13 and pytest 8.

Structure the script for testing

Python
#!/usr/bin/env python3
import argparse
import sys
from pathlib import Path


def count_lines(path: Path) -> int:
    with path.open() as handle:
        return sum(1 for _ in handle)


def main(argv=None) -> int:
    parser = argparse.ArgumentParser(description="Count lines in a file")
    parser.add_argument("file", type=Path)
    args = parser.parse_args(argv)
    if not args.file.is_file():
        print(f"error: {args.file} is not a file", file=sys.stderr)
        return 1
    print(count_lines(args.file))
    return 0


if __name__ == "__main__":
    sys.exit(main())

Passing argv lets a test supply arguments without touching sys.argv. Returning an integer, instead of calling sys.exit deep inside, keeps failure paths testable. Only the last two lines know about the process. The design follows the argparse guide.

Write the tests

Python
import pytest
from linecount import main


def test_counts_lines(tmp_path, capsys):
    f = tmp_path / "data.txt"
    f.write_text("a\nb\nc\n")
    assert main([str(f)]) == 0
    assert capsys.readouterr().out == "3\n"


def test_missing_file(tmp_path, capsys):
    assert main([str(tmp_path / "nope.txt")]) == 1
    assert "is not a file" in capsys.readouterr().err


def test_no_arguments_exits_2():
    with pytest.raises(SystemExit) as exc:
        main([])
    assert exc.value.code == 2
Text
$ pytest -q
...                                                [100%]
3 passed in 0.04s

tmp_path is a real directory that pytest cleans up later, so tests use real files and no mocks. capsys separates stdout from stderr, which matters because scripts that print errors to stdout break pipelines. argparse exits with code 2 on a usage error, by calling SystemExit, which pytest catches.

Environment variables and the current directory

Python
def test_reads_env(monkeypatch, tmp_path):
    monkeypatch.setenv("APP_DIR", str(tmp_path))
    monkeypatch.chdir(tmp_path)
    ...

monkeypatch sets variables and changes directory for one test and restores them after. Never rely on the developer's own environment. For commands that call other programs, patch the function that runs them, such as subprocess.run, and assert on the arguments passed, or point the script at a fake executable in tmp_path placed first on PATH. The mocking trade-offs are in why mocks are hard in shell.

One real end-to-end test

Python
import subprocess
import sys


def test_entry_point(tmp_path):
    f = tmp_path / "x.txt"
    f.write_text("one\n")
    result = subprocess.run(
        [sys.executable, "linecount.py", str(f)],
        capture_output=True, text=True,
    )
    assert result.returncode == 0
    assert result.stdout == "1\n"

This is slower, about 30 milliseconds, so keep it to one or two tests. It verifies what in-process tests cannot: the file runs as a script, the imports resolve and the exit status reaches the shell. Use sys.executable to run the same interpreter as the tests.

Testing the exit status and the error messages

A command line tool communicates through three channels: standard output, standard error and the exit status. Tests that look only at output miss the one scripts depend on most. Each failure path deserves its own test that asserts the status code and a distinctive part of the message. With argparse, a bad argument raises SystemExit with status 2, and pytest.raises(SystemExit) captures it so the test can inspect excinfo.value.code.

Messages are where regressions hide. A refactor that moves a check can silently change an error from "file not found" to a Python traceback, and the exit status changes from 1 to 1 as well, so only an assertion on stderr notices. Capture it with the capsys fixture and assert on a stable fragment, not on the whole string, so reworded punctuation does not break the test while a missing explanation does.

Isolating the file system and the environment

A test that reads and writes real paths is a test that fails on another machine. Use tmp_path, the built-in fixture that gives each test a fresh directory, and build every input file inside it. Pass paths as arguments so the code under test never refers to the home directory. Where the code reads an environment variable or the current directory, use monkeypatch.setenv and monkeypatch.chdir, which restore the original value when the test ends, even if it fails.

Time and randomness are the other sources of flakiness. Inject the clock as a parameter with a default, so a test can pass a fixed value, rather than patching datetime globally. Anything that writes to the network should sit behind a small function the test can replace. The goal is that every test can run in any order, in parallel, on a machine with no network, and give the same answer.

Parametrised tests for option combinations

CLI options multiply. Flags interact, defaults change behaviour, and the same function is reached through several spellings. @pytest.mark.parametrize turns a table of argument lists and expected results into separate test cases, each reported by name, so a failure says exactly which combination broke. A table of ten rows reads more clearly than ten copied functions, and adding a row costs one line.

Pair that with a coverage run, but treat the percentage as a pointer rather than a goal. The useful report is the list of lines that no test reached, which in a CLI are nearly always error branches. Reading that list and writing a test for each branch that matters finds real bugs, while chasing a number encourages tests that execute code without checking anything. Run the suite in CI with -W error so a deprecation warning fails the build before the library removes the feature.

Keeping the suite fast and readable

CLI tests are cheap when they call the main function in-process and slow when each one starts a new interpreter through subprocess. Reserve subprocess tests for the few that prove the entry point wiring, such as the installed console script and the shebang, and run the rest by calling main(argv) with a list. A hundred in-process tests finish in a second or two, which keeps people running them before every commit.

Write each test so a failure explains itself. Name tests for behaviour, such as test_missing_file_exits_with_status_1, and keep the arrange, act and assert steps visible. Avoid shared mutable fixtures that couple tests together; a fixture should build fresh state each time. When a bug is reported, write a failing test first, then fix it, so the case stays covered.

Finally, run the suite across the Python versions you support using a matrix in CI. Argument parsing and path handling changed between releases, and a script that passes on 3.13 can fail on an older interpreter that a server still uses. A matrix of two or three versions catches that for the price of a few minutes of runner time.

Keep test data small and obvious. A three-line input file in the test body is easier to read and debug than a fixture loaded from a distant directory. When a realistic sample is necessary, store it next to the tests and name it for the case it represents, so the connection between data and assertion is visible without a search.

Where this does not apply

Click and Typer ship their own test runners (CliRunner) that do the same job with less setup; see Click and Typer. Interactive prompts need input fed with monkeypatch.setattr("builtins.input", ...) or a runner that simulates stdin. Output that includes colours or terminal width depends on the environment, so set NO_COLOR in tests. See the pytest capture documentation and the argparse reference.

Also inTesting
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.