使用 unittest 测试 argparse - 退出错误

Posted

技术标签:

【中文标题】使用 unittest 测试 argparse - 退出错误【英文标题】:Using unittest to test argparse - exit errors 【发布时间】:2016-12-25 23:24:08 【问题描述】:

离开Greg Haskin's answer in this question,我尝试进行单元测试以检查当我传递一些choices 中不存在的args 时argparse 是否给出了适当的错误。但是,unittest 使用下面的 try/except 语句会产生误报。

此外,当我只使用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,我在哪里可以找到我系统上的那个文件? I found what I think you're talking about here. 是的,就是这个文件。您可能需要 Python 的开发版本才能在自己的计算机上找到它。查找Lib/test 目录。但是从存储库下载也很好。大多数基于ParserTestCase的测试不用担心错误信息;只是案件是否运行。进一步测试文件查看错误消息。 【参考方案1】:

这里的技巧是捕捉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 更容易使用,但你可以让它在任何一个中工作。

【讨论】:

感谢您的详尽解释!这真的很有用!【参考方案2】:

虽然解析器可能会在解析特定参数期间引发 ArgumentError,但该参数通常被捕获并传递给 parser.errorparse.exit。结果是打印了用法,以及一条错误消息,然后是sys.exit(2)

所以asssertRaises 不是测试argparse 中此类错误的好方法。模块的 unittest 文件 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()

跑步:

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

OK

【讨论】:

谢谢,这很有帮助 ErrorRaisingArgumentParser 中,可能只是重新引发异常而不是将其转换为ValueError。将raise ValueError(message) 更改为raise sys.exc_info()[1] ? @ebergerson,看起来不错,但真正的证据是它是否有效。 @hpaulj 我正在使用它并且它有效。至少在 mac,python 3.6.4.【参考方案3】:

如果您查看错误日志,您会看到引发了 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()

【讨论】:

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

如果你查看 argparse 的源代码,在 argparse.py 的第 1732 行附近(我的 python 版本是 3.5.1),有一个 ArgumentParser 的方法称为 parse_known_args。代码是:

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

因此,ArgumentError 将被argparse 吞噬,并以错误代码退出。如果你想测试这个,我能想到的唯一方法就是嘲笑sys.exc_info

【讨论】:

【参考方案5】:

通过上面的许多很好的答案,我看到在setUp 方法中,在我们的测试中创建了一个解析器实例,并向其中添加了一个参数,从而有效地使测试成为argparse's 实现。当然,这可能是一个有效的测试/用例,但不一定会测试脚本或应用程序对argparse 的特定使用。 我认为Yauhen Yakimovich's answer 很好地了解了如何以务实的方式使用argparse。虽然我还没有完全接受它,但我认为可以通过解析器生成器和覆盖来简化测试方法。

我选择测试我的代码而不是 argparse's 实现。为了实现这一点,我们需要利用工厂在我们的代码中创建包含所有参数定义的解析器。这有助于在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

在我们的测试中,我们可以生成解析器并针对它进行测试。我们将覆盖错误方法以确保我们不会陷入argparse's ArgumentError 评估中。

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)

这可能不是绝对的最佳答案,但我发现使用我们自己的解析器的参数并通过简单地针对我们知道应该只在测试本身发生的异常进行测试来测试该部分是很好的。

【讨论】:

【参考方案6】:

我知道这是一个老问题,但只是为了扩展@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())

【讨论】:

【参考方案7】:

我遇到了与 argparse(退出 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'])

【讨论】:

以上是关于使用 unittest 测试 argparse - 退出错误的主要内容,如果未能解决你的问题,请参考以下文章

带有 argparse 的 Python 单元测试

使用 unittest 检查“--help”标志输出

带有命令行参数的单元测试

使用 Pytest 框架进行 Argparse 测试

如何在单元测试中使用 argparse 参数调用函数?

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