96

I would like to set

sys.argv

so I can unit test passing in different combinations. The following doesn't work:

#!/usr/bin/env python
import argparse, sys

def test_parse_args():
    global sys.argv
    sys.argv = ["prog", "-f", "/home/fenton/project/setup.py"]
    setup = get_setup_file()
    assert setup == "/home/fenton/project/setup.py"

def get_setup_file():
    parser = argparse.ArgumentParser()
    parser.add_argument('-f')
    args = parser.parse_args()
    return args.file

if __name__ == '__main__':
    test_parse_args()

Then running the file:

pscripts % ./test.py                                                                                           
  File "./test.py", line 4
    global sys.argv
              ^
SyntaxError: invalid syntax
pscripts %  
Neuron
  • 5,141
  • 5
  • 38
  • 59
ftravers
  • 3,809
  • 3
  • 37
  • 38
  • What about it "doesn't work"? – Nick Olson-Harris Sep 07 '13 at 02:09
  • 3
    You didn't call `get_setup_file`. You don't need `global sys.argv`. You do need `import sys`. – Pavel Anossov Sep 07 '13 at 02:10
  • The syntax `"~/project/setup.py"` doesn't work because it's the shell that [expands tildes](http://www.gnu.org/software/bash/manual/bashref.html#Tilde-Expansion), not the OS. You need to do that expansion yourself, e.g. `os.path.join(os.getenv("HOME"), "project/setup.py")` to get what you want. – Adam Rosenfield Sep 07 '13 at 02:11
  • 6
    @ftravers: You really shouldn't have applied the answer to the code in your question because now others can't see what the problem was. It's better to just accept the answer that solves your problem best. – martineau Sep 07 '13 at 02:30
  • @martineau, arguable. I do like just having a simple, complete, fairly useful snippet which addresses the problem, which is stated at the top...how do i unit test argparse. If I could, I'd erase all the rest of everything here and just have that...cause the rest seems like noise. I don't really see the benefit of putting up my confusion...sorry if I'm going against the grain of StackOverflow??? – ftravers Sep 07 '13 at 02:41
  • 4
    Yes, it's "going against the grain" because someone with the same confusion won't be able to benefit quite as much. – martineau Sep 07 '13 at 02:43
  • 1
    @ftravers: A good place to put (and source of) snippets like that is [ActiveState Code » Recipes](http://code.activestate.com/recipes/langs/python/), whereas here the format is different with much more in the way of constructive feedback and interaction. – martineau Sep 07 '13 at 10:51
  • Please consider selecting the correct answer. I found @Jason Antman's reply to be the most detailed, most helpful, and most accurate. – Felipe Alvarez Jan 12 '22 at 00:19

9 Answers9

125

Changing sys.argv at runtime is a pretty fragile way of testing. You should use mock's patch functionality, which can be used as a context manager to substitute one object (or attribute, method, function, etc.) with another, within a given block of code.

The following example uses patch() to effectively "replace" sys.argv with the specified return value (testargs).

try:
    # python 3.4+ should use builtin unittest.mock not mock package
    from unittest.mock import patch
except ImportError:
    from mock import patch

def test_parse_args():
    testargs = ["prog", "-f", "/home/fenton/project/setup.py"]
    with patch.object(sys, 'argv', testargs):
        setup = get_setup_file()
        assert setup == "/home/fenton/project/setup.py"
Neuron
  • 5,141
  • 5
  • 38
  • 59
Jason Antman
  • 2,620
  • 2
  • 24
  • 26
  • 16
    To do the same thing with the standard library, the syntax would be `with unittest.mock.patch('sys.argv'. [stuff])` – dbaston Nov 09 '15 at 19:50
  • yeah, unittest.mock is built-in on recent py3 versions. Updated the code example to take that into account. – Jason Antman Mar 25 '18 at 15:08
  • How do I refactor this to use in `setup_method` across multiple tests? – aandis Aug 07 '18 at 16:24
  • @dbaston, can you explain what is that `.` between `'sys.argv'. [stuff]` is doing? Fail to find it in the documentation https://docs.python.org/3/library/unittest.mock.html#unittest.mock.patch. – Jia Gao Sep 20 '21 at 09:06
  • 3
    @JasonGoal I think they mistyped a comma (`,`) – xjcl Jan 26 '22 at 22:46
20

test_argparse.py, the official argparse unittest file, uses several means of setting/using argv:

parser.parse_args(args)

where args is a list of 'words', e.g. ['--foo','test'] or --foo test'.split().

old_sys_argv = sys.argv
sys.argv = [old_sys_argv[0]] + args
try:
    return parser.parse_args()
finally:
    sys.argv = old_sys_argv

This pushes the args onto sys.argv.

I just came across a case (using mutually_exclusive_groups) where ['--foo','test'] produces different behavior than '--foo test'.split(). It's a subtle point involving the id of strings like test.

Sam Shleifer
  • 1,716
  • 2
  • 18
  • 29
hpaulj
  • 221,503
  • 14
  • 230
  • 353
9

global only exposes global variables within your module, and sys.argv is in sys, not your module. Rather than using global sys.argv, use import sys.

You can avoid having to change sys.argv at all, though, quite simply: just let get_setup_file optionally take a list of arguments (defaulting to None) and pass that to parse_args. When get_setup_file is called with no arguments, that argument will be None, and parse_args will fall back to sys.argv. When it is called with a list, it will be used as the program arguments.

icktoofay
  • 126,289
  • 21
  • 250
  • 231
  • Great idea, but I don't know how to force 'parser.parse_args()' to take a parameter, versus ALWAYS use sys.argv. Ideas??? Seems really stupid to not be able to take an arg. – ftravers Sep 07 '13 at 02:29
  • 3
    @ftravers: But [it *does* take a parameter](http://docs.python.org/3.3/library/argparse.html#argparse.ArgumentParser.parse_args). If you look at all the examples in the documentation, almost *all* of them pass `parse_args` a list. – icktoofay Sep 07 '13 at 02:31
  • Okay thanks, m a python newbie, thanks for showing where the docs were. Also updated the code. – ftravers Sep 07 '13 at 02:36
  • 2
    @ftravers: I should note that your `if` statement is not necessary; `parse_args` *itself* will deal with `None` and replace it with `sys.argv` if so. – icktoofay Sep 07 '13 at 02:45
7

I like to use unittest.mock.patch(). The difference to patch.object() is that you don't need a direct reference to the object you want to patch but use a string.

from unittest.mock import patch

with patch("sys.argv", ["file.py", "-h"]):
    print(sys.argv)
xjcl
  • 12,848
  • 6
  • 67
  • 89
1

It doesn't work because you're not actually calling get_setup_file. Your code should read:

import argparse

def test_parse_args():
    sys.argv = ["prog", "-f", "/home/fenton/project/setup.py"]
    setup = get_setup_file()  # << You need the parentheses
    assert setup == "/home/fenton/project/setup.py"
Ian Stapleton Cordasco
  • 26,944
  • 4
  • 67
  • 72
1

I achieved this by creating an execution manager that would set the args of my choice and remove them upon exit:

import sys    


class add_resume_flag(object):
    def __enter__(self):
        sys.argv.append('--resume')

    def __exit__(self, typ, value, traceback):
        sys.argv = [arg for arg in sys.argv if arg != '--resume']

class MyTestClass(unittest.TestCase):

    def test_something(self):
        with add_resume_flag():
            ...
progfan
  • 2,454
  • 3
  • 22
  • 28
0

You'll normally have command arguments. You need to test them. Here is how to unit test them.

  • Assume program may be run like: % myprogram -f setup.py

  • We create a list to mimic this behaviour. See line (4)

  • Then our method that parses args, takes an array as an argument that is defaulted to None. See line (7)
  • Then on line (11) we pass this into parse_args, which uses the array if it isn't None. If it is None then it defaults to using sys.argv.
    1: #!/usr/bin/env python
    2: import argparse
    3: def test_parse_args():
    4:     my_argv = ["-f", "setup.py"]
    5:     setup = get_setup_file(my_argv)
    6:     assert setup == "setup.py"
    7: def get_setup_file(argv=None):
    8:     parser = argparse.ArgumentParser()
    9:     parser.add_argument('-f')
    10:     # if argv is 'None' then it will default to looking at 'sys.argv'        
    11:     args = parser.parse_args(argv) 
    12:     return args.f
    13: if __name__ == '__main__':
    14:     test_parse_args()
ftravers
  • 3,809
  • 3
  • 37
  • 38
0

Very good question.

The trick to setting up unit tests is all about making them repeatable. This means that you have to eliminate the variables, so that the tests are repeatable. For example, if you are testing a function that must perform correctly given the current date, then force it to work for specific dates, where the date chosen does not matter, but the chosen dates match in type and range to the real ones.

Here sys.argv will be an list of length at least one. So create a "fakemain" that gets called with a list. Then test for the various likely list lengths, and contents. You can then call your fake main from the real one passing sys.argv, knowing that fakemain works, or alter the "if name..." part to do perform the normal function under non-unit testing conditions.

Fred Mitchell
  • 2,145
  • 2
  • 21
  • 29
  • I'm not sure what you mean by repeatable...do you mean automateable/scriptable? If so yes that's essential. What defines a unit test is the ability to unhook it from it's context and provide it's expected context. In this case, I cannot run each test by hand, individually passing arguments. I want to pass an equivalent of sys.argv, the expected context, to the test. – ftravers Sep 07 '13 at 03:48
0

You can attach a wrapper around your function, which prepares sys.argv before calling and restores it when leaving:

def run_with_sysargv(func, sys_argv):
""" prepare the call with given sys_argv and cleanup afterwards. """
    def patched_func(*args, **kwargs):
        old_sys_argv = list(sys.argv)
        sys.argv = list(sys_argv)
        try:
            return func(*args, **kwargs)
        except Exception, err:
            sys.argv = old_sys_argv
            raise err
    return patched_func

Then you can simply do

def test_parse_args():
    _get_setup_file = run_with_sysargv(get_setup_file, 
                                       ["prog", "-f", "/home/fenton/project/setup.py"])
    setup = _get_setup_file()
    assert setup == "/home/fenton/project/setup.py"

Because the errors are passed correctly, it should not interfere with external instances using the testing code, like pytest.

Neuron
  • 5,141
  • 5
  • 38
  • 59
flonk
  • 3,726
  • 3
  • 24
  • 37