Pytest 参数化
修饰器 @pytest.mark.parametrize() 允许一个测试函数使用不同的输入值运行数次。 当断言逻辑相同但示例有所不同时,请使用它。 参数化可以减少重复,并允许 pytest 将每个输入报告为其自己的测试项。
该修饰器通常采用两个必需的参数:
-
argnames:逗号分隔的字符串,或字符串的列表或元组,其中包含要传递给测试函数的参数的名称。 示例包括"item"、"test_input, expected"、["test_input", "expected"]和("test_input", "expected")。 -
argvalues:用于这些参数的值或参数集的迭代对象。 当仅有一个参数名时,每一项都作为一个测试值传递,即使该值为元组。 使用多个参数名称时,每个项必须为每个参数名称提供一个值,通常以元组、列表或pytest.param(...)调用的形式提供。
可选 ids 参数允许自定义 pytest 用于标记每个生成的测试项的测试 ID。 更高级的选项(例如 indirect ,以及 scope)对于参数化装置或昂贵的资源等情况非常有用;可以在 pytest 参数 化操作 指南和 @pytest.mark.parametrize 参考中了解它们。
Note
Pytest 将参数值原样传递给测试,它不会复制它们。 如果测试将可变参数值(如列表或字典)改变,则此突变可以在后续生成的接收相同对象的事例中可见。
何时使用参数化
两种你可能想要使用 @pytest.mark.parametrize() 的常见方案包括:
- 当测试循环访问输入并重复相同的断言时
- 当多个测试函数在不同输入下断言相同的测试行为时
让我们先查看每个示例而不使用 @pytest.mark.parametrize(),然后通过它来展示如何使测试更易于维护和诊断。
For 循环
下面是包含 for 循环的测试函数的示例:
def test_string_is_digit():
items = ["1", "10", "33"]
for item in items:
assert item.isdigit()
当一个输入失败时,此模式可能很难诊断:
- 不完整的反馈: 第一个失败的断言会停止循环,因此以后的值不会在这一轮中求值。
- 单个测试结果: Pytest 报告一个收集的测试,而不是每个输入一个测试用例。
- 反复重新运行:修复第一个故障后,可能只有在再次运行测试时才会发现另一个故障。
让我们修改测试以包含两个会导致断言失败的项:
def test_string_is_digit():
items = ["No", "1", "10", "33", "Yes"]
for item in items:
assert item.isdigit()
运行测试仅显示第一次失败,即使列表中有两个无效项。 示例命令使用 python -m pytest,当 Python 可通过 python 命令调用时,此命令有效。 如果 shell 在 macOS 或 Linux 上使用 python3,或者在 Windows 上使用 py,请替换此命令。
python -m pytest test_items.py
=================================== FAILURES ===================================
_____________________________ test_string_is_digit _____________________________
def test_string_is_digit():
items = ["No", "1", "10", "33", "Yes"]
for item in items:
> assert item.isdigit()
E AssertionError: assert False
E + where False = <built-in method isdigit of str object at 0x...>()
E + where <built-in method isdigit of str object at 0x...> = 'No'.isdigit
test_items.py:4: AssertionError
=========================== short test summary info ============================
FAILED test_items.py::test_string_is_digit - AssertionError: assert False
============================== 1 failed in 0.01s ===============================
Pytest 的断言内省输出会显示绑定的 str.isdigit 方法以及触发故障的字符串值。 会话标头、平台详细信息、内存地址和运行时间可能会有所不同,因此示例省略或缩短了它们 ... 和 0x...。 相关详细信息是循环在第一次失败'No'时停止,并且不会执行'Yes'。
这是 @pytest.mark.parametrize() 的一个实用用例。 在更新测试之前,让我们探索另一种不涉及 for 循环的常见情况。
断言相同行为的测试
执行相同断言的一组测试也是 @pytest.mark.parametrize() 的一个很好例子。 如果将前一个测试重写为每个项目单独测试,可以提高故障报告的效果,但会导致内容重复:
def test_is_digit_1():
assert "1".isdigit()
def test_is_digit_10():
assert "10".isdigit()
def test_is_digit_33():
assert "33".isdigit()
这些测试更精细,因为故障可以与单个输入相关联。 尽管有几个类似的测试似乎异常,但这种模式在尝试准确报告故障的生产测试套件中很常见。
但是,重复测试存在以下问题:
- 代码是重复的,这会产生维护负担。
- 类似的函数很容易不一致地更新。
- 添加新输入需要复制另一个测试正文,因此可以忽略重要情况。
参数化保留了单独的测试项的报告优势,而无需复制相同的测试正文。
如何使用参数化
现在您已经了解了一些 @pytest.mark.parametrize() 的用例,让我们更新那个使用 for 循环且包含失败项的测试。
导入 pytest,然后直接应用于 @pytest.mark.parametrize() 测试函数上方:
import pytest
@pytest.mark.parametrize("item", ["No", "1", "10", "33", "Yes"])
def test_string_is_digit(item):
assert item.isdigit()
在运行测试之前,让我们回顾一下更改。
在此示例中,修饰器接收两个参数。 第一个参数 "item"命名 pytest 传递到测试函数的参数。 名称必须与item中的test_string_is_digit(item)参数匹配。 第二个参数是 pytest 用于生成的测试用例的值列表。
丰富的错误报告
在后台,pytest 为参数列表中的每个值收集一个测试项。 这意味着会单独报告通过和失败用例。 让我们看看运行测试时会发生什么情况:
python -m pytest test_items.py
============================= test session starts ==============================
...
collected 5 items
test_items.py F...F [100%]
=================================== FAILURES ===================================
___________________________ test_string_is_digit[No] ___________________________
item = 'No'
@pytest.mark.parametrize("item", ["No", "1", "10", "33", "Yes"])
def test_string_is_digit(item):
> assert item.isdigit()
E AssertionError: assert False
E + where False = <built-in method isdigit of str object at 0x...>()
E + where <built-in method isdigit of str object at 0x...> = 'No'.isdigit
test_items.py:5: AssertionError
__________________________ test_string_is_digit[Yes] ___________________________
item = 'Yes'
@pytest.mark.parametrize("item", ["No", "1", "10", "33", "Yes"])
def test_string_is_digit(item):
> assert item.isdigit()
E AssertionError: assert False
E + where False = <built-in method isdigit of str object at 0x...>()
E + where <built-in method isdigit of str object at 0x...> = 'Yes'.isdigit
test_items.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_items.py::test_string_is_digit[No] - AssertionError: assert False
FAILED test_items.py::test_string_is_digit[Yes] - AssertionError: assert False
========================= 2 failed, 3 passed in 0.07s ==========================
测试报告中有几个值得注意的项目。 一开始,pytest 会报告从单个测试函数收集的五个测试项:三个通过,两个失败。 单独报告失败,包括导致每个失败的输入值。 标头 test_string_is_digit[No] 显示参数集,参数本地 item = 'No' 显示在源视图上方。 绑定方法自省行(如下所示的 0x... 内存地址)确认了哪个值触发了断言失败:
___________________________ test_string_is_digit[No] ___________________________
item = 'No'
[...]
FAILED test_items.py::test_string_is_digit[No] - AssertionError: assert False
由于有很多地方都报告了失败,因此很难忽视导致失败的值。
使用详细输出标志
测试全部通过时,默认情况下命令行只会报告极简的结果。 更新以修复故障(继续编辑同一个 test_items.py,因此 import pytest 已存在)后,测试如下所示:
@pytest.mark.parametrize("item", ["0", "1", "10", "33", "9"])
def test_string_is_digit(item):
assert item.isdigit()
运行测试会生成最少的输出:
python -m pytest test_items.py
============================= test session starts ==============================
...
collected 5 items
test_items.py ..... [100%]
============================== 5 passed in 0.01s ===============================
增加详细程度会显示每个生成的测试用例及其对应的参数 ID。
python -m pytest -v test_items.py
============================= test session starts ==============================
...
collected 5 items
test_items.py::test_string_is_digit[0] PASSED [ 20%]
test_items.py::test_string_is_digit[1] PASSED [ 40%]
test_items.py::test_string_is_digit[10] PASSED [ 60%]
test_items.py::test_string_is_digit[33] PASSED [ 80%]
test_items.py::test_string_is_digit[9] PASSED [100%]
============================== 5 passed in 0.01s ===============================
如何使用多个参数名称
到目前为止,我们所看到的示例使用了一个参数名称: "item" 还可以在第一个参数中包含多个参数名称。 使用字符串时,用逗号分隔名称。 还可以将名称作为列表或元组传递,例如 ["item", "attribute"] 或 ("item", "attribute")。
多个参数名称的一个用例是传递输入值和要对其测试的预期值。 在第二个参数中,每个参数集需要为每个参数名称一个值。 例如,如果参数名称为 "test_input, expected_value",则参数值可能是 [("3+5", 8), ("3*4", 12)]。
下一个示例使用 Python 的 hasattr() 函数,该函数根据对象是否具有命名属性返回布尔值:
>>> hasattr(dict(), "keys")
True
>>> hasattr("string", "append")
False
每个测试用例都需要一个对象和一个属性名称,因此我们可以按以下方式使用 @pytest.mark.parametrize() 。 继续在同一 test_items.py 文件中操作,因此 import pytest 已存在:
@pytest.mark.parametrize("item, attribute", [("", "format"), (list(), "append")])
def test_attributes(item, attribute):
assert hasattr(item, attribute)
@pytest.mark.parametrize()修饰器仍对第一个参数使用单个字符串,但该字符串现在包含用逗号分隔的两个参数名称。 名称将成为测试函数的参数。 在这种情况下,它们是 item 和 attribute。 名称列表或元组的工作方式相同。
第二个参数是具有两个参数集的列表。 每个参数集都包含一个 item 值和一个 attribute 值。 如果参数集的参数名称的值太少或太多,则 pytest 会在运行测试用例之前引发集合错误。
Pytest 为每个参数集生成测试 ID。 对于诸如数字、字符串、布尔值等简单值,pytest 使用这些值的常规字符串表示形式。 对于其他对象,pytest 会回退到基于参数名称和参数集从零开始的索引的名称。 在此输出中,空字符串产生了空的 ID 部分,因此两个 ID 之间的短划线分隔符在 [-format] 中仍然可见。 由于 list() 没有简单的生成 ID,pytest 将 item1替换为:
python -m pytest -v test_items.py::test_attributes
============================= test session starts ==============================
...
collected 2 items
test_items.py::test_attributes[-format] PASSED [ 50%]
test_items.py::test_attributes[item1-append] PASSED [100%]
============================== 2 passed in 0.01s ===============================
如果生成的 ID 难以读取,则可以传递可选 ids 参数或包装参数集 pytest.param(..., id="name") 以提供更清晰的名称。 例如:
import pytest
@pytest.mark.parametrize(
"item, attribute",
[
pytest.param("", "format", id="empty-string-format"),
(list(), "append"),
],
)
def test_attributes(item, attribute):
assert hasattr(item, attribute)
调用 pytest.param("", "format", id="empty-string-format") 提供该参数集的整个 ID,替换自动生成 -format 的 ID。 在详细输出中,生成的测试被命名为test_attributes[empty-string-format] 和test_attributes[item1-append]。 清晰的 ID 可以使冗长输出和失败信息更易于扫描。