目录
使用pytest.param()为参数化fixture添加标记
使用非参数化的fixture覆盖参数化的fixture,反之亦然
参数化夹具
夹具函数可以被参数化,在这种情况下,它们将被多次调用,每次调用都会执行一组依赖于该夹具的测试,即那些依赖于此夹具的测试。测试函数通常不需要意识到它们正在重新运行。夹具的参数化有助于为那些本身可以以多种方式配置的组件编写详尽的功能测试。
在 pytest 中,参数化夹具允许你定义一组不同的参数值,并且为每个参数值运行一次夹具函数及其依赖的测试。这样,你可以为不同的输入条件测试你的代码,而无需为每个条件编写单独的测试函数。这对于测试具有多个可能配置或状态的组件特别有用。
扩展前面的例子,我们可以标记夹具以创建两个smtp_connection
夹具实例,这将导致所有使用该夹具的测试都运行两次。夹具函数通过特殊的request
对象访问每个参数:
# conftest.py 文件的内容
import smtplib
import pytest
@pytest.fixture(scope="module", params=["smtp.gmail.com", "mail.python.org"])
def smtp_connection(request):
smtp_connection = smtplib.SMTP(request.param, 587, timeout=5)
yield smtp_connection
print(f"finalizing {smtp_connection}")
smtp_connection.close()
smtp_connection
夹具被标记为pytest.fixture
,并且使用了params
参数来指定两个SMTP服务器地址。scope="module"
表示这个夹具在每个测试模块中只会被实例化一次,但是在这个上下文中,由于params
的使用,它实际上会为每个参数值实例化一次,但仍然是模块级别的,这意味着在单个模块内所有使用此夹具的测试都会针对每个参数值运行一次。在smtp_connection
函数内部,request.param
用于访问当前测试正在使用的参数值(即SMTP服务器地址)。
主要的变化是在使用@pytest.fixture
声明时添加了params
参数,它是一个值列表,对于列表中的每个值,夹具函数都会执行一次,并且可以通过request.param
访问当前的值。测试函数的代码不需要进行任何更改。我们再运行一次:
$ pytest -q test_module.py
FFFF [100%]
================================= FAILURES =================================
________________________ test_ehlo[smtp.gmail.com] _________________________
smtp_connection = <smtplib.SMTP object at 0xdeadbeef0004>
def test_ehlo(smtp_connection):
response, msg = smtp_connection.ehlo()
assert response == 250
assert b"smtp.gmail.com" in msg
> assert 0 # for demo purposes
E assert 0
test_module.py:7: AssertionError
________________________ test_noop[smtp.gmail.com] _________________________
smtp_connection = <smtplib.SMTP object at 0xdeadbeef0004>
def test_noop(smtp_connection):
response, msg = smtp_connection.noop()
assert response == 250
> assert 0 # for demo purposes
E assert 0
test_module.py:13: AssertionError
________________________ test_ehlo[mail.python.org] ________________________
smtp_connection = <smtplib.SMTP object at 0xdeadbeef0005>
def test_ehlo(smtp_connection):
response, msg = smtp_connection.ehlo()
assert response == 250
> assert b"smtp.gmail.com" in msg
E AssertionError: assert b'smtp.gmail.com' in b'mail.python.org\nPIPELINING\nSIZE 51200000\nETRN\nSTARTTLS\nAUTH DIGEST-MD5 NTLM CRAM-MD5\nENHANCEDSTATUSCODES\n8BITMIME\nDSN\nSMTPUTF8\nCHUNKING'
test_module.py:6: AssertionError
-------------------------- Captured stdout setup ---------------------------
finalizing <smtplib.SMTP object at 0xdeadbeef0004>
________________________ test_noop[mail.python.org] ________________________
smtp_connection = <smtplib.SMTP object at 0xdeadbeef0005>
def test_noop(smtp_connection):
response, msg = smtp_connection.noop()
assert response == 250
> assert 0 # for demo purposes
E assert 0
test_module.py:13: AssertionError
------------------------- Captured stdout teardown -------------------------
finalizing <smtplib.SMTP object at 0xdeadbeef0005>
========================= short test summary info ==========================
FAILED test_module.py::test_ehlo[smtp.gmail.com] - assert 0
FAILED test_module.py::test_noop[smtp.gmail.com] - assert 0
FAILED test_module.py::test_ehlo[mail.python.org] - AssertionError: asser...
FAILED test_module.py::test_noop[mail.python.org] - assert 0
4 failed in 0.12s
我们看到,我们的两个测试函数各自针对不同的smtp_connection
实例运行了两次。还需要注意的是,在使用mail.python.org
连接时,第二个测试在test_ehlo
中失败了,因为预期的服务器字符串与收到的不同。
pytest会为参数化夹具中的每个夹具值构建一个字符串作为测试ID,例如上述例子中的test_ehlo[smtp.gmail.com]
和test_ehlo[mail.python.org]
。这些ID可以与-k
选项一起使用来选择要运行的特定案例,并且在某个案例失败时,它们还可以标识出具体的失败案例。运行pytest时加上--collect-only
选项将显示生成的ID。
在pytest中,测试ID用于唯一标识测试用例或测试参数化中的每个实例。对于数字、字符串、布尔值和None
,pytest会使用它们通常的字符串表示形式作为测试ID。对于其他对象,pytest会根据参数名生成一个字符串。不过你可以通过使用ids关键字参数,可以自定义测试ID中使用的字符串,用于特定的fixture值:
以下示例展示了如何使用ids
来自定义测试ID:
# test_ids.py 文件内容
import pytest
# 使用 ids 列表直接指定测试ID
@pytest.fixture(params=[0, 1], ids=["spam", "ham"])
def a(request):
return request.param
def test_a(a):
pass
# 使用 ids 函数动态生成测试ID
def idfn(fixture_value):
if fixture_value == 0:
return "eggs"
else:
return None # 如果函数返回 None,pytest 将使用自动生成的ID
@pytest.fixture(params=[0, 1], ids=idfn)
def b(request):
return request.param
def test_b(b):
pass
上述内容展示了如何使用ids
,它既可以是一个要使用的字符串列表,也可以是一个函数,该函数将使用fixture的值作为参数被调用,并需要返回一个字符串来使用。在后一种情况下,如果函数返回None
,则pytest将使用自动生成的ID。这种方式提供了灵活性,允许开发者根据具体情况选择最适合的方式来标识测试ID。
运行上述测试将使用以下测试ID:
$ pytest --collect-only
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 12 items
<Dir fixtures.rst-219>
<Module test_anothersmtp.py>
<Function test_showhelo[smtp.gmail.com]>
<Function test_showhelo[mail.python.org]>
<Module test_emaillib.py>
<Function test_email_received>
<Module test_finalizers.py>
<Function test_bar>
<Module test_ids.py>
<Function test_a[spam]>
<Function test_a[ham]>
<Function test_b[eggs]>
<Function test_b[1]>
<Module test_module.py>
<Function test_ehlo[smtp.gmail.com]>
<Function test_noop[smtp.gmail.com]>
<Function test_ehlo[mail.python.org]>
<Function test_noop[mail.python.org]>
======================= 12 tests collected in 0.12s ========================
使用pytest.param()
为参数化fixture添加标记
pytest.param()
可以在参数化fixture的值集中应用标记,其方式与在@pytest.mark.parametrize
中使用它们相同。这意味着你可以对参数化fixture中的特定值或组合进行标记,以便在测试运行时应用特定的行为或条件。
例子:
# content of test_fixture_marks.py
import pytest
@pytest.fixture(params=[0, 1, pytest.param(2, marks=pytest.mark.skip)])
def data_set(request):
return request.param
def test_data(data_set):
pass
运行此测试将跳过值为2的data_set调用:
$ pytest test_fixture_marks.py -v
=========================== 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 3 items
test_fixture_marks.py::test_data[0] PASSED [ 33%]
test_fixture_marks.py::test_data[1] PASSED [ 66%]
test_fixture_marks.py::test_data[2] SKIPPED (unconditional skip) [100%]
======================= 2 passed, 1 skipped in 0.12s =======================
再比如:
import pytest
# 使用 pytest.param() 为参数化fixture中的值添加标记
@pytest.fixture(params=[
pytest.param(1, marks=pytest.mark.basic),
pytest.param(2, marks=pytest.mark.basic),
pytest.param(3, marks=pytest.mark.advanced),
pytest.param(4, marks=[pytest.mark.xfail, pytest.mark.advanced])
])
def input_value(request):
return request.param
def test_example(input_value):
# 测试逻辑
if input_value == 4:
# 这个测试预期会失败
assert False
else:
# 其他测试逻辑
assert input_value >= 1
# 在命令行中运行测试时,你可以使用 -m 选项来只运行具有特定标记的测试
# 例如,只运行标记为 basic 的测试:pytest -m basic
# 或者,只运行没有 xfail 标记的测试(这通常意味着排除预期失败的测试)
# pytest -m 'not xfail'
模块化:在fixture函数中使用fixtures
除了在测试函数中使用fixtures之外,fixture函数本身也可以使用其他fixtures。这有助于实现fixtures的模块化设计,并允许跨多个项目重用特定于框架的fixtures。
这样做的好处是,你可以构建一个复杂的fixture结构,每个fixture都专注于其特定的任务或资源,而它们之间则通过依赖关系相互连接。这样,当你需要在多个测试或项目中重复使用某些复杂的配置或资源时,你可以简单地重用这些fixtures,而无需在每个地方都重新编写相同的设置代码。
例如,如果你有一个用于数据库连接的fixture,你可以在多个fixture中重用它,这些fixture分别用于设置不同的测试数据或执行不同的数据库操作,而无需在每个fixture中都重新建立数据库连接。
作为一个简单的例子,我们可以扩展前面的示例,并实例化一个对象app,其中我们将已定义的smtp_connection资源嵌入其中:
# test_appsetup.py 文件的内容
import pytest
# 定义了一个App类,它接受一个smtp_connection参数并在初始化时设置它
class App:
def __init__(self, smtp_connection):
self.smtp_connection = smtp_connection
# 使用pytest.fixture装饰器定义了一个名为app的fixture,其作用域为module级别
# 它接收之前定义的smtp_connection fixture,并使用它来实例化App对象
@pytest.fixture(scope="module")
def app(smtp_connection):
return App(smtp_connection)
# 定义了一个测试函数,它接收app fixture作为参数
# 该测试函数检查app对象的smtp_connection属性是否存在(即非空或非None)
def test_smtp_connection_exists(app):
assert app.smtp_connection
这里我们声明了一个app fixture,它接收之前定义的smtp_connection fixture,并用它来实例化一个App对象。
让我们运行它:
$ pytest -v test_appsetup.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 2 items # 表示收集到了2个测试项
test_appsetup.py::test_smtp_connection_exists[smtp.gmail.com] PASSED [ 50%] # 第一个测试项通过
test_appsetup.py::test_smtp_connection_exists[mail.python.org] PASSED [100%] # 第二个测试项也通过
============================ 2 passed in 0.12s ============================= # 2个测试项都通过了,总共耗时0.12秒
由于smtp_connection
被参数化了,测试将使用两个不同的App
实例和相应的SMTP服务器运行两次。app
fixture无需了解smtp_connection
的参数化,因为pytest会完全分析fixture依赖图。
请注意,app
fixture的作用域是模块级别的,并且它使用了一个模块级别的smtp_connection
fixture。即使smtp_connection
是在会话作用域内缓存的,这个示例仍然可以工作:fixture使用“更广泛”作用域的fixture是可以的,但反过来则不行:一个会话作用域的fixture不能以一种有意义的方式使用模块作用域的fixture。这是因为作用域较小的fixture(如模块级别)的生命周期和作用范围被包含在作用域更大的fixture(如会话级别)之内,但反过来则会导致作用域和生命周期的冲突。
通过fixture实例自动分组测试
pytest在测试运行时尽量减少活动fixture的数量。如果你有一个参数化的fixture,那么所有使用它的测试将首先使用一个实例执行,然后在下一个fixture实例创建之前调用终结器(finalizers)。这样做的好处之一是简化了那些创建和使用全局状态的应用程序的测试。通过这种方式,每个fixture实例的上下文都是独立的,有助于避免不同测试之间的相互影响,确保测试的独立性和准确性。
以下示例使用了两个参数化的fixture,其中一个fixture的作用域是基于每个模块的,并且所有函数都执行print打印调用来显示设置/拆除(setup/teardown)流程:
# test_module.py 文件内容
import pytest
# 这是一个模块级别的参数化fixture,它将为每个模块参数("mod1" 和 "mod2")执行一次设置和一次拆除
@pytest.fixture(scope="module", params=["mod1", "mod2"])
def modarg(request):
param = request.param
print(" SETUP modarg", param) # 设置时打印
yield param # 产出参数,以便测试函数可以使用
print(" TEARDOWN modarg", param) # 拆除时打印
# 这是一个函数级别的参数化fixture,它将为每个测试函数分别针对每个参数(1 和 2)执行设置和拆除
@pytest.fixture(scope="function", params=[1, 2])
def otherarg(request):
param = request.param
print(" SETUP otherarg", param) # 设置时打印
yield param # 产出参数,以便测试函数可以使用
print(" TEARDOWN otherarg", param) # 拆除时打印
# 这个测试函数只使用了otherarg fixture
def test_0(otherarg):
print(" RUN test0 with otherarg", otherarg)
# 这个测试函数只使用了modarg fixture
def test_1(modarg):
print(" RUN test1 with modarg", modarg)
# 这个测试函数同时使用了otherarg和modarg两个fixture
# 注意:由于modarg是模块级别的,而otherarg是函数级别的,因此对于modarg的每个参数,otherarg的每个参数都会被测试一次
def test_2(otherarg, modarg):
print(f" RUN test2 with otherarg {otherarg} and modarg {modarg}")
让我们以详细模式运行测试,并查看打印输出:
$ pytest -v -s test_module.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 8 items
test_module.py::test_0[1] SETUP otherarg 1
RUN test0 with otherarg 1
PASSED TEARDOWN otherarg 1
test_module.py::test_0[2] SETUP otherarg 2
RUN test0 with otherarg 2
PASSED TEARDOWN otherarg 2
test_module.py::test_1[mod1] SETUP modarg mod1
RUN test1 with modarg mod1
PASSED
test_module.py::test_2[mod1-1] SETUP otherarg 1
RUN test2 with otherarg 1 and modarg mod1
PASSED TEARDOWN otherarg 1
test_module.py::test_2[mod1-2] SETUP otherarg 2
RUN test2 with otherarg 2 and modarg mod1
PASSED TEARDOWN otherarg 2
test_module.py::test_1[mod2] TEARDOWN modarg mod1
SETUP modarg mod2
RUN test1 with modarg mod2
PASSED
test_module.py::test_2[mod2-1] SETUP otherarg 1
RUN test2 with otherarg 1 and modarg mod2
PASSED TEARDOWN otherarg 1
test_module.py::test_2[mod2-2] SETUP otherarg 2
RUN test2 with otherarg 2 and modarg mod2
PASSED TEARDOWN otherarg 2
TEARDOWN modarg mod2
============================ 8 passed in 0.12s =============================
可以看到,参数化的模块级作用域modarg
资源导致了测试执行的顺序,使得“活跃”资源数量尽可能少。在mod2
资源设置之前,mod1
参数化资源的终结器就已经执行了。
特别注意的是,test_0
是完全独立的,并且首先完成。然后依次是:test_1
使用mod1
执行,test_2
使用mod1
执行,test_1
使用mod2
执行,最后test_2
使用mod2
执行。
而函数作用域的otherarg
参数化资源在每个使用它的测试之前被设置,并在每个测试之后被拆除。
在类和模块中使用 夹具通过usefixtures
装饰器
有时测试函数不需要直接访问一个 fixture 对象。例如,测试可能需要将当前工作目录设置为一个空目录,但除此之外并不关心这个具体是哪个目录。下面是如何使用标准的 tempfile
和 pytest
fixtures 来实现这一点的示例。我们将 fixture 的创建过程分离到一个 conftest.py
文件中(conftest.py
文件是 pytest 特有的,它允许你定义作用于整个测试目录的 fixtures 和钩子(hooks)。当你将 fixture 定义在 conftest.py
文件中时,它会自动被该目录下所有的测试文件所识别和使用):
#content of conftest.py
import os
import tempfile
import pytest
@pytest.fixture
def cleandir():
"""
使用临时目录作为当前工作目录,并在测试完成后恢复原始工作目录。
"""
with tempfile.TemporaryDirectory() as newpath:
# 保存当前工作目录
old_cwd = os.getcwd()
# 切换到临时目录
os.chdir(newpath)
# yield 之后,测试函数开始执行
yield
# 测试函数执行完毕后,恢复原始工作目录
os.chdir(old_cwd)
并通过在测试模块中使用 usefixtures
标记来声明其使用:
# content of test_setenv.py
import os
import pytest
# 使用 usefixtures 标记来声明 TestDirectoryInit 类中的每个测试方法都需要 cleandir fixture
@pytest.mark.usefixtures("cleandir")
class TestDirectoryInit:
def test_cwd_starts_empty(self):
"""
测试当前工作目录在开始时是空的。
"""
assert os.listdir(os.getcwd()) == [] # 确保当前目录为空
# 在当前目录创建一个文件
with open("myfile", "w", encoding="utf-8") as f:
f.write("hello")
# 注意:虽然这里创建了文件,但由于 yield 的作用,文件将在测试结束后自动被删除(因为临时目录被删除了)
def test_cwd_again_starts_empty(self):
"""
测试当前工作目录在每个测试方法开始前都是空的。
"""
assert os.listdir(os.getcwd()) == [] # 再次确保当前目录为空
由于使用了 usefixtures
标记,cleandir
fixture 将在执行 TestDirectoryInit
类中的每个测试方法时自动被调用,就好像你为它们每个都指定了一个名为 cleandir
的函数参数一样。现在,让我们运行这些测试来验证我们的 fixture 是否被激活,并且测试是否通过:
$ pytest -q
.. [100%]
2 passed in 0.12s
你可以像这样指定多个 fixture:
@pytest.mark.usefixtures("cleandir", "anotherfixture")
def test():
# ... 测试代码 ...
此外,你也可以在测试模块级别使用 pytestmark来指定 fixture 的使用:
pytestmark = pytest.mark.usefixtures("cleandir")
# 然后在这个模块中的测试函数或类将自动使用 "cleandir" fixture
还有一种可能的方式是将你项目中所有测试所需的 fixture 放入一个 ini 文件中:
# content of pytest.ini
[pytest]
usefixtures = cleandir
警告
请注意,这个标记在 fixture 函数中没有任何效果。例如,下面的用法不会按预期工作:
@pytest.mark.usefixtures("my_other_fixture")
@pytest.fixture
def my_fixture_that_sadly_wont_use_my_other_fixture():
# ...
上述代码会产生一个弃用警告,并且在 Pytest 8 中将成为一个错误。这是因为 @pytest.mark.usefixtures
标记是设计用来在测试函数或测试类上使用的,而不是在 fixture 函数上。
在不同层级上覆盖 fixture
在相对较大的测试套件中,你很可能需要用局部定义的 fixture 来覆盖全局或根级的 fixture,以保持测试代码的可读性和可维护性。这样做可以让你为特定的测试场景或测试集定制环境或前置条件,而不影响其他测试。例如,你可能有一个全局的 database_fixture
,但在某些测试模块或测试类中,你可能需要一个不同的数据库配置或初始化方式,这时就可以通过定义同名的局部 fixture 来覆盖全局的 fixture。Pytest 会根据 fixture 的作用域和查找顺序来决定使用哪个 fixture。
在文件夹(conftest)级别覆盖 fixture
给定以下测试文件结构:
tests/
conftest.py
# content of tests/conftest.py
import pytest
@pytest.fixture
def username():
return 'username'
test_something.py
# content of tests/test_something.py
def test_username(username):
assert username == 'username'
subfolder/
conftest.py
# content of tests/subfolder/conftest.py
import pytest
@pytest.fixture
def username(username):
return 'overridden-' + username
test_something_else.py
# content of tests/subfolder/test_something_else.py
def test_username(username):
assert username == 'overridden-username'
正如你所看到的,对于特定的测试文件夹层级,可以使用相同名称的 fixture 来进行覆盖。值得注意的是,从覆盖的 fixture 中可以很容易地访问到基础或超类 fixture,这在上面的示例中已经得到了应用。
在测试模块级别覆盖一个fixture
对于特定的测试模块(即Python文件),你可以定义一个与全局或父级conftest.py
文件中定义的fixture同名的fixture,从而在该测试模块内部使用新的fixture定义。Pytest会按照作用域(scope)和查找顺序(通常是最近的定义优先)来选择使用哪个fixture。
当你在测试模块级别覆盖fixture时,你只需在该测试模块的顶层代码中(即直接在该Python文件内)使用@pytest.fixture
装饰器来定义一个新的fixture。这个新的fixture将仅在该测试模块内有效,并会覆盖任何具有相同名称的、作用域更广的fixture。
tests/
conftest.py
# content of tests/conftest.py
import pytest
@pytest.fixture
def username():
return 'username'
test_something.py
# content of tests/test_something.py
import pytest
@pytest.fixture
def username(username):
return 'overridden-' + username
def test_username(username):
assert username == 'overridden-username'
test_something_else.py
# content of tests/test_something_else.py
import pytest
@pytest.fixture
def username(username):
return 'overridden-else-' + username
def test_username(username):
assert username == 'overridden-else-username'
直接通过测试参数化来“覆盖”一个fixture
tests/
conftest.py
# content of tests/conftest.py
import pytest
@pytest.fixture
def username():
return 'username'
@pytest.fixture
def other_username(username):
return 'other-' + username
test_something.py
# content of tests/test_something.py
import pytest
@pytest.mark.parametrize('username', ['directly-overridden-username'])
def test_username(username):
assert username == 'directly-overridden-username'
@pytest.mark.parametrize('username', ['directly-overridden-username-other'])
def test_username_other(other_username):
assert other_username == 'other-directly-overridden-username-other'
使用非参数化的fixture覆盖参数化的fixture,反之亦然
tests/
conftest.py
# content of tests/conftest.py
import pytest
@pytest.fixture(params=['one', 'two', 'three'])
def parametrized_username(request):
return request.param
@pytest.fixture
def non_parametrized_username(request):
return 'username'
test_something.py
# content of tests/test_something.py
import pytest
@pytest.fixture
def parametrized_username():
return 'overridden-username'
@pytest.fixture(params=['one', 'two', 'three'])
def non_parametrized_username(request):
return request.param
def test_username(parametrized_username):
assert parametrized_username == 'overridden-username'
def test_parametrized_username(non_parametrized_username):
assert non_parametrized_username in ['one', 'two', 'three']
test_something_else.py
# content of tests/test_something_else.py
def test_username(parametrized_username):
assert parametrized_username in ['one', 'two', 'three']
def test_username(non_parametrized_username):
assert non_parametrized_username == 'username'
使用来自其他项目的fixture
通常,提供pytest支持的项目会使用入口点( entry points),因此只需将这些项目安装到环境中,就可以使这些fixture可供使用。
如果你想要用来自不使用入口点的项目的fixture,你可以在你的顶级conftest.py
文件中定义pytest_plugins来将该模块注册为插件。
假设你在mylibrary.fixtures
中有一些fixture,并且你想要在你的app/tests
目录中重用它们。
你所需要做的就是在app/tests/conftest.py
中定义pytest_plugins
并指向该模块。
pytest_plugins = "mylibrary.fixtures"
这实际上将mylibrary.fixtures
注册为一个插件,使其所有fixture和钩子对app/tests
中的测试可用。
注意
有时用户会从其他项目中导入fixture以供使用,但这并不推荐:将fixture导入到模块中会按照该模块中定义的方式在pytest中注册它们。
这会产生一些细微的影响,比如在pytest --help
中多次出现,但不推荐这样做,因为这种行为在未来的版本中可能会改变/停止工作。