pytest:使用 pytest.mark.parametrize 混合异常测试

pytest: mixing exception testing using pytest.mark.parametrize

我一般在写unit的时候使用pytest.mark.parametrize装饰器 测试。我突然想到,在测试引发异常的函数时,我 可以做类似下面的事情:

bar.py:

def foo(n: int, threshold: int = 1) -> int:
    if n >= threshold:
        return n
    else:
        raise ValueError(f'n: {n} < {threshold} (threshold)')

test_bar.py:

import pytest
from bar import foo

test_foo_data = [
    (1, {}, 1),
    (0, {'threshold': 0}, 0),
    (0, {}, None),
    (1, {'threshold': 2}, None),
]


@pytest.mark.parametrize('n, params, expectation',
                         test_foo_data)
def test_foo(n, params, expectation):
    if expectation is not None:
        assert foo(n, **params) == expectation
    else:
        with pytest.raises(ValueError):
            foo(n, **params)

结果:

$ py.test
============================= test session starts ==============================
platform darwin -- Python 3.7.6, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
rootdir: /path/to/scripts
plugins: cov-2.9.0, hypothesis-5.19.0, quickcheck-0.8.4
collected 4 items                                                              

test_bar.py ....                                                         [100%]

============================== 4 passed in 0.02s ===============================

(我不一定会检查 Noneassertwith pytest.raises ...,但在这种情况下,它似乎很简单。) 问题只是因为我可以这样做,应该我这样做,还是会 最好为 do/don 不引发的输入编写单独的测试函数 例外。后者让我觉得有点乏味,但我不确定 这种情况下的最佳做法。

测试的最佳实践(顺便说一句,在每种语言中)是每个测试都应该测试一个且唯一的东西。
每一个测试都应该是具体的。您可以通过测试名称知道您的测试是否足够具体。如果你遇到像“test_foo_works”这样的测试名称(就像你的测试),你可以推断这个测试太笼统了。因此需要拆分。
您的测试可以分为“test_valid_foo_inputs”和“test_invalid_foo_inputs”

在您的示例中,您滥用了 parametrize 注释。 Pytest 为同一目的的多个参数提供了 parametrize。 (比如多个 valid/invalid foo 输入)。

我认为这是很好的做法。原因是它减少了代码重复。您的示例没有显示这一点,但真正的参数化测试通常必须在进行实际测试之前进行一些设置工作(例如,从简化参数实例化有效输入对象)。如果将有效和无效输入拆分为单独的测试函数,则很难避免重复此设置代码。

也就是说,我认为有一种更简洁的方法来编写这种测试。基本思想是直接使用 pytest.raises()nullcontext() 作为参数。这简化了测试本身(没有更多的 if 语句)并且可以测试不同类型的异常(或不同的异常消息——一个被忽视的错误来源)。一个小问题是,这需要预期的 return 值作为它自己的参数。由于指定 return 值和异常是没有意义的,因此我建议编写一些简单的辅助函数来填充隐含的任何参数。错误情况的预期 return 值并不重要,但我喜欢使用 Mock(),因为如果测试需要将其视为其他类型的对象,它不会导致问题。

from contextlib import nullcontext
from unittest.mock import Mock

def expected(x):
    return x, nullcontext()

def error(*args, **kwargs):
    return Mock(), pytest.raises(*args, **kwargs)

test_foo_names = 'n, params, expected, error'
test_foo_data = [
    (1, {}, *expected(1)),
    (0, {'threshold': 0}, *expected(0)),
    (0, {}, *error(ValueError, match="0 < 1")),
    (1, {'threshold': 2}, *error(ValueError, match="0 < 2")),
]

@pytest.mark.parametrize(test_foo_names, test_foo_data)
def test_foo(n, params, expected, error):
    with error:
        assert foo(n, **params) == expected

编辑:pytest documentation 描述了一个与此非常相似的想法。