How to Test CLI Tools in Python With pytest and subprocess
Test a Python command line tool with pytest: subprocess and in-process runs, exit codes and output, tmp_path, monkeypatch and parametrized cases.

A command line tool is a function with a strange signature: arguments in, text and an exit status out. Testing it is straightforward if you decide what level to test at. The expensive level runs the real process; the cheap one calls the entry point directly. A good suite has a few of the first and many of the second. This post shows both with pytest, as the Python counterpart to Go table-driven tests. Examples use Python 3.13 and pytest 8.3.
Make the tool testable
import argparse
import sys
def build_parser():
p = argparse.ArgumentParser(prog="greet")
p.add_argument("name")
p.add_argument("--shout", action="store_true")
return p
def main(argv=None):
args = build_parser().parse_args(argv)
msg = f"hello {args.name}"
print(msg.upper() if args.shout else msg)
return 0
if __name__ == "__main__":
sys.exit(main())Two choices make this easy to test: main takes the arguments as a parameter (defaulting to sys.argv) and returns the exit code instead of calling sys.exit itself. The sys.exit lives only in the __main__ block. Packaging it as in a pipx-installable CLI uses the same main.
In-process tests
import pytest
from greet.cli import main
def test_basic(capsys):
assert main(["ada"]) == 0
out = capsys.readouterr()
assert out.out == "hello ada\n"
assert out.err == ""
@pytest.mark.parametrize("argv, expected", [
(["ada"], "hello ada\n"),
(["ada", "--shout"], "HELLO ADA\n"),
])
def test_cases(capsys, argv, expected):
main(argv)
assert capsys.readouterr().out == expected
def test_missing_argument_exits_with_2(capsys):
with pytest.raises(SystemExit) as exc:
main([])
assert exc.value.code == 2
assert "usage:" in capsys.readouterr().errcapsys captures what the test wrote to stdout and stderr. argparse calls sys.exit(2) on a usage error, which raises SystemExit, so the third test expects it. These tests run in milliseconds, so write plenty. They do not exercise the real process boundary, which the next layer covers.
End-to-end with subprocess
import subprocess, sys
def run(*args, **kw):
return subprocess.run([sys.executable, "-m", "greet", *args],
capture_output=True, text=True, timeout=30, **kw)
def test_exit_status_and_streams():
r = run("ada", "--shout")
assert r.returncode == 0
assert r.stdout == "HELLO ADA\n"
def test_error_goes_to_stderr():
r = run()
assert r.returncode == 2
assert r.stdout == ""
assert "usage" in r.stderrUsing sys.executable runs the tool with the same interpreter as the tests. The timeout stops a hung tool from blocking the suite. This level checks the packaging, the entry point and real exit codes, and it is the place to test behavior that depends on being a separate process: signals, reading stdin, environment. Pass input with input="data\n".
Isolate files and environment
def test_reads_config_from_home(tmp_path, monkeypatch):
(tmp_path / ".greetrc").write_text("name=grace\n")
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.chdir(tmp_path)
r = run("--use-config")
assert "grace" in r.stdouttmp_path creates a unique temporary directory per test, and monkeypatch changes the environment and working directory only for that test, restoring them afterward. Never let a test read the developer's real home directory or write to the repository. Assertions on files should check the content and permissions the tool created.
Click and Typer
from click.testing import CliRunner
from mytool.cli import app
def test_click():
result = CliRunner().invoke(app, ["--name", "ada"])
assert result.exit_code == 0
assert "hello ada" in result.outputBoth Click and Typer ship a CliRunner that invokes the command in process, captures output and returns the exit code and any exception, so you do not need to construct argv and capture streams yourself.
Where this does not apply
Tests for scripts in other languages belong in the tool of that ecosystem: Bats for shell. Output with timestamps or paths needs normalization, as in golden file tests. Do not assert on the exact wording of argparse error messages, which vary between Python versions; check the exit code and a stable fragment.
Designing the tool so it can be tested at all
A Python command-line tool is easy to test when its entry point is a function that takes its arguments as a list and returns an exit code, not a script that reads sys.argv and calls sys.exit at the top level. The usual shape is a main(argv=None) function that parses with argparse, calls ordinary functions for the work and returns an integer, with a final line that passes it to sys.exit only when the file is run directly. Tests then call main(["--flag", "value"]) in the same process, which is fast, can use fixtures and gives clean tracebacks. Keep the work in functions that take plain values and return plain values, with the printing at the edge, so most of the logic can be checked without capturing output at all. The thin layer of argument parsing and printing needs a few tests, and the rest is normal unit testing.
Keep the process-level facts out of the logic: read environment variables and the current directory in one place, near the entry point, and pass the values down, so a test can supply any combination without touching the real environment.
In-process tests and the fixtures that help
The built-in fixtures cover most needs. capsys captures what the code prints to standard output and error, so a test can assert on both separately. tmp_path gives each test a fresh directory, monkeypatch.setenv and monkeypatch.chdir change the environment and the working directory for one test and restore them afterwards, and monkeypatch.setattr replaces a function, such as the one that reads the clock or calls the network. To test a failing exit, catch SystemExit with pytest.raises when the tool exits through argparse, which exits with code 2 on a bad argument. For tools built on Click or Typer, use their own CliRunner, which invokes the command in process, feeds input, captures output and returns the exit code and any exception in one result object. Use pytest.mark.parametrize for the table of arguments and expected results, which keeps a dozen cases to a dozen lines.
One real process, and keeping tests isolated
A few tests should run the installed program as a separate process with subprocess.run, using [sys.executable, "-m", "yourpackage", ...] so the same interpreter and the same environment as the tests are used. Capture output as text, pass input with the input argument, set a timeout so a hang fails the test and not the whole run, and check the exit code and both streams. These tests catch what in-process ones cannot: a missing entry point in the packaging, a broken import at startup, behavior of real standard streams, signal handling and the program's exit status as the operating system sees it. Keep them few, since they are slower. Isolate every test from the developer's real files: point HOME and the configuration directory variables at tmp_path, so a test never reads your personal configuration or writes into it. Coverage of the subprocess needs the coverage tool's subprocess support turned on, or it will report the code as unrun.
Typical mistakes in these tests
Three mistakes recur. Tests that read the real home directory pass on the author's machine and fail in CI, or worse, modify real configuration. Tests that depend on the current directory break when run from another one. Tests that run the tool through a shell string, such as subprocess.run("tool " + arg, shell=True), mishandle spaces and quotes, which are exactly the cases a command-line test should be probing, so pass a list of arguments. Another is forgetting to check standard error: a tool that prints a traceback and exits with the right code can pass a test that looks only at standard output. Assert on both streams and on the exit code. When a test fails in CI only, print the full captured output in the failure message, since the difference is often an environment variable or a terminal width that you cannot see from the assertion alone.
See the pytest capture documentation and subprocess.run.


