使用unittest测试argparse - 退出错误

13

参考Greg Haskin在这个问题中的回答,我尝试编写一个单元测试来检查当我传递一些不在choices中的参数时,argparse是否会给出适当的错误。然而,使用下面的try/except语句,unittest生成了一个错误的结果。

此外,当我只使用with assertRaises语句进行测试时,argparse会强制退出系统,程序将不再执行任何其他测试。

虽然argparse遇到错误时会退出,但我仍希望能够对其进行测试,这样可能有些冗余吗?

#!/usr/bin/env python3

import argparse
import unittest

class sweep_test_case(unittest.TestCase):
    """Tests that the merParse class works correctly"""

    def setUp(self):
        self.parser=argparse.ArgumentParser()
        self.parser.add_argument(
            "-c", "--color",
            type=str,
            choices=["yellow", "blue"],
            required=True)

    def test_required_unknown_TE(self):
        """Try to perform sweep on something that isn't an option.
        Should return an attribute error if it fails.
        This test incorrectly shows that the test passed, even though that must
        not be true."""
        args = ["--color", "NADA"]
        try:
            self.assertRaises(argparse.ArgumentError, self.parser.parse_args(args))
        except SystemExit:
            print("should give a false positive pass")

    def test_required_unknown(self):
        """Try to perform sweep on something that isn't an option.
        Should return an attribute error if it fails.
        This test incorrectly shows that the test passed, even though that must
        not be true."""
        args = ["--color", "NADA"]
        with self.assertRaises(argparse.ArgumentError):
            self.parser.parse_args(args)

if __name__ == '__main__':
    unittest.main()

错误:

Usage: temp.py [-h] -c {yellow,blue}
temp.py: error: argument -c/--color: invalid choice: 'NADA' (choose from 'yellow', 'blue')
E
usage: temp.py [-h] -c {yellow,blue}
temp.py: error: argument -c/--color: invalid choice: 'NADA' (choose from 'yellow', 'blue')
should give a false positive pass
.
======================================================================
ERROR: test_required_unknown (__main__.sweep_test_case)
Try to perform sweep on something that isn't an option.
----------------------------------------------------------------------
Traceback (most recent call last): #(I deleted some lines)
  File "/Users/darrin/anaconda/lib/python3.5/argparse.py", line 2310, in _check_value
    raise ArgumentError(action, msg % args)
argparse.ArgumentError: argument -c/--color: invalid choice: 'NADA' (choose from 'yellow', 'blue')

During handling of the above exception, another exception occurred:

Traceback (most recent call last): #(I deleted some lines)
  File "/anaconda/lib/python3.5/argparse.py", line 2372, in exit
    _sys.exit(status)
SystemExit: 2

test/test_argparse.py 单元测试文件包含了大量的示例,因为它测试了模块的大部分功能。sys.exit 需要特殊处理。 - hpaulj
谢谢@hpaulj,我在哪里可以找到我的系统上的那个文件?我在这里找到了我认为你在谈论的内容 - conchoecia
是的,那就是那个文件。你可能需要一个开发版本的Python才能在自己的电脑上找到它。请寻找Lib/test目录。但是从存储库下载也可以。大多数基于ParserTestCase构建的测试不用担心错误消息;只要看情况是否运行即可。文件向下进行的测试会查看错误消息。 - hpaulj
7个回答

24
这里的技巧是捕获 SystemExit 而不是 ArgumentError。以下是重写后捕获 SystemExit 的测试代码:
#!/usr/bin/env python3

import argparse
import unittest

class SweepTestCase(unittest.TestCase):
    """Tests that the merParse class works correctly"""

    def setUp(self):
        self.parser=argparse.ArgumentParser()
        self.parser.add_argument(
            "-c", "--color",
            type=str,
            choices=["yellow", "blue"],
            required=True)

    def test_required_unknown(self):
        """ Try to perform sweep on something that isn't an option. """
        args = ["--color", "NADA"]
        with self.assertRaises(SystemExit):
            self.parser.parse_args(args)

if __name__ == '__main__':
    unittest.main()

现在已经正确运行,测试通过:

$ python scratch.py
usage: scratch.py [-h] -c {yellow,blue}
scratch.py: error: argument -c/--color: invalid choice: 'NADA' (choose from 'yellow', 'blue')
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

OK

然而,您可以看到使用消息被打印出来了,所以您的测试输出有点混乱。检查使用消息是否包含“无效选择”也是不错的选择。
您可以通过修补sys.stderr来实现:
#!/usr/bin/env python3

import argparse
import unittest
from io import StringIO
from unittest.mock import patch


class SweepTestCase(unittest.TestCase):
    """Tests that the merParse class works correctly"""

    def setUp(self):
        self.parser=argparse.ArgumentParser()
        self.parser.add_argument(
            "-c", "--color",
            type=str,
            choices=["yellow", "blue"],
            required=True)

    @patch('sys.stderr', new_callable=StringIO)
    def test_required_unknown(self, mock_stderr):
        """ Try to perform sweep on something that isn't an option. """
        args = ["--color", "NADA"]
        with self.assertRaises(SystemExit):
            self.parser.parse_args(args)
        self.assertRegexpMatches(mock_stderr.getvalue(), r"invalid choice")


if __name__ == '__main__':
    unittest.main()

现在你只能看到常规的测试报告:
$ python scratch.py
.
----------------------------------------------------------------------
Ran 1 test in 0.002s

OK

对于pytest的用户,下面是不检查消息的等效方法。
import argparse

import pytest


def test_required_unknown():
    """ Try to perform sweep on something that isn't an option. """
    parser=argparse.ArgumentParser()
    parser.add_argument(
        "-c", "--color",
        type=str,
        choices=["yellow", "blue"],
        required=True)
    args = ["--color", "NADA"]

    with pytest.raises(SystemExit):
        parser.parse_args(args)

Pytest默认捕获标准输出(stdout)和标准错误(stderr),因此不会影响测试报告。

$ pytest scratch.py
================================== test session starts ===================================
platform linux -- Python 3.6.7, pytest-3.5.0, py-1.7.0, pluggy-0.6.0
rootdir: /home/don/.PyCharm2018.3/config/scratches, inifile:
collected 1 item                                                                         

scratch.py .                                                                       [100%]

================================ 1 passed in 0.01 seconds ================================

您也可以使用pytest检查stdout/stderr内容:

import argparse

import pytest


def test_required_unknown(capsys):
    """ Try to perform sweep on something that isn't an option. """
    parser=argparse.ArgumentParser()
    parser.add_argument(
        "-c", "--color",
        type=str,
        choices=["yellow", "blue"],
        required=True)
    args = ["--color", "NADA"]

    with pytest.raises(SystemExit):
        parser.parse_args(args)

    stderr = capsys.readouterr().err
    assert 'invalid choice' in stderr

通常情况下,我发现使用pytest更容易,但是你可以在任意一种测试框架中使用它。


感谢您的详尽解释!这真的很有用! - noobie2023

5
尽管解析器在解析特定参数时可能会引发ArgumentError,但通常会被捕获并传递给parser.error和parse.exit。结果是打印用法及错误消息,然后sys.exit(2)。
因此,使用asserRaises方法来测试argparse中的这种错误并不是一种好的方法。模块的单元测试文件test/test_argparse.py有一种复杂的方法来解决这个问题,即通过子类化ArgumentParser、重新定义其error方法并重定向输出来实现。 parser.parse_known_args(由parse_args调用)以以下方式结束:
    try:
        namespace, args = self._parse_known_args(args, namespace)
        if hasattr(namespace, _UNRECOGNIZED_ARGS_ATTR):
            args.extend(getattr(namespace, _UNRECOGNIZED_ARGS_ATTR))
            delattr(namespace, _UNRECOGNIZED_ARGS_ATTR)
        return namespace, args
    except ArgumentError:
        err = _sys.exc_info()[1]
        self.error(str(err))

这个测试怎么样(我借鉴了test_argparse.py中的几个想法):

import argparse
import unittest

class ErrorRaisingArgumentParser(argparse.ArgumentParser):
    def error(self, message):
        #print(message)
        raise ValueError(message)  # reraise an error

class sweep_test_case(unittest.TestCase):
    """Tests that the Parse class works correctly"""

    def setUp(self):
        self.parser=ErrorRaisingArgumentParser()
        self.parser.add_argument(
            "-c", "--color",
            type=str,
            choices=["yellow", "blue"],
            required=True)

    def test_required_unknown(self):
        """Try to perform sweep on something that isn't an option.
        Should pass"""
        args = ["--color", "NADA"]
        with self.assertRaises(ValueError) as cm:
            self.parser.parse_args(args)
        print('msg:',cm.exception)
        self.assertIn('invalid choice', str(cm.exception))

if __name__ == '__main__':
    unittest.main()

使用run命令:

1931:~/mypy$ python3 stack39028204.py 
msg: argument -c/--color: invalid choice: 'NADA' (choose from 'yellow', 'blue')
.
----------------------------------------------------------------------
Ran 1 test in 0.002s

OK

谢谢,这非常有帮助。 - conchoecia
ErrorRaisingArgumentParser 中,也许只需重新引发异常而不是将其转换为 ValueError。将 raise ValueError(message) 更改为 raise sys.exc_info()[1] - ebergerson
@ebergerson,一时半会看起来还不错,但真正的证明是它是否有效。 - hpaulj
@hpaulj 我正在使用它,而且它可以正常工作。至少在Mac上,Python 3.6.4可以。 - ebergerson

2
通过阅读上面很多出色的回答,我发现在setUp方法中,我们创建了一个解析器实例并向其中添加了一个参数,从而有效地使测试成为argparse的实现。当然,这可能是一个有效的测试/用例,但不一定会测试脚本或应用程序对argparse的特定使用。 我认为Yauhen Yakimovich的回答提供了如何以实用的方式利用argparse的有益见解。虽然我还没有完全采用它,但我认为可以通过解析器生成器和覆盖来简化测试方法。
我选择测试我的代码而不是 argparse 的实现。为了实现这一点,我们需要利用一个工厂在我们的代码中创建解析器,该解析器包含所有参数定义。这有助于在 setUp 中测试我们自己的解析器。
// my_class.py
import argparse

class MyClass:
    def __init__(self):
        self.parser = self._create_args_parser()

    def _create_args_parser():
        parser = argparse.ArgumentParser()
        parser.add_argument('--kind', 
                             action='store',
                             dest='kind', 
                             choices=['type1', 'type2'], 
                             help='kind can be any of: type1, type2')

        return parser

在我们的测试中,我们可以生成我们的解析器并对其进行测试。我们将覆盖错误方法以确保我们不会陷入argparseArgumentError评估中。
import unittest
from my_class import MyClass

class MyClassTest(unittest.TestCase):
    def _redefine_parser_error_method(self, message):
        raise ValueError

    def setUp(self):
        parser = MyClass._create_args_parser()
        parser.error = self._redefine_parser_error_func
        self.parser = parser

    def test_override_certificate_kind_arguments(self):
        args = ['--kind', 'not-supported']
        expected_message = "argument --kind: invalid choice: 'not-supported'.*$"

        with self.assertRaisesRegex(ValueError, expected_message):
            self.parser.parse_args(args)

这可能不是最佳答案,但我认为使用我们自己的解析器参数并通过针对测试中应仅发生的异常进行测试来测试该部分很好。

1

我知道这是一个老问题,但是为了扩展@don-kirkby的答案,寻找SystemExit - 但是不必使用pytestpatching - 如果您想断言有关错误消息的某些内容,可以将测试代码包装在contextlib.redirect_stderr中:

    import contextlib
    from io import StringIO
    import unittest
    class MyTest(unittest.TestCase):
        def test_foo(self):
            ioerr = StringIO()
            with contextlib.redirect_stderr(ioerr):
                with self.assertRaises(SystemExit) as err:
                    foo('bad')
            self.assertEqual(err.exception.code, 2)
            self.assertIn("That is a 'bad' thing", ioerr.getvalue())

1

如果您查看错误日志,您会发现出现了一个argparse.ArgumentError而不是AttributeError。您的代码应该像这样:

#!/usr/bin/env python3

import argparse
import unittest
from argparse import ArgumentError

class sweep_test_case(unittest.TestCase):
    """Tests that the merParse class works correctly"""

    def setUp(self):
        self.parser=argparse.ArgumentParser()
        self.parser.add_argument(
            "-c", "--color",
            type=str,
            choices=["yellow", "blue"],
            required=True)

    def test_required_unknown_TE(self):
        """Try to perform sweep on something that isn't an option.
        Should return an attribute error if it fails.
        This test incorrectly shows that the test passed, even though that must
        not be true."""
        args = ["--color", "NADA"]
        try:
            self.assertRaises(ArgumentError, self.parser.parse_args(args))
        except SystemExit:
            print("should give a false positive pass")

    def test_required_unknown(self):
        """Try to perform sweep on something that isn't an option.
        Should return an attribute error if it fails.
        This test incorrectly shows that the test passed, even though that must
        not be true."""
        args = ["--color", "NADA"]
        with self.assertRaises(ArgumentError):
            self.parser.parse_args(args)

if __name__ == '__main__':
    unittest.main()

1
感谢您的建议。当我按照您的建议将 AttributeError 替换为 ArgumentError 时,我得到了 NameError: name 'ArgumentError' is not defined 的错误。这是有道理的,因为 ArgumentError 不在一般的命名空间中,它是 argparse 的一部分。然后我尝试用 argparse.ArgumentError 替换 AttributeError,但仍然出现了以上相同的错误。我已经编辑了我的问题以反映这一点。 - conchoecia

1
如果您查看argparse的源代码,在argparse.py中,大约在第1732行(我的Python版本为3.5.1),有一个名为parse_known_argsArgumentParser方法。代码如下:
# parse the arguments and exit if there are any errors
try:
    namespace, args = self._parse_known_args(args, namespace)
    if hasattr(namespace, _UNRECOGNIZED_ARGS_ATTR):
        args.extend(getattr(namespace, _UNRECOGNIZED_ARGS_ATTR))
        delattr(namespace, _UNRECOGNIZED_ARGS_ATTR)
    return namespace, args
except ArgumentError:
    err = _sys.exc_info()[1]
    self.error(str(err))

因此,argparse 会吞噬 ArgumentError 并以错误代码退出。如果您仍想进行测试,我能想到的唯一方法是模拟 sys.exc_info。请注意,内容中保留了 HTML 标签。

0

我曾经遇到过类似的问题,同样是 argparse 报错(exit 2),后来通过获取 parse_known_args() 返回的元组中的第一个元素——argparse.Namespace 对象进行了修正。

def test_basics_options_of_parser(self):
    parser = w2ptdd.get_parser()
    # unpacking tuple
    parser_name_space,__ = parser.parse_known_args()
    args = vars(parser_name_space)
    self.assertFalse(args['unit'])
    self.assertFalse(args['functional'])

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