Testing command-line applications with pytest is surprisingly straightforward, but the real magic happens when you realize you can treat your CLI as a black box and test its output and behavior just like any other function.

Let’s say you have a simple Python script, cli.py, that takes a name as an argument and prints a greeting:

# cli.py
import argparse

def main():
    parser = argparse.ArgumentParser(description="A simple greeting CLI.")
    parser.add_argument("name", help="The name to greet.")
    args = parser.parse_args()
    print(f"Hello, {args.name}!")

if __name__ == "__main__":
    main()

To test this, you’ll use pytest’s built-in capsys fixture and the subprocess module. capsys captures standard output and standard error.

First, let’s write a basic test. You’ll need to simulate running your script as if from the terminal.

# test_cli.py
import subprocess
import sys

def test_greeting_output():
    # Construct the command to run your script
    # sys.executable points to the current Python interpreter
    command = [sys.executable, "cli.py", "Alice"]

    # Run the command and capture output
    result = subprocess.run(command, capture_output=True, text=True, check=True)

    # Assert that the standard output is as expected
    assert result.stdout.strip() == "Hello, Alice!"
    # Assert that there was no standard error
    assert result.stderr == ""

When you run pytest, it will execute cli.py with "Alice" as the argument, capture its printed output, and verify it matches "Hello, Alice!". The check=True argument in subprocess.run is crucial; it raises a CalledProcessError if the command returns a non-zero exit code (indicating an error in your script).

This approach treats your CLI as an external process. You’re not importing functions directly; you’re invoking the script as a separate entity, which is excellent for testing the actual user experience.

The real power comes from how you can combine this with pytest’s fixtures and parameterization. Imagine your CLI has more options.

# cli.py (updated)
import argparse

def main():
    parser = argparse.ArgumentParser(description="A simple greeting CLI.")
    parser.add_argument("name", help="The name to greet.")
    parser.add_argument("--formal", action="store_true", help="Use formal greeting.")
    args = parser.parse_args()

    if args.formal:
        print(f"Greetings, {args.name}.")
    else:
        print(f"Hello, {args.name}!")

if __name__ == "__main__":
    main()

Now, let’s test the --formal flag using parameterization:

# test_cli.py (updated)
import subprocess
import sys
import pytest

@pytest.mark.parametrize("name, formal, expected_output", [
    ("Alice", False, "Hello, Alice!"),
    ("Bob", True, "Greetings, Bob."),
    ("Charlie", False, "Hello, Charlie!"),
])
def test_greeting_variations(name, formal, expected_output):
    command = [sys.executable, "cli.py", name]
    if formal:
        command.append("--formal")

    result = subprocess.run(command, capture_output=True, text=True, check=True)
    assert result.stdout.strip() == expected_output
    assert result.stderr == ""

This test_greeting_variations function will run three times, once for each set of parameters. Each run simulates calling cli.py with different arguments and checks if the output is correct.

You can also test error conditions. What if the user forgets to provide a name?

# test_cli.py (updated)
import subprocess
import sys
import pytest

# ... (previous tests) ...

def test_missing_name_error():
    command = [sys.executable, "cli.py"]
    # We expect this to fail, so we don't use check=True
    # Instead, we assert the return code and stderr
    result = subprocess.run(command, capture_output=True, text=True)

    # argparse typically returns 2 for argument parsing errors
    assert result.returncode == 2
    assert "the following arguments are required: name" in result.stderr
    assert result.stdout == ""

Here, we don’t use check=True because we expect the command to fail. We then assert that the returncode is non-zero (specifically, 2, which is standard for argparse errors) and that the error message is what we expect.

The capsys fixture is handy when you’re testing functions that internally print to stdout/stderr, but for testing actual CLI invocation, subprocess.run is your primary tool. It gives you the exit code, stdout, and stderr of the executed process.

When using subprocess.run, capture_output=True is key to getting the stdout and stderr. text=True (or encoding='utf-8') ensures the output is decoded into strings rather than bytes, making assertions much easier.

The most common pitfall is forgetting text=True when you expect string output, leading to comparisons between bytes and strings. Another is not using check=True when you expect success, which can mask underlying script errors. Conversely, using check=True when you expect an error will cause your test to fail prematurely.

The true power of this method lies in its ability to test your CLI exactly as a user would interact with it, abstracting away the internal Python code and focusing purely on the input-output contract of your command-line interface.

Once you’ve mastered testing the output and error codes of your CLI, the next logical step is to explore how to manage more complex test environments, perhaps involving temporary files or network services, which can be orchestrated using pytest fixtures.

Want structured learning?

Take the full Pytest course →