I was asked recently about how to test the argument parsing bit of an application that used argparse
. argparse
is a built in Python library for dealing with parsing command line arguments for command line interfaces, CLI’s.
You know, like git clone <repo address>
.
git
is the application.<repo address>
is a command line argument.clone
is a sub-command.
Well, that might be a bad example, as I’m not going to use subcommands in my example, but lots of this still applies, even if you are using subcommands. Anyway, loads of applications use command line arguments, also sometimes called flags and options.
Even small utility scripts that started out with no arguments can grow arguments over time. And getting the parsing of the options right can be tricky and weird, even if you’ve done it before, so testing that bit totally makes sense.
Smug Answer
My first answer is to use click
or typer
as they are great to work with and have built in CliRunner
objects, explicitly to help with testing. argparse
doesn’t have built in testing tools. But it’s got some features that help.
There are also sometimes good reasons to use argparse
.
- Maybe you don’t have any other dependencies, why add one just for argument parsing?
- Especially single file scripts and applications that you’re not packaging, just sharing as a single file.
- Maybe you just really like
argparse
. Honestly, after researching this topic, I might useargparse
more. It’s got some cool features.
Design for Test
First off, let’s design the application so it’s ready for testing. This applies to all applications, even small scripts.
Here’s a few good things to do with all CLI applications.
- Have a
main()
function called by aif __name__ == "__main__"
block that either contains all of your logic, or calls other functions. - Have a
parse_args()
that contains all of your interactions withargparse
. - Have all of the logic of the application or script in a function.
- This way our test code can import the application, test the
parse_args()
andmain()
function, and any other function. Or just callmain()
if you want. - But this makes testing easier.
Now, in more detail:
-
Have a
if __name__ == "__main__":
block that calls a single function,main()
, with no arguments.- When running from the command line, this gets called.
- When testing, we can import the different pieces and test separately, if we want.
-
Define your
main(arg_list: list[str] | None = None)
function to take a list of strings, orNone
, and default toNone
. This is going to be our argument list when testing, and when actually running, theNone
will be fine, as the__name__ == "__main__"
block just callsmain()
with no args.- Seems weird at first. Bear with me. It will make sense.
-
The first thing you do in the
main()
function is callparse_args(arg_list)
with thearg_list
passed in tomain
, and assign the return value toargs
orparams
or whatever you want to call your variable. -
The
parse_args(arg_list: list[str] | None)
function also takes either a list of strings, orNone
. No default is necessary.- This function will set up create a parser, add arguments, and call
parser.parse_args(arg_list)
with thearg_list
passed in. argparse
’sparse_args()
function, if passed in a list, will parse it. If it’s passed inNone
, it will parsesys.argv
instead.- It’s starting to make sense now, isn’t it.
- This function will set up create a parser, add arguments, and call
Test code
Now for our test code, we can import our application, or script, or file, or whatever and pull out the main()
and parse_args()
and other bits to test as a whole or by themselves, and being able to unittest parse_args()
is super cool. Even if you don’t have any other unit tests. It will help you understand how to use argparse
with your little unit test playground.
Also, when testing from an imported script, your code runs in process, so mocking and such work, if you want to fake or stub out side effects.
Adding -d
/--debug
and ‘-p
/--preview
’
While we’re at it, There are a couple of flags I like to add to CLI applications.
-d
/--debug
for printing out debug print statements to follow the flow of the script/application. You can write adebug_print()
function that checksargs.debug
or just check it yourself whenever you want to print some info that will help with debugging.- I don’t care about testing this part, so throw a
# pragma: no cover
on it.
- I don’t care about testing this part, so throw a
'-p
/--preview
for doing all of the logic, but not actually doing any side effects.- If you end up calling an external API, just print what you would have called instead in preview mode.
Lots of tools you probably use every day have these features as part of their user accessible API.
- If you want to expose these, take those
# pragma: no cover
lines out and test it. - If you don’t want to expose them, you can set
help=argparse.SUPPRESS
and those flags won’t show up in the help description.- Click and Typer call this “hidden”.
Testing stdout
One of the beautiful parts of -p
/--preview
is that now you don’t have to mock or stub stuff.
You can test with pytest
and the builtin fixture capsys
and just check to make sure the output is correct with various command line flags.
Using shlex.split()
When testing CLI’s, I’d like to think in terms of the command line I’m passing to an application, which is a space delimited string. You know, something like --goodbye Brian
.
The problem is that argparse
and it’s parse_args()
function accept lists of strings, like ["--goodbye", "Brian"]
. This makes sense because that’s what sys.argv
looks like.
It’s not bad for short things, but it gets annoying fast, especially when testing lots of combinations.
Luckily, Python has a builtin function, shlex.split()
that does this work for us. It takes a space delimited string and produces a list of strings. Perfect. I use this all the time when testing CLIs.
Using subprocess.run()
If you cannot split up your application or script and importing is not an option, well, then you’re stuck with calling it with subprocess.run()
.
There are downsides to this:
- It’s gonna run, and do any side effects. So hopefully you have some flags to disable that bit for testing.
- It’s in a separate process, so introspection and mocking are either not possible, or at the very least, I don’t know how to do it.
Then again, plenty of applications are fine to test this way.
Take “Hello, World!”, for example.
An example: “Hello, World!” with argparse
Let’s look at some code. So, I need an example. How about “Hello, World!”?
You totally don’t need argparse
for “Hello, World!”.
But let’s say we add a few requirements:
- If a name is passed in, we’d like it to replace “World” with the name.
- ex:
python hello.py Brian
should print"Hello, Brian!"
- ex:
- If someone passes in
-g
or--goodbye
, “Hello” should be replaced with “Goodbye”.- ex:
python hello.py -g Brian
should print"Goodbye, Brian!"
- ex:
Stick with me here, of course this is silly, but I’m trying to come up with an easy example that uses argparse
.
I’ve got the code here, but it’s also in a repo: github.com/okken/test-argparse-apps-hello-world.
hello.py
import argparse
def parse_args(arg_list: list[str] | None):
parser = argparse.ArgumentParser()
parser.add_argument('name', type=str, default='World',
nargs='?', help='any name')
parser.add_argument('-g', '--goodbye', action='store_true',
help='say goodbye instead')
parser.add_argument('-d', '--debug', action='store_true',
help=argparse.SUPPRESS)
args = parser.parse_args(arg_list)
if args.debug: # pragma: no cover
print('--- debug output ---')
print(f' {args=}')
print(f' {args.goodbye=}, {args.name=}')
print('')
return args
def main(arg_list: list[str] | None = None):
args = parse_args(arg_list)
if args.goodbye:
print(f'Goodbye, {args.name}!')
else:
print(f'Hello, {args.name}!')
if __name__ == '__main__':
main()
test_hello.py
import subprocess
import pytest
import shlex
from hello import parse_args, main
def test_shlex():
# I want to write this
command = '-d -g Brian'
# command parsers want this
as_list = ['-d', '-g', 'Brian']
# shlex.split() does the work for me
assert shlex.split(command) == as_list
test_cases = [
("", "Hello, World!"), # no args
("Okken", "Hello, Okken!"), # one arg
("-g", "Goodbye, World!"), # the other arg
("--goodbye", "Goodbye, World!"), # long form
("Okken -g", "Goodbye, Okken!"), # both args
]
@pytest.mark.parametrize('command, expected_output', test_cases)
def test_main(capsys, command, expected_output):
main(shlex.split(command))
output = capsys.readouterr().out.rstrip()
assert output == expected_output
@pytest.mark.parametrize('command, expected_output', test_cases)
def test_app(command, expected_output):
full_command = ["python", "hello.py"] + shlex.split(command)
result = subprocess.run(full_command,
capture_output=True, text=True)
output = result.stdout.rstrip()
assert output == expected_output
@pytest.mark.parametrize(
'command, debug, goodbye, name',
[
# no params
("", False, False, 'World'),
# each param
("-d", True, False, 'World'),
("-g", False, True, 'World'),
("Name", False, False, 'Name'),
# all params
("-d -g Earth", True, True, 'Earth'),
# long form
("--goodbye", False, True, 'World'),
])
def test_parse_args(command, debug, goodbye, name):
args = parse_args(shlex.split(command))
# combine test into in one assert
assert ((args.debug, args.goodbye, args.name) ==
(debug, goodbye, name))
# or split them up, either works
assert args.debug == debug
assert args.goodbye == goodbye
assert args.name == name