如何为Python模块的argparse部分编写测试?

265

我有一个使用argparse库的Python模块。如何为代码库中的该部分编写测试?


argparse是一个命令行界面。编写测试以通过命令行调用应用程序。 - Homer6
2
你的问题让人难以理解你想测试什么。我猜测最终目的是,例如:“当我使用命令行参数X、Y、Z时,函数foo()被调用”。如果是这种情况,那么模拟sys.argv就是答案。可以查看Python包cli-test-helpers。另请参阅https://dev59.com/52Yr5IYBdhLWcg3wqr7o#58594599 - Peterino
我在这个帖子的讨论基础上写了一篇博客文章,其中包含了完整的示例。链接在这里:https://hughesadam87.medium.com/dead-simple-pytest-and-argparse-d1dbb6affbc3 - Adam Hughes
11个回答

361

你应该重构你的代码并将解析操作封装成一个函数:

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

然后在您的main函数中,您只需使用以下方式调用它:

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

(其中sys.argv的第一个元素表示脚本名称,已被删除以避免在CLI操作期间将其作为附加开关发送。)

在您的测试中,您可以使用任何想要测试的参数列表调用解析器函数:

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

通过这种方式,您将永远不需要执行应用程序的代码来测试解析器。

如果您需要在应用程序中稍后更改和/或添加选项到解析器,则创建一个工厂方法:

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

如果您想要,稍后可以对其进行操作,测试可能如下所示:

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')

5
谢谢您的提问。当未传递特定参数时,我们如何测试错误? - Pratik Khadloya
4
如果需要参数但未传入,argparse将引发异常。 - Viktor Kerkez
3
是的,不幸的是这个信息并没有什么帮助 :( 它只是“2”... 因为argparse会直接打印到sys.stderr,所以它并不非常适合测试。 - Viktor Kerkez
5
我认为没有必要在调用“ArgumentParser.parse_args”方法时使用参数“args=sys.argv[1:]”。它已经调用了“ArgumentParser.parse_known_args”方法。如果参数“args==None”,它将使用“args=_sys.argv[1:]”获取它们,其中“_sys”是“sys”的别名。(这可能是自回答发布以来的更新。) - Thomas Fauskanger
5
回应@thomas-fauskanger上面的内容:parse_args(args)允许您传入测试中的args参数-这是本意。而且,parse_args()本身可以正常工作,而不需要从main()中传递sys.argv[1:]参数。顺便说一下,这非常有帮助。 - basswaves
显示剩余14条评论

56
“argparse部分”有点模糊,因此本答案重点关注一个部分: parse_args方法。这是与您的命令行交互并获取所有传递值的方法。基本上,您可以模拟parse_args返回的值,以便它不需要实际从命令行获取值。mockpackage可以通过pip在python 2.6-3.2版本中安装。从3.3版本开始,它是标准库的一部分,名称为unittest.mock
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

你必须在Namespace中包含所有命令方法的参数,即使它们没有被传递。将这些参数赋值为None。(请参阅文档)这种风格对于快速测试不同值传递到每个方法参数的情况非常有用。如果您选择模拟Namespace本身以完全不依赖argparse进行测试,请确保其行为类似于实际的Namespace类。
下面是使用argparse库中第一个片段的示例。
# 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())

1
但是现在你的unittest代码也依赖于argparse和它的Namespace类。你应该模拟Namespace - imrek
1
@DrunkenMaster,抱歉语气有些尖锐。我更新了我的回答并解释了可能的用途。我也在这里学习,如果可以的话,能否请你(或其他人)提供模拟返回值有益的情况?(或者至少是 模拟返回值有害的情况) - munsu
1
现在,从unittest模块中导入mock类是正确的导入方式——至少对于Python3而言。 - Michael Hall
1
@MichaelHall 谢谢。我更新了代码片段并添加了上下文信息。 - munsu
2
在这里使用Namespace类恰好是我正在寻找的。尽管测试仍然依赖于argparse,但它不依赖于被测试代码的特定实现,这对我的单元测试非常重要。此外,很容易使用pytestparametrize()方法来快速测试各种参数组合,其中包括return_value = argparse.Namespace(accumulate = accumulate, integers = integers)的模板化模拟。 - acetone
显示剩余3条评论

32

让你的main()函数接受argv作为一个参数,而不是默认从sys.argv中读取参数。具体请参考这里

# 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:]))

然后您可以正常测试。

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')

21

我不想修改原来的服务脚本,所以我只是在argparse中模拟了sys.argv这一部分。

from unittest.mock import patch

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

如果 argparse 实现发生更改,这将失效,但对于快速测试脚本已足够。在任何情况下,测试脚本中的合理性比特定性更重要。


我喜欢这个解决方案,但是当我执行我的脚本时,'python' 不是 sys.argv 的一部分。第一个参数只是脚本名称。我认为你答案中模拟的返回值应该只是 ['serve.py'] - supermitch

13

parse_args会抛出SystemExit并打印到stderr,你可以捕获它们:

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

您可以检查stderr(使用err.seek(0); err.read()),但通常不需要那么详细。

现在,您可以使用assertTrue或其他测试方法:

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

或者你可能想要捕获并重新抛出不同的错误(而不是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())

1
真没想到这个回答,我认为它解决了测试“argparse”时的实际问题,但是却被埋没得这么深。感谢提供这些细节! - Liedman

11
  1. 使用sys.argv.append()填充您的参数列表,然后调用parse(),检查结果并重复执行。
  2. 使用您的标志和一个dump args标志从批处理/ bash文件中调用。
  3. 将所有参数解析放入单独的文件中,并在if __name__ == "__main__":中调用parse和dump /评估结果,然后从批处理/ bash文件中测试此文件。

9

测试解析器的简单方法如下:

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)
...

另一种方法是修改 sys.argv,然后调用 args = parser.parse_args()

lib/test/test_argparse.py 中有许多测试 argparse 的示例


这应该是被接受的答案:测试给每个参数赋予不同值的最简单方法。 - Positive Navid

4

当将argparse.ArgumentParser.parse_args的结果传递给函数时,我有时使用namedtuple来模拟测试参数。

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()

4

为了测试命令行界面(CLI)而不是命令输出,我做了类似于这样的事情

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 == "?"
    ...

2
从我的博客文章中提供一个简单的完整示例(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:])

一些测试

#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

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接