目录
pytest
允许你轻松地参数化测试函数。对于基础文档,请参阅如何参数化固件和测试函数 How to parametrize fixtures and test functions.。
以下我们将通过内置机制提供一些示例。
基于命令行生成参数组合
假设我们想要使用不同的计算参数来执行一个测试,并且这些参数的范围应该由命令行参数来确定。首先,我们编写一个简单的(不执行实际计算)的计算测试:
# content of test_compute.py
def test_compute(param1):
assert param1 < 4
现在,我们添加一个测试配置,如下所示:
# conftest.py 文件的内容
def pytest_addoption(parser):
# 向 pytest 添加一个命令行选项 "--all",如果指定,则执行所有组合
parser.addoption("--all", action="store_true", help="run all combinations")
def pytest_generate_tests(metafunc):
# 这是一个 pytest 钩子函数,用于在测试执行前生成测试参数
if "param1" in metafunc.fixturenames:
# 检查是否指定了 "--all" 命令行选项
if metafunc.config.getoption("all"):
# 如果指定了 "--all",则设置 end 为 5
end = 5
else:
# 否则,设置 end 为 2
end = 2
# 使用 parametrize 方法为测试函数 param1 参数生成参数列表
metafunc.parametrize("param1", range(end))
首先通过 pytest_addoption
钩子函数向 pytest 添加了一个新的命令行选项 --all
。这个选项是一个布尔标志,当在命令行中指定时(例如,使用 pytest --all
命令),pytest 会将其值设置为 True
。然后,我们定义了 pytest_generate_tests
钩子函数,该函数在 pytest 即将执行测试之前被调用。在这个函数中,我们检查即将执行的测试函数是否依赖于名为 param1
的 fixture(通过检查 metafunc.fixturenames
)。如果是这样,我们根据命令行上是否指定了 --all
选项来决定 param1
参数的取值范围。如果指定了 --all
,则 param1
将从 0 到 4(即 range(5)
)取值;如果没有指定 --all
,则 param1
将仅从 0 到 1(即 range(2)
)取值。
这意味着,如果我们不使用--all
选项,那么我们只运行2个测试:
$ pytest -q test_compute.py
.. [100%]
2 passed in 0.12s
我们只运行了两个计算,所以我们看到了两个点(.
)。现在,让我们运行完整的测试集:
$ pytest -q --all
....F [100%]
================================= FAILURES =================================
_____________________________ test_compute[4] ______________________________
param1 = 4
def test_compute(param1):
> assert param1 < 4
E assert 4 < 4
test_compute.py:4: AssertionError
========================= short test summary info ==========================
FAILED test_compute.py::test_compute[4] - assert 4 < 4
1 failed, 4 passed in 0.12s
正如预期,当我们运行param1
参数值的完整范围时,最后一个测试会失败。这是因为param1
的值为4时,断言param1 < 4
不成立,从而触发了AssertionError
。
metafunc
是一个特殊的参数,它用于pytest_generate_tests
钩子函数中。这个钩子函数允许开发者在测试执行前动态地生成测试用例或测试参数。metafunc
对象包含以下主要属性和方法:
-
fixturenames:一个包含当前测试函数或测试类中所有fixture名称的列表。这些fixture是在测试函数或测试类中通过参数形式声明的,Pytest会尝试为它们找到对应的fixture函数或值。
-
module:表示当前测试函数或测试类所在的模块对象。这可以用于访问模块级别的变量或函数。
-
config:Pytest的配置对象,包含了Pytest的配置信息,如命令行选项等。通过
metafunc.config
可以访问这些配置信息,并根据它们来定制测试行为。 -
function:当前正在生成的测试用例所对应的函数对象。这可以用于获取函数的名称、签名等信息。
-
cls(如果适用):当前正在生成的测试用例所属的类对象。这在使用类级别的fixture或测试方法时特别有用。
- parametrize:这是
metafunc
对象提供的一个方法,用于为测试函数或测试类的参数动态地生成参数值。它接受一个或多个参数名称和对应的参数值列表(或可迭代对象),并为每个参数值生成一个新的测试用例。
测试ID的不同选项
pytest会为参数化测试中的每组值构建一个字符串作为测试ID。这些ID可以使用-k选项来选择要运行的特定情况,并且在某个情况失败时,它们还将标识出具体的失败情况。运行pytest时加上--collect-only选项将显示生成的ID。
数字、字符串、布尔值和None在测试ID中将使用它们通常的字符串表示。对于其他对象,pytest将基于参数名生成一个字符串:
# content of test_time.py
from datetime import datetime, timedelta
import pytest
testdata = [
(datetime(2001, 12, 12), datetime(2001, 12, 11), timedelta(1)),
(datetime(2001, 12, 11), datetime(2001, 12, 12), timedelta(-1)),
]
@pytest.mark.parametrize("a,b,expected", testdata)
def test_timedistance_v0(a, b, expected):
diff = a - b
assert diff == expected
@pytest.mark.parametrize("a,b,expected", testdata, ids=["forward", "backward"])
def test_timedistance_v1(a, b, expected):
diff = a - b
assert diff == expected
def idfn(val):
if isinstance(val, (datetime,)):
# note this wouldn't show any hours/minutes/seconds
return val.strftime("%Y%m%d")
@pytest.mark.parametrize("a,b,expected", testdata, ids=idfn)
def test_timedistance_v2(a, b, expected):
diff = a - b
assert diff == expected
@pytest.mark.parametrize(
"a,b,expected",
[
pytest.param(
datetime(2001, 12, 12), datetime(2001, 12, 11), timedelta(1), id="forward"
),
pytest.param(
datetime(2001, 12, 11), datetime(2001, 12, 12), timedelta(-1), id="backward"
),
],
)
def test_timedistance_v3(a, b, expected):
diff = a - b
assert diff == expected
在test_timedistance_v0
中,没有指定 ids
参数,我们让pytest自动生成测试ID。
在test_timedistance_v1
中,我们将ids
指定为一个字符串列表,"forward" 和 "backward"用作测试ID,分别对应 testdata
列表中的每个测试案例这些字符串被用作测试ID。这些ID简洁明了,但可能难以维护。
在test_timedistance_v2
中,我们将ids
指定为一个函数idfn,函数检查传入的值是否为 datetime
类型,并返回该日期(不包括时间)的格式化字符串作为测试ID的一部分。由于 idfn
只处理 datetime
类型的值,timedelta
对象仍然使用 pytest 的默认表示方式作为测试ID的一部分:
$ pytest test_time.py --collect-only
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 8 items
<Dir parametrize.rst-200>
<Module test_time.py>
<Function test_timedistance_v0[a0-b0-expected0]>
<Function test_timedistance_v0[a1-b1-expected1]>
<Function test_timedistance_v1[forward]>
<Function test_timedistance_v1[backward]>
<Function test_timedistance_v2[20011212-20011211-expected0]>
<Function test_timedistance_v2[20011211-20011212-expected1]>
<Function test_timedistance_v3[forward]>
<Function test_timedistance_v3[backward]>
======================== 8 tests collected in 0.12s ========================
在test_timedistance_v3
中,我们使用pytest.param
来同时指定测试ID和实际数据,而不是将它们分别列出。
“testscenarios”的快速移植版
这里是一个快速迁移示例,用于运行通过testscenarios
配置的测试,testscenarios
是Robert Collins为标准的unittest
框架提供的一个附加工具。我们只需要稍微努力一下,为pytest的Metafunc.parametrize
构造正确的参数。
# test_scenarios.py 的内容
import pytest
def pytest_generate_tests(metafunc):
# 初始化ID列表和参数值列表
idlist = []
argvalues = []
# 遍历类属性中的场景
for scenario in getattr(metafunc.cls, 'scenarios', []):
# 添加场景ID
idlist.append(scenario[0])
# 从场景字典中提取参数名和参数值
items = scenario[1].items()
argnames = [x[0] for x in items] # 参数名列表
argvalues.append([x[1] for x in items]) # 参数值列表
# 使用参数名和参数值列表来参数化测试
# 注意:实际上应该使用metafunc.parametrize而不是Metafunc.parametrize
# 这里也指定了scope为"class",意味着参数在类级别上共享
metafunc.parametrize(argnames, argvalues, ids=idlist, scope="class")
# 定义场景
scenario1 = ("basic", {"attribute": "value"})
scenario2 = ("advanced", {"attribute": "value2"})
# 定义一个测试类,该类使用上述场景
class TestSampleWithScenarios:
scenarios = [scenario1, scenario2]
# 测试函数,它接受从场景中传递的attribute参数
def test_demo1(self, attribute):
assert isinstance(attribute, str)
# 另一个测试函数,同样接受attribute参数
def test_demo2(self, attribute):
assert isinstance(attribute, str)
这是一个完全独立的示例,您可以使用以下命令运行它:
$ pytest test_scenarios.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 4 items
test_scenarios.py .... [100%]
============================ 4 passed in 0.12s =============================
如果你只是收集测试,你也会很清楚地看到“advanced”和“basic”作为测试函数的变体:
$ pytest --collect-only test_scenarios.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 4 items
<Dir parametrize.rst-200>
<Module test_scenarios.py>
<Class TestSampleWithScenarios>
<Function test_demo1[basic]>
<Function test_demo2[basic]>
<Function test_demo1[advanced]>
<Function test_demo2[advanced]>
======================== 4 tests collected in 0.12s ========================
请注意,我们告诉metafunc.parametrize()
你的场景值应该被视为类作用域。在pytest 2.3中,这会导致基于资源的排序。
推迟参数化资源的设置
"测试函数的参数化发生在收集时。为了优化性能,只在实际运行测试时设置昂贵的资源(如数据库连接或子进程)是一个好主意。以下是一个简单的示例,展示了如何实现这一点。此测试需要一个名为db
的fixture对象:
# content of test_backends.py
import pytest
# 假设有一个db fixture,它根据某种逻辑(可能是通过参数化)返回不同类型的数据库连接
# 这里不展示db fixture的实现,因为它可能涉及复杂的逻辑和可能的参数化
def test_db_initialized(db):
# 一个示例测试
# 检查db对象是否为DB2类的一个实例,并为此示例故意失败
if db.__class__.__name__ == "DB2":
pytest.fail("为了演示目的,故意使测试失败")
我们现在可以添加一个测试配置,该配置会生成对test_db_initialized
函数的两次调用,并实现一个工厂,该工厂为实际的测试调用创建数据库对象。
# content of conftest.py
import pytest
# 使用pytest的钩子函数pytest_generate_tests来参数化测试
def pytest_generate_tests(metafunc):
# 检查是否有任何测试函数请求了名为'db'的fixture
if "db" in metafunc.fixturenames:
# 使用parametrize方法(注意:在钩子函数中,我们使用metafunc.parametrize而不是装饰器)
# 来为'db' fixture生成两个参数值:"d1"和"d2",并设置indirect=True,
# 这意味着'db'是一个间接参数,pytest将查找同名的fixture来解析它
metafunc.parametrize("db", ["d1", "d2"], indirect=True)
# 定义两个数据库类,分别代表不同的数据库对象
class DB1:
"一个数据库对象"
class DB2:
"另一个数据库对象"
# 定义一个fixture,它根据请求的参数返回相应的数据库对象
@pytest.fixture
def db(request):
# request.param包含了通过pytest_generate_tests钩子函数传递的参数值
if request.param == "d1":
return DB1()
elif request.param == "d2":
return DB2()
else:
# 如果参数不是"d1"或"d2",则抛出异常
raise ValueError("无效的内部测试配置")
pytest_generate_tests
钩子函数用于在测试收集阶段为db
fixture生成两个参数值:"d1"和"d2"。通过设置indirect=True
,我们告诉pytest这些参数值不是直接传递给测试函数的,而是应该通过同名的fixture(即db
fixture)来解析。然后,我们定义了db
fixture,它接收一个request
对象作为参数。request.param
属性包含了通过pytest_generate_tests
钩子函数传递的参数值("d1"或"d2")。根据这个参数值,db
fixture返回相应的数据库对象(DB1
或DB2
的实例)。
首先,让我们看看在收集时间(collection time)时它的样子:
$ pytest test_backends.py --collect-only
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 2 items
<Dir parametrize.rst-200> # 这行可能是特定于你的输出或文档环境的,通常不会出现在实际pytest输出中
<Module test_backends.py>
<Function test_db_initialized[d1]> # 使用DB1的测试案例
<Function test_db_initialized[d2]> # 使用DB2的测试案例
======================== 2 tests collected in 0.12s ========================
pytest
命令使用了 --collect-only
选项来仅收集测试,而不实际执行它们。这样做可以让你看到哪些测试案例被收集到了,而不会运行它们。
然后,当我们运行测试时:
$ pytest -q test_backends.py
.F [100%]
================================= FAILURES =================================
_________________________ test_db_initialized[d2] __________________________
db = <conftest.DB2 object at 0xdeadbeef0001>
def test_db_initialized(db):
# 一个示例测试
if db.__class__.__name__ == "DB2":
> pytest.fail("故意失败以演示目的")
E Failed: 故意失败以演示目的
test_backends.py:8: Failed
========================= short test summary info ==========================
FAILED test_backends.py::test_db_initialized[d2] - Failed: 故意失败以演示目的
1 failed, 1 passed in 0.12s
输出表明,test_db_initialized
函数的两个测试变体被执行了。第一个变体(使用DB1
作为数据库对象)通过了测试,而第二个变体(使用DB2
作为数据库对象)失败了。具体来说,当db
参数是DB2
的实例时,测试函数内部检查db
的类名,如果发现是"DB2"
,则通过pytest.fail()
函数故意让测试失败,并给出了“故意失败以演示目的”的错误信息。
这表明我们的db
fixture函数在测试的准备阶段(setup phase)为每个数据库值(DB1
和DB2
)创建了实例,而pytest_generate_tests
钩子函数在收集阶段(collection phase)为test_db_initialized
函数生成了两个相应的调用。这样,我们就能够针对不同的数据库对象运行相同的测试函数,并验证其在不同环境下的行为。
间接参数化
在参数化测试时使用indirect=True
参数允许通过接收参数值的fixture来参数化测试,然后再将这些值传递给测试函数:
import pytest
@pytest.fixture
def fixt(request):
return request.param * 3
@pytest.mark.parametrize("fixt", ["a", "b"], indirect=True)
def test_indirect(fixt):
assert len(fixt) == 3
这种方法可以用在,例如,在fixture中在测试运行时执行更昂贵的设置操作,而不是在收集时间就运行这些设置步骤。在上面的例子中,fixt
是一个fixture,它接收由parametrize
装饰器通过indirect=True
指定的参数值(在这个例子中是字符串"a"
和"b"
),然后对这些值进行转换(在这个例子中是将它们各自乘以3),并将结果传递给test_indirect
测试函数。
对特定参数应用间接参数化
通常,参数化会使用多个参数名。可以对特定参数应用间接参数化,这可以通过将参数的名称列表或元组传递给indirect
参数来实现。在下面的例子中,有一个测试函数test_indirect
,它使用了两个fixture:x
和y
。
在这里,我们向indirect
传递了一个列表,该列表包含了fixture x
的名称。indirect
参数将仅应用于这个参数,并且值"a"
将被传递给相应的fixture函数。
# content of test_indirect_list.py
import pytest
# 定义了一个作用域为函数的fixture x
@pytest.fixture(scope="function")
def x(request):
# 对x应用间接参数化,将接收到的param参数乘以3
return request.param * 3
# 定义了另一个作用域为函数的fixture y
@pytest.fixture(scope="function")
def y(request):
# 这里的y没有应用间接参数化,将直接返回接收到的param参数
return request.param * 2
# 使用parametrize装饰器对x和y进行参数化,但仅对x应用indirect
@pytest.mark.parametrize("x, y", [("a", "b")], indirect=["x"])
def test_indirect(x, y):
# 由于x应用了indirect,它会被传递给x fixture,经过处理后返回"aaa"
assert x == "aaa"
# y没有应用indirect,所以直接接收参数化列表中的值"b"
assert y == "b"
test_indirect
函数通过parametrize
装饰器接收了两个参数x
和y
,其中只有x
被标记为间接参数化(通过indirect=["x"]
)。因此,当测试运行时,"a"
这个值会被传递给x
fixture,x
fixture将其乘以3后返回"aaa"
给测试函数。而y
fixture则直接接收了参数化列表中的"b"
值。
$ pytest -v test_indirect_list.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python
cachedir: .pytest_cache
rootdir: /home/sweet/project
collecting ... collected 1 item
test_indirect_list.py::test_indirect[a-b] PASSED [100%]
============================ 1 passed in 0.12s =============================
通过每个类配置参数化测试方法
在这个例子中,pytest_generate_tests
函数实现了一个类似于 Michael Foord 的 unittest
参数化器(parametrizer)的参数化方案,但使用了更少的代码。
# content of ./test_parametrize.py
import pytest
# 自定义的 pytest_generate_tests 钩子函数,它会在每个测试函数执行前被调用
def pytest_generate_tests(metafunc):
# metafunc.cls 是指向当前测试类的引用,metafunc.function 是指向当前测试函数的引用
# 因此,我们可以从类的 params 字典中获取特定测试函数的参数列表
funcarglist = metafunc.cls.params[metafunc.function.__name__]
# 获取第一个参数集的键(即参数名)并对其排序,这确保参数名的顺序在所有的测试实例中都是一致的
argnames = sorted(funcarglist[0])
# 使用 pytest 的 parametrize 方法来参数化测试函数
# 我们为每个参数名创建一个参数列表,列表中的每个元素都是一个包含所有参数值的列表
# 这样做是为了与 pytest.mark.parametrize 的语法相匹配
metafunc.parametrize(
argnames, # 参数名列表
[[funcargs[name] for name in argnames] for funcargs in funcarglist] # 参数值列表
)
# 定义一个测试类
class TestClass:
# params 字典为每个测试方法指定了多个参数集
# 每个键是测试方法的名称,每个值是一个包含多个字典的列表
# 每个字典代表测试方法的一组参数
params = {
"test_equals": [dict(a=1, b=2), dict(a=3, b=3)], # test_equals 方法将使用这两组参数进行测试
"test_zerodivision": [dict(a=1, b=0)], # test_zerodivision 方法将使用这组参数进行测试
}
# 定义测试方法
def test_equals(self, a, b):
assert a == b
def test_zerodivision(self, a, b):
# 预期会抛出 ZeroDivisionError 异常
with pytest.raises(ZeroDivisionError):
a / b
我们的测试生成器查找类级别的定义,该定义指定了每个测试函数应使用哪些参数集。现在让我们运行它:
$ pytest -q
F.. [100%]
================================= FAILURES =================================
________________________ TestClass.test_equals[1-2] ________________________
self = <test_parametrize.TestClass object at 0xdeadbeef0002>, a = 1, b = 2
def test_equals(self, a, b):
> assert a == b
E assert 1 == 2
test_parametrize.py:21: AssertionError
========================= short test summary info ==========================
FAILED test_parametrize.py::TestClass::test_equals[1-2] - assert 1 == 2
1 failed, 2 passed in 0.12s
使用多个夹具(fixtures)进行参数化
以下是一个简化的实际示例,展示了如何使用参数化测试来测试不同Python解释器之间对象的序列化。我们定义了一个test_basic_objects
函数,该函数将使用三组不同的参数来运行,每组参数分别对应它的三个参数:
python1
:第一个Python解释器,用于将对象pickle序列化到文件中。python2
:第二个解释器,用于从文件中pickle反序列化对象。obj
:要被序列化/反序列化的对象。"""Module containing a parametrized tests testing cross-python serialization via the pickle module.""" from __future__ import annotations import shutil import subprocess import textwrap import pytest pythonlist = ["python3.9", "python3.10", "python3.11"] @pytest.fixture(params=pythonlist) def python1(request, tmp_path): picklefile = tmp_path / "data.pickle" return Python(request.param, picklefile) @pytest.fixture(params=pythonlist) def python2(request, python1): return Python(request.param, python1.picklefile) class Python: def __init__(self, version, picklefile): self.pythonpath = shutil.which(version) if not self.pythonpath: pytest.skip(f"{version!r} not found") self.picklefile = picklefile def dumps(self, obj): dumpfile = self.picklefile.with_name("dump.py") dumpfile.write_text( textwrap.dedent( rf""" import pickle f = open({str(self.picklefile)!r}, 'wb') s = pickle.dump({obj!r}, f, protocol=2) f.close() """ ) ) subprocess.run((self.pythonpath, str(dumpfile)), check=True) def load_and_is_true(self, expression): loadfile = self.picklefile.with_name("load.py") loadfile.write_text( textwrap.dedent( rf""" import pickle f = open({str(self.picklefile)!r}, 'rb') obj = pickle.load(f) f.close() res = eval({expression!r}) if not res: raise SystemExit(1) """ ) ) print(loadfile) subprocess.run((self.pythonpath, str(loadfile)), check=True) @pytest.mark.parametrize("obj", [42, {}, {1: 3}]) def test_basic_objects(python1, python2, obj): python1.dumps(obj) python2.load_and_is_true(f"obj == {obj}")
如果我们没有安装所有Python解释器,运行该模块时可能会跳过一些测试,否则它会运行所有组合(3个解释器与3个解释器相乘,再乘以3个要序列化/反序列化的对象)。
. $ pytest -rs -q multipython.py ssssssssssss...ssssssssssss [100%] ========================= short test summary info ========================== SKIPPED [12] multipython.py:65: 'python3.9' not found SKIPPED [12] multipython.py:65: 'python3.11' not found 3 passed, 24 skipped in 0.12s
可选实现/导入的参数化
如果你想比较给定API的几个不同实现的结果,你可以编写测试函数,这些函数接收已经导入的实现,并在实现无法导入/不可用时跳过测试。假设我们有一个“基础”实现,而其他(可能是优化过的)实现需要提供相似的结果。
# content of conftest.py
import pytest
# 定义一个fixture,用于导入基础模块。如果基础模块无法导入,则跳过测试。
@pytest.fixture(scope="session")
def basemod(request):
return pytest.importorskip("base")
# 定义一个带参数的fixture,用于导入优化模块。这里使用params参数化fixture,以支持多个优化版本。
# 如果指定的优化模块无法导入,则跳过对应的测试。
@pytest.fixture(scope="session", params=["opt1", "opt2"])
def optmod(request):
return pytest.importorskip(request.param)
这是基础实现的简单函数定义
# content of base.py
def func1():
return 1
这是一个优化版本的实现,函数返回的值略有不同。
# content of opt1.py
def func1():
return 1.0001
这是测试模块,它使用前面定义的fixture来比较基础实现和优化实现的结果。
# content of test_module.py
def test_func1(basemod, optmod):
# 由于func1可能返回整数或浮点数,我们使用round函数来比较两者在指定位数上的值是否相等
assert round(basemod.func1(), 3) == round(optmod.func1(), 3)
如果你在运行测试时启用了跳过报告的选项:
$ pytest -rs test_module.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 2 items
test_module.py .s [100%]
========================= short test summary info ==========================
SKIPPED [1] test_module.py:3: could not import 'opt2': No module named 'opt2'
======================= 1 passed, 1 skipped in 0.12s =======================
你会看到我们没有opt2
模块,因此test_func1
的第二次测试运行被跳过了。这里有几个注意事项:
-
conftest.py文件中的fixture函数是“会话范围”的:因为我们不需要多次导入
-
如果有多个测试函数和一个被跳过的导入:在测试报告中,你会看到
[1]
计数增加,表示有一个测试被跳过了。 -
在测试函数上使用 @pytest.mark.parametrize 风格的参数化:你还可以在测试函数上使用
@pytest.mark.parametrize
来参数化输入/输出值。
为单个参数化测试设置标记或测试ID
使用pytest.param
来为单个参数化测试应用标记或设置测试ID。例如:
# test_pytest_param_example.py 文件内容
import pytest
@pytest.mark.parametrize(
"test_input,expected",
[
("3+5", 8),
pytest.param("1+7", 8, marks=pytest.mark.basic),
pytest.param("2+4", 6, marks=pytest.mark.basic, id="basic_2+4"),
pytest.param(
"6*9", 42, marks=[pytest.mark.basic, pytest.mark.xfail], id="basic_6*9"
),
],
)
def test_eval(test_input, expected):
assert eval(test_input) == expected
在这个例子中,我们有4个参数化测试。除了第一个测试外,我们使用自定义标记basic
来标记其余三个参数化测试。对于第四个测试,我们还使用了内置标记xfail
来表示这个测试预期会失败。为了更明确,我们还为一些测试设置了测试ID。
- 第一个测试没有特别的标记或ID。
- 第二个测试使用了
pytest.param
并附加了pytest.mark.basic
标记。 - 第三个测试同样使用了
pytest.param
并附加了pytest.mark.basic
标记,同时还设置了测试ID为"basic_2+4"
以提供更清晰的测试描述。 - 第四个测试则更加复杂,它不仅附加了
pytest.mark.basic
和pytest.mark.xfail
两个标记,还设置了测试ID为"basic_6*9"
,以明确表示这个测试的预期失败情况及其对应的输入。
然后,使用详细模式并仅包含带有basic
标记的测试来运行pytest:
$ pytest -v -m basic
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python
cachedir: .pytest_cache
rootdir: /home/sweet/project
collecting ... collected 24 items / 21 deselected / 3 selected
test_pytest_param_example.py::test_eval[1+7-8] PASSED [ 33%]
test_pytest_param_example.py::test_eval[basic_2+4] PASSED [ 66%]
test_pytest_param_example.py::test_eval[basic_6*9] XFAIL [100%]
=============== 2 passed, 21 deselected, 1 xfailed in 0.12s ================
结果如下:
- 总共收集了四个测试。
- 一个测试因为没有
basic
标记而被排除。 - 三个带有
basic
标记的测试被选中并执行。 - 测试
test_eval[1+7-8]
通过了,但其名称是自动生成的,可能会让人容易混淆。 - 测试
test_eval[basic_2+4]
通过了。 - 测试
test_eval[basic_6*9]
预期会失败,并且确实失败了。这是因为它被标记为xfail
。
参数化条件异常抛出
使用pytest.raises()
与pytest.mark.parametrize
装饰器来编写参数化测试,其中一些测试会抛出异常,而另一些则不会。
contextlib.nullcontext
可用于测试那些不期望抛出异常但应产生某些值的用例。这个值作为enter_result
参数给出,它将作为with
语句的目标(在以下示例中为e
)可用。
这样,您可以在测试函数中统一使用with
语句来处理所有情况,即使某些情况不期望抛出异常也能正常工作。contextlib.nullcontext
在with
块执行时不会做任何操作,只是简单地返回您通过enter_result
参数提供的值(如果有的话),从而允许测试逻辑保持一致性和简洁性。
from contextlib import nullcontext
import pytest
@pytest.mark.parametrize(
"example_input,expectation",
[
(3, nullcontext(2)),
(2, nullcontext(3)),
(1, nullcontext(6)),
(0, pytest.raises(ZeroDivisionError)),
],
)
def test_division(example_input, expectation):
"""Test how much I know division."""
with expectation as e:
assert (6 / example_input) == e
在上面的示例中,前三个测试用例应该能够顺利运行而不抛出任何异常,而第四个测试用例则应该抛出一个ZeroDivisionError
异常,这是pytest
所期望的。