259

I have a Python module that uses the argparse library. How do I write tests for that section of the code base?

raphael
  • 2,762
  • 5
  • 26
  • 55
pydanny
  • 7,954
  • 6
  • 34
  • 42
  • argparse is a command line interface. Write your tests to invoke the application via the command line. – Homer6 Aug 10 '13 at 08:36
  • 2
    Your question makes it difficult to understand _what_ you want to test. I would suspect it is ultimately, e.g. "when I use command line arguments X, Y, Z then function `foo()` is called". Mocking of `sys.argv` is the answer if that's the case. Take a look at the [cli-test-helpers](https://pypi.org/project/cli-test-helpers/) Python package. See also https://stackoverflow.com/a/58594599/202834 – Peterino Nov 16 '19 at 12:53
  • I wrote a blog post with full example based on the discussions in this thread: https://hughesadam87.medium.com/dead-simple-pytest-and-argparse-d1dbb6affbc3 – Adam Hughes Aug 04 '23 at 17:28

11 Answers11

357

You should refactor your code and move the parsing to a function:

def parse_args(args):
    parser = argparse.ArgumentParser(...)
    parser.add_argument...
    # ...Create your parser as you like...
    return parser.parse_args(args)

Then in your main function you should just call it with:

parser = parse_args(sys.argv[1:])

(where the first element of sys.argv that represents the script name is removed to not send it as an additional switch during CLI operation.)

In your tests, you can then call the parser function with whatever list of arguments you want to test it with:

def test_parser(self):
    parser = parse_args(['-l', '-m'])
    self.assertTrue(parser.long)
    # ...and so on.

This way you'll never have to execute the code of your application just to test the parser.

If you need to change and/or add options to your parser later in your application, then create a factory method:

def create_parser():
    parser = argparse.ArgumentParser(...)
    parser.add_argument...
    # ...Create your parser as you like...
    return parser

You can later manipulate it if you want, and a test could look like:

class ParserTest(unittest.TestCase):
    def setUp(self):
        self.parser = create_parser()

    def test_something(self):
        parsed = self.parser.parse_args(['--something', 'test'])
        self.assertEqual(parsed.something, 'test')
Daniel Andersson
  • 1,614
  • 1
  • 14
  • 23
Viktor Kerkez
  • 45,070
  • 12
  • 104
  • 85
  • 5
    Thanks for your answer. How do we test for errors when a certain argument is not passed? – Pratik Khadloya Feb 05 '15 at 02:49
  • 4
    @PratikKhadloya If the argument is required and it's not passed, argparse will raise an exception. – Viktor Kerkez Feb 05 '15 at 11:26
  • Right, but i am trying to test the message in the exception as well. I am not able to get a hold of the message in the exception object. https://gist.github.com/tispratik/aebff28b8c5afd7bee59 – Pratik Khadloya Feb 05 '15 at 18:03
  • 3
    @PratikKhadloya Yes, the message is unfortunately not really helpful :( It's just ``2``... ``argparse`` is not very test friendly since it prints directly to ``sys.stderr``... – Viktor Kerkez Feb 06 '15 at 14:02
  • Hm, well my edit was rejected, but I get an error when I pass sys.argv to parser.parse_args() which is cleared when I remove the filename value from the first position in sys.argv. Let me know if you do not, and thanks. – kporter Apr 04 '15 at 01:09
  • 1
    @ViktorKerkez You may be able to mock sys.stderr to check for a specific message, either mock.assert_called_with or by examining mock_calls, see https://docs.python.org/3/library/unittest.mock.html for more detail. See also http://stackoverflow.com/questions/6271947/how-can-i-simulate-input-to-stdin-for-pyunit for an example of mocking stdin. (stderr should be similar) – BryCoBat Jul 23 '15 at 11:56
  • @kporter You can ask a separate question, but I believe argparse expects you to drop that first filename argument. – jtpereyda Feb 18 '16 at 18:38
  • 1
    @PratikKhadloya You can mock or modify stderr in order to capture the output. See this question: http://stackoverflow.com/questions/18651705/argparse-unit-tests-suppress-the-help-message – jtpereyda Feb 18 '16 at 18:39
  • or `def parse_args(args=sys.argv[1:]):` so you don't have to change how the function is called from `main()` – confused00 Aug 25 '16 at 19:59
  • I was to say there a flag you can give argparse to throw exceptions instead of halting the program. Its also possible to overload sys.stderr with a `StringIO` instance, unless it opens its own `/dev/stdout` descriptor, in which case you _might_ be able to overload argparses file descriptor in its module, or overload the functions that use it. It can almost certainly be done. Python is amazing that way <3 – ThorSummoner Nov 30 '16 at 22:36
  • @PratikKhadloya I think I may have found a solution for you http://stackoverflow.com/a/40916320/4047679 – raphael Dec 01 '16 at 17:06
  • 5
    I don't think it's necessary to call the ``ArgumentParser.parse_args``-method calls with parameter ``args=sys.argv[1:]``. It already calls the ``ArgumentParser.parse_known_args``-method. With argument ``args==None`` it will obtain them with ``args = _sys.argv[1:]`` where ``_sys`` is an alias for ``sys``. (It might be an update since the answer was posted.) – Thomas Fauskanger Dec 14 '17 at 01:20
  • How would I test 2 arguments? Or 3? I can't tell from this example. – Steve3p0 Jan 15 '19 at 07:23
  • It raises a SystemExit exception. And barfs some to stderr. – Andy Hayden Mar 18 '19 at 00:53
  • 1
    @PratikKhadloya see my answer for handling/testing errors https://stackoverflow.com/a/55234595/1240268 – Andy Hayden Mar 19 '19 at 07:44
  • Does it mean I have to modify my original python script? – wawawa Apr 20 '20 at 09:35
  • 5
    In response to @thomas-fauskanger above: `parse_args(args)` allows you to pass in the args from the test - which is the intent here. Of and by itself `parse_args()` will work without the `sys.argv[1:] ` being passed in from `main()`. This is super helpful by the way. – basswaves Mar 24 '21 at 21:41
  • 2
    @basswaves I believe that thomas is suggesting calling `parser = parse_args(None)` rather than `parser = parse_args(sys.argv[1:])` in the main function, as both yield the same result (this is the behaviour when parse_args is given `None`). – Kraigolas Jan 15 '22 at 19:41
  • I wrote up a complete example based on the discussion here, thanks: https://hughesadam87.medium.com/dead-simple-pytest-and-argparse-d1dbb6affbc3 – Adam Hughes Aug 04 '23 at 17:27
54

"argparse portion" is a bit vague so this answer focuses on one part: the parse_args method. This is the method that interacts with your command line and gets all the passed values. Basically, you can mock what parse_args returns so that it doesn't need to actually get values from the command line. The mock package can be installed via pip for python versions 2.6-3.2. It's part of the standard library as unittest.mock from version 3.3 onwards.

import argparse
try:
    from unittest import mock  # python 3.3+
except ImportError:
    import mock  # python 2.6-3.2


@mock.patch('argparse.ArgumentParser.parse_args',
            return_value=argparse.Namespace(kwarg1=value, kwarg2=value))
def test_command(mock_args):
    pass

You have to include all your command method's args in Namespace even if they're not passed. Give those args a value of None. (see the docs) This style is useful for quickly doing testing for cases where different values are passed for each method argument. If you opt to mock Namespace itself for total argparse non-reliance in your tests, make sure it behaves similarly to the actual Namespace class.

Below is an example using the first snippet from the argparse library.

# test_mock_argparse.py
import argparse
try:
    from unittest import mock  # python 3.3+
except ImportError:
    import mock  # python 2.6-3.2


def main():
    parser = argparse.ArgumentParser(description='Process some integers.')
    parser.add_argument('integers', metavar='N', type=int, nargs='+',
                        help='an integer for the accumulator')
    parser.add_argument('--sum', dest='accumulate', action='store_const',
                        const=sum, default=max,
                        help='sum the integers (default: find the max)')

    args = parser.parse_args()
    print(args)  # NOTE: this is how you would check what the kwargs are if you're unsure
    return args.accumulate(args.integers)


@mock.patch('argparse.ArgumentParser.parse_args',
            return_value=argparse.Namespace(accumulate=sum, integers=[1,2,3]))
def test_command(mock_args):
    res = main()
    assert res == 6, "1 + 2 + 3 = 6"


if __name__ == "__main__":
    print(main())
munsu
  • 1,914
  • 19
  • 24
  • 1
    But now your unittest code also depends on `argparse` and its `Namespace` class. You should mock `Namespace`. – imrek May 13 '17 at 17:03
  • 1
    @DrunkenMaster apologies for the snarky tone. I updated my answer with explanation and possible uses. I'm learning here as well so if you would, can you (or someone else) provide cases where mocking the return value is beneficial? (or at least cases where *not* mocking the return value is detrimental) – munsu May 15 '17 at 03:11
  • 1
    `from unittest import mock` is now the correct import method - well at least for python3 – Michael Hall Nov 09 '18 at 17:11
  • 1
    @MichaelHall thanks. I updated the snippet and added contextual info. – munsu Nov 11 '18 at 23:49
  • @munsu I ended up doing https://stackoverflow.com/a/55234595/1240268. With this answer it does not actually call parse_args, which seems suboptimal. – Andy Hayden Mar 19 '19 at 06:08
  • 2
    The use of the `Namespace` class here is exactly what I was looking for. Despite the test still relying on `argparse`, it does not rely on the particular implementation of `argparse` by the code under test, which is important for my unit tests. In addition, it's easy to use `pytest`'s `parametrize()` method to quickly test various argument combinations with a templated mock that includes `return_value=argparse.Namespace(accumulate=accumulate, integers=integers)`. – acetone Aug 28 '19 at 21:14
  • Upvote for using @mock.patch(), IMHO most flexible way now. – Leo Skhrnkv Feb 20 '20 at 20:48
  • How would you modify the `Namespace` call to account for providing a file path that usually would be provided in a manner `self.file_path = str(importlib.resources.files('tests').joinpath('some_file_for_testing.txts')` within `setUp` call? – Konrad Jun 19 '23 at 15:26
32

Make your main() function take argv as an argument rather than letting it read from sys.argv as it will by default:

# mymodule.py
import argparse
import sys


def main(args):
    parser = argparse.ArgumentParser()
    parser.add_argument('-a')
    process(**vars(parser.parse_args(args)))
    return 0


def process(a=None):
    pass

if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

Then you can test normally.

import mock

from mymodule import main


@mock.patch('mymodule.process')
def test_main(process):
    main([])
    process.assert_call_once_with(a=None)


@mock.patch('foo.process')
def test_main_a(process):
    main(['-a', '1'])
    process.assert_call_once_with(a='1')
Ceasar
  • 22,185
  • 15
  • 64
  • 83
21

I did not want to modify the original serving script so I just mocked out the sys.argv part in argparse.

from unittest.mock import patch

with patch('argparse._sys.argv', ['python', 'serve.py']):
    ...  # your test code here

This breaks if argparse implementation changes but enough for a quick test script. Sensibility is much more important than specificity in test scripts anyways.

김민준
  • 937
  • 11
  • 14
  • I love this solution, but when I execute my script, `'python'` isn't part of `sys.argv`. The first argument is only the script name. I think the mocked return value in your answer should be only `['serve.py']`. – supermitch May 08 '23 at 17:39
12

parse_args throws a SystemExit and prints to stderr, you can catch both of these:

import contextlib
import io
import sys

@contextlib.contextmanager
def captured_output():
    new_out, new_err = io.StringIO(), io.StringIO()
    old_out, old_err = sys.stdout, sys.stderr
    try:
        sys.stdout, sys.stderr = new_out, new_err
        yield sys.stdout, sys.stderr
    finally:
        sys.stdout, sys.stderr = old_out, old_err

def validate_args(args):
    with captured_output() as (out, err):
        try:
            parser.parse_args(args)
            return True
        except SystemExit as e:
            return False

You inspect stderr (using err.seek(0); err.read() but generally that granularity isn't required.

Now you can use assertTrue or whichever testing you like:

assertTrue(validate_args(["-l", "-m"]))

Alternatively you might like to catch and rethrow a different error (instead of SystemExit):

def validate_args(args):
    with captured_output() as (out, err):
        try:
            return parser.parse_args(args)
        except SystemExit as e:
            err.seek(0)
            raise argparse.ArgumentError(err.read())
Andy Hayden
  • 359,921
  • 101
  • 625
  • 535
  • Can't believe this answer, which IMHO addresses the real problem with testing `argparse`, is buried so far down. Thanks for these details! – Liedman May 29 '23 at 11:24
11
  1. Populate your arg list by using sys.argv.append() and then call parse(), check the results and repeat.
  2. Call from a batch/bash file with your flags and a dump args flag.
  3. Put all your argument parsing in a separate file and in the if __name__ == "__main__": call parse and dump/evaluate the results then test this from a batch/bash file.
Steve Barnes
  • 27,618
  • 6
  • 63
  • 73
9

A simple way of testing a parser is:

parser = ...
parser.add_argument('-a',type=int)
...
argv = '-a 1 foo'.split()  # or ['-a','1','foo']
args = parser.parse_args(argv)
assert(args.a == 1)
...

Another way is to modify sys.argv, and call args = parser.parse_args()

There are lots of examples of testing argparse in lib/test/test_argparse.py

hpaulj
  • 221,503
  • 14
  • 230
  • 353
4

When passing results from argparse.ArgumentParser.parse_args to a function, I sometimes use a namedtuple to mock arguments for testing.

import unittest
from collections import namedtuple
from my_module import main

class TestMyModule(TestCase):

    args_tuple = namedtuple('args', 'arg1 arg2 arg3 arg4')

    def test_arg1(self):
        args = TestMyModule.args_tuple("age > 85", None, None, None)
        res = main(args)
        assert res == ["55289-0524", "00591-3496"], 'arg1 failed'

    def test_arg2(self):
        args = TestMyModule.args_tuple(None, [42, 69], None, None)
        res = main(args)
        assert res == [], 'arg2 failed'

if __name__ == '__main__':
    unittest.main()
guest
  • 41
  • 1
4

For testing CLI (command line interface), and not command output I did something like this

import pytest
from argparse import ArgumentParser, _StoreAction

ap = ArgumentParser(prog="cli")
ap.add_argument("cmd", choices=("spam", "ham"))
ap.add_argument("-a", "--arg", type=str, nargs="?", default=None, const=None)
...

def test_parser():
    assert isinstance(ap, ArgumentParser)
    assert isinstance(ap, list)
    args = {_.dest: _ for _ in ap._actions if isinstance(_, _StoreAction)}
    
    assert args.keys() == {"cmd", "arg"}
    assert args["cmd"] == ("spam", "ham")
    assert args["arg"].type == str
    assert args["arg"].nargs == "?"
    ...
vczm
  • 574
  • 6
  • 14
0

In addition to many good answers...

In my case I should provide my parameters to function where parameters are parsed, e.g.:

# main.py
import argparse

def get_myparam():
    parser = argparse.ArgumentParser()
    parser.add_argument('--myparam', type=str, default='myvalue')
    args = parser.parse_args()
    return args.myparam

if __name__ == '__main__':
    print(f'main: myparam={get_myparam()}')

Example of output:

$ python main.py
main: myparam=myvalue

$ python main.py --myparam newvalue
main: myparam=newvalue

pytest test example:

# test_main.py
import argparse

import main


def test_mock_params(mocker):
    mocker.patch('argparse.ArgumentParser.parse_args',
                 return_value=argparse.Namespace(myparam='mocked',))
    assert main.get_myparam() == 'mocked'

For mocker you need to install pytest-mock:

$ pip install pytest-mock
Maxim Suslov
  • 4,335
  • 1
  • 35
  • 29
0

Minmal complete example from my blog post (https://hughesadam87.medium.com/dead-simple-pytest-and-argparse-d1dbb6affbc3)

# coolapp.py
import argparse as ap
import sys

def _parse(args) -> ap.Namespace:
    parser = ap.ArgumentParser() 
    parser.add_argument("myfile")
    parsed = parser.parse_args(args)
    start(parsed.myfile


def start(myfile) -> None:
    print(f'my file: {myfile}')


if __name__ == "__main__":
    _parse(sys.argv[1:])

Some tests

#test_coolapp.py
from coolapp import start, _parse
import sys

def test_coolapp():
    """ Direct import of start """
    start("myfile.txt")

def test_coolapp_sysargs():
    """ Called through __main__ (eg. python coolapp.py myfile.txt) """
    _parse(['myfile.txt'])

def test_coolapp_no_args(capsys):
    """ ie. python coolapp.py """
    with pytest.raises(SystemExit):
        _parse([])
    captured = capsys.readouterr()
    assert "the following arguments are required: myfile" in captured.err

def test_coolapp_extra_args(capsys):
    """ ie. python coolapp.py arg1 arg2 """
    with pytest.raises(SystemExit):
        _parse(['arg1', 'arg2'])
    captured = capsys.readouterr()
    assert "unrecognized arguments: arg2" in captured.err
Adam Hughes
  • 14,601
  • 12
  • 83
  • 122