A CLI you can call from tests is a CLI you can change safely.
A testable layout separates parsing from work:
def build_parser(): ...
def main(argv=None):
args = build_parser().parse_args(argv)
...
return 0
if __name__ == "__main__":
raise SystemExit(main())parse_args(None) reads sys.argv[1:], so the real CLI works, while tests call main(["--port", "1"]) directly. Return an exit code from main and pass it to SystemExit.
Usage errors call sys.exit(2), which raises SystemExit. In tests, assert on it: with pytest.raises(SystemExit) as e: ... then e.value.code == 2, and capture stderr to check the message.
ArgumentParser(exit_on_error=False) makes some errors (such as invalid type conversions) raise argparse.ArgumentError instead of exiting, useful when embedding a parser in a larger program, though not every error path respects it, so tests should still expect SystemExit for others.
parse_known_args(argv) returns (namespace, leftovers) instead of failing on unknown arguments. Use it for wrappers that pass the rest to another program. parse_intermixed_args allows options in between positionals that have nargs.
Example
import argparse, contextlib, io
def build_parser():
p = argparse.ArgumentParser(prog="greet")
p.add_argument("name")
p.add_argument("--times", type=int, default=1)
return p
def main(argv=None):
args = build_parser().parse_args(argv)
for _ in range(args.times):
print(f"hello {args.name}")
return 0
assert main(["ada", "--times", "2"]) == 0 # hello ada / hello ada
err = io.StringIO()
with contextlib.redirect_stderr(err):
try:
main([])
except SystemExit as e:
print("exit code:", e.code) # exit code: 2
print(err.getvalue().splitlines()[-1]) # greet: error: the following arguments are required: name
known, rest = build_parser().parse_known_args(["bob", "--color", "-x"])
print(known.name, rest) # bob ['--color', '-x']Remember
- main(argv=None) + parse_args(argv) makes the CLI callable from tests
- Usage errors raise SystemExit(2); assert on it and on stderr
- parse_known_args returns leftovers instead of failing
Watch out for
- Testing by patching sys.argv globally instead of passing argv explicitly.