目录
如何更改命令行选项的默认值
每次使用 pytest 时都输入相同的命令行选项序列可能会很繁琐。例如,如果你总是想查看跳过的和预期失败的测试的详细信息,并且希望有更简洁的“点”式进度输出,你可以将这些选项写入配置文件:
# pytest.ini 文件的内容
[pytest]
addopts = -ra -q
这里 -ra
选项表示“显示所有额外的测试摘要信息”,包括跳过的和预期失败的测试;-q
选项表示“更简洁的进度输出”,即只显示测试进度的点。
另外,你可以设置一个 PYTEST_ADDOPTS
环境变量,以便在环境使用中时添加命令行选项:
export PYTEST_ADDOPTS="-v"
这会将 -v
(表示“详细输出”)选项添加到 pytest 的命令行中,而无需修改配置文件或每次手动输入。这种方式在临时需要更改 pytest 的行为时特别有用,比如当你想在特定的终端会话中启用更详细的输出时。
在存在 addopts
或环境变量的情况下,命令行是这样构建的:
<pytest.ini:addopts> $PYTEST_ADDOPTS <extra command-line arguments>
因此,如果用户在命令行中执行:
pytest -m slow
实际执行的命令行是:
pytest -ra -q -v -m slow
请注意,与其他命令行应用程序一样,在出现冲突选项的情况下,最后一个选项会生效。所以,上面的例子中会显示详细的输出,因为 -v
(详细输出)覆盖了 -q
(更简洁的进度输出)。
根据命令行选项向测试函数传递不同的值
假设我们想编写一个依赖于命令行选项的测试。以下是一个实现这一需求的基本模式:
# content of test_sample.py
def test_answer(cmdopt):
if cmdopt == "type1":
print("first")
elif cmdopt == "type2":
print("second")
assert 0 # to see what was printed
通过一个 fixture 函数fixture function:来提供这个 cmdopt
值给测试函数。
# content of conftest.py
import pytest
def pytest_addoption(parser):
parser.addoption(
"--cmdopt", action="store", default="type1", help="my option: type1 or type2"
)
@pytest.fixture
def cmdopt(request):
return request.config.getoption("--cmdopt")
让我们在不提供新选项的情况下运行它:
$ pytest -q test_sample.py
F [100%]
================================= FAILURES =================================
_______________________________ test_answer ________________________________
cmdopt = 'type1'
def test_answer(cmdopt):
if cmdopt == "type1":
print("first")
elif cmdopt == "type2":
print("second")
> assert 0 # to see what was printed
E assert 0
test_sample.py:6: AssertionError
--------------------------- Captured stdout call ---------------------------
first
========================= short test summary info ==========================
FAILED test_sample.py::test_answer - assert 0
1 failed in 0.12s
在没有提供任何命令行选项的情况下,cmdopt
的默认值为 'type1'
,因此打印了 "first"。
接下来,我们提供一个命令行选项:
$ pytest -q --cmdopt=type2
F [100%]
================================= FAILURES =================================
_______________________________ test_answer ________________________________
cmdopt = 'type2'
def test_answer(cmdopt):
if cmdopt == "type1":
print("first")
elif cmdopt == "type2":
print("second")
> assert 0 # 这里故意使用assert 0来查看打印了什么
E assert 0
test_sample.py:6: AssertionError
--------------------------- Captured stdout call ---------------------------
second
========================= short test summary info ==========================
FAILED test_sample.py::test_answer - assert 0
1 failed in 0.12s
通过命令行选项 --cmdopt=type2
设置了 cmdopt
的值为 'type2'
,因此打印了 "second"
。
你可以看到命令行选项已经成功传递到了我们的测试中。
我们可以通过列出选择项来为输入添加简单的验证:
# conftest.py 的内容
import pytest
def pytest_addoption(parser):
parser.addoption(
"--cmdopt",
action="store",
default="type1",
help="我的选项:type1 或 type2",
choices=("type1", "type2"), # 这里指定了允许的选项
)
现在,如果我们传入一个错误的参数,我们会得到反馈:
$ pytest -q --cmdopt=type3
ERROR: usage: pytest [options] [file_or_dir] [file_or_dir] [...]
pytest: error: argument --cmdopt: invalid choice: 'type3' (choose from 'type1', 'type2')
如果你需要提供更详细的错误消息,你可以使用 type
参数并抛出 pytest.UsageError
:
# conftest.py 的内容
import pytest
def type_checker(value):
msg = "cmdopt 必须指定为 typeNNN 形式的数字类型"
if not value.startswith("type"):
raise pytest.UsageError(msg)
try:
int(value[4:]) # 尝试将 value[4:](即 "type" 后面的部分)转换为整数
except ValueError:
raise pytest.UsageError(msg)
return value
def pytest_addoption(parser):
parser.addoption(
"--cmdopt",
action="store",
default="type1",
help="我的选项:type1 或 type2(或其他以 'type' 开头后跟数字的格式)",
type=type_checker, # 使用自定义的类型检查函数
)
这完成了基本模式的设置。然而,在实际情况中,人们往往希望在测试之外处理命令行选项,并传入不同的或更复杂的对象。
动态添加命令行选项
通过 addopts,你可以为你的项目静态地添加命令行选项。此外,你还可以在命令行参数被处理之前动态地修改它们:
# setuptools 插件示例
import sys
def pytest_load_initial_conftests(args):
if "xdist" in sys.modules: # 如果已安装 pytest-xdist 插件
import multiprocessing
num = max(multiprocessing.cpu_count() // 2, 1) # 使用 CPU 数量的一半(至少为 1)作为子进程数
args[:] = ["-n", str(num)] + args # 动态地将 "-n" 选项和子进程数添加到命令行参数列表的开头
如果你已经安装了 xdist plugin插件,那么现在当你运行测试时,它总是会使用接近你 CPU 数量的子进程数来执行测试。在一个空的目录中,使用上面的 conftest.py
文件运行 pytest
:
$ pytest
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 0 items
========================== no tests ran in 0.12s ===========================
根据命令行选项控制跳过测试
以下是一个 conftest.py
文件的示例,它添加了一个 --runslow
命令行选项来控制跳过被 pytest.mark.slow
标记的测试:
# conftest.py 的内容
import pytest
def pytest_addoption(parser):
parser.addoption(
"--runslow", action="store_true", default=False, help="运行慢速测试"
)
def pytest_configure(config):
config.addinivalue_line("markers", "slow: 标记测试为慢速运行")
def pytest_collection_modifyitems(config, items):
if config.getoption("--runslow"):
# 如果在命令行中指定了 --runslow,则不跳过慢速测试
return
skip_slow = pytest.mark.skip(reason="需要 --runslow 选项来运行")
for item in items:
if "slow" in item.keywords:
item.add_marker(skip_slow)
现在,我们可以编写一个测试模块,如下所示:
# test_module.py 的内容
import pytest
def test_func_fast():
pass
@pytest.mark.slow
def test_func_slow():
pass
运行测试时,将看到一个被跳过的“slow”测试:
$ pytest -rs # "-rs" 表示在小写的 's' 上报告详细信息
=========================== 测试会话开始 ============================
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%]
========================= 简短的测试摘要信息 ==========================
SKIPPED [1] test_module.py:8: 需要 --runslow 选项来运行
======================= 1 passed, 1 skipped in 0.12s =======================
或者,包括被 slow
标记的测试一起运行:
$ pytest --runslow
=========================== 测试会话开始 ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 2 items
test_module.py .. [100%]
============================ 2 passed in 0.12s =============================
注:config.addinivalue_line()
是 pytest 框架中的一个方法,用于在 pytest 的配置文件(通常是 pytest.ini
或 tox.ini
文件,或者是通过 pytest_configure
钩子动态添加的)中添加或更新一个配置值。这个方法主要用于向 pytest 的配置系统添加自定义的命令行选项或配置项的文档说明。具体来说,config.addinivalue_line()
方法会在 pytest 的帮助信息(即当你运行 pytest --help
时显示的信息)中添加一行文本,这通常用于解释某个特定的命令行选项或配置项的作用。然而,需要注意的是,这个方法本身并不直接定义命令行选项或配置项的行为;它只是为已经存在或即将通过其他方式(如 pytest_addoption
钩子)定义的选项提供文档说明。
pytest_collection_modifyitems
是一个钩子函数(hook function),它允许你修改已经收集到的测试项(items)。这个函数在 pytest 收集完所有测试项之后,但在执行它们之前被调用。
config
参数是一个 pytest 配置对象,它包含了 pytest 的所有配置信息,包括命令行选项。items
是一个列表,包含了所有已经收集到的测试项。每个测试项都是一个pytest.Item
对象,它代表了将要执行的测试函数、类或方法。
编写良好的集成断言辅助函数
如果你在测试中调用了一个测试辅助函数,你可以使用pytest.fail
标记来以特定消息失败一个测试。如果你在某个辅助函数中的某个地方设置了__tracebackhide__
选项,那么该测试支持函数将不会出现在回溯信息中。以下是一个示例:
# test_checkconfig.py 文件内容
import pytest
def checkconfig(x):
__tracebackhide__ = True # 设置此选项以隐藏此函数的回溯信息
if not hasattr(x, "config"):
pytest.fail(f"not configured: {x}") # 如果x没有'config'属性,则测试失败并显示消息
def test_something():
checkconfig(42) # 调用checkconfig函数,传入整数42,因为整数没有'config'属性,所以测试将失败
checkconfig
函数是一个测试辅助函数,用于检查传入的对象x
是否具有config
属性。如果没有,它将使用pytest.fail
函数来失败测试,并显示一个自定义的错误消息。通过在checkconfig
函数内部设置__tracebackhide__ = True
,当测试失败时,回溯信息将不会显示checkconfig
函数的调用栈,这有助于使测试报告更加清晰,专注于测试失败的实际原因,而不是测试辅助函数的内部实现细节。
__tracebackhide__
设置影响了 pytest 对回溯信息的显示:除非指定了 --full-trace
命令行选项,否则 checkconfig
函数将不会显示在回溯中。现在,让我们运行我们的小函数:
$ pytest -q test_checkconfig.py
F [100%]
================================= FAILURES =================================
______________________________ test_something ______________________________
def test_something():
> checkconfig(42)
E Failed: not configured: 42
test_checkconfig.py:11: Failed
========================= short test summary info ==========================
FAILED test_checkconfig.py::test_something - Failed: not configured: 42
1 failed in 0.12s
如果你只想隐藏特定的异常,你可以将 __tracebackhide__
设置为一个可调用对象,该对象会接收到一个 ExceptionInfo
对象。你可以使用这种方式来确保意外的异常类型被不会隐藏。
import operator
import pytest
#class ConfigException(Exception): 这行代码定义了一个名为 ConfigException 的新异常类,它继承自Python内置的 Exception 类。这里的 Exception 是Python中所有异常类的基类,Exception 类(以及它的子类,如 ValueError、TypeError、KeyError 等)用于表示程序运行期间发生的异常情况。
class ConfigException(Exception):
pass
def checkconfig(x):
# 使用 operator.methodcaller 创建一个可调用对象,该对象调用 ExceptionInfo 的 errisinstance 方法
# 并传入 ConfigException 作为参数。这样,只有当异常是 ConfigException 类型时,回溯信息才会被隐藏。
__tracebackhide__ = operator.methodcaller("errisinstance", ConfigException)
if not hasattr(x, "config"):
# 如果 x 没有 'config' 属性,则抛出 ConfigException 异常
raise ConfigException(f"not configured: {x}")
def test_something():
# 调用 checkconfig 并传入一个整数 42,这将触发 ConfigException 异常
checkconfig(42)
运行此测试时,因为抛出了 ConfigException 异常,并且 __tracebackhide__ 被设置为仅隐藏 ConfigException 类型的异常,所以回溯信息将正常显示。如果 checkconfig 函数内部发生了其他类型的异常(即不是 ConfigException),那么这些异常的回溯信息将不会被隐藏,有助于调试。
在 Python 的 operator
模块中,methodcaller
函数用于动态地创建一个可调用的对象,该对象会调用其参数所指定的方法,并将后续传递给这个可调用对象的位置参数和关键字参数转发给那个方法。
检测是否从pytest运行中调用
通常,让应用程序代码在测试调用时表现不同是一个不好的想法。但是,如果你确实需要找出你的应用程序代码是否正在测试中运行,你可以采用类似下面的方法:
# content of your_module.py
_called_from_test = False
# content of conftest.py
def pytest_configure(config):
your_module._called_from_test = True
然后,在你的应用程序中检查your_module._called_from_test
标志:
if your_module._called_from_test:
# called from within a test run
...
else:
# called "normally"
...
向测试报告头部添加信息
在pytest运行时,展示额外信息非常简单:
# content of conftest.py
def pytest_report_header(config):
return "project deps: mylib-1.1"
这将把指定的字符串添加到测试报告的头部,如下所示:
$ pytest
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
project deps: mylib-1.1
rootdir: /home/sweet/project
collected 0 items
========================== no tests ran in 0.12s ===========================
同样地,你也可以返回一个字符串列表,这些字符串将被视为多行信息。如果你适用的话,你可以考虑使用config.getoption('verbose')
来显示更多信息:
# content of conftest.py
def pytest_report_header(config):
if config.getoption("verbose") > 0:
return ["info1: did you know that ...", "did you?"]
这将仅在以“--v”或更详细的级别运行时添加信息:
$ pytest -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
info1: did you know that ...
did you?
rootdir: /home/sweet/project
collecting ... collected 0 items
========================== no tests ran in 0.12s ===========================
而当以普通方式运行时,则不会显示任何额外信息:
$ pytest
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 0 items
========================== no tests ran in 0.12s ===========================
pytest_report_header
函数检查是否启用了详细模式(即--v
或更高的详细级别)。如果是,它将返回一个包含两条信息的列表,这些信息随后会作为测试报告的头部内容显示。否则,如果未启用详细模式,则不会返回任何信息,因此测试报告的头部将不包含这些额外行。
config.getoption("verbose") > 0
这行代码的含义是检查 pytest 是否以详细模式(verbose mode)运行,并且这个模式被设置为至少一个级别(即 -v
或更详细的级别,如 -vv
)。getoption(name)
方法用于获取指定名称的命令行选项的值。getoption("verbose")
返回的是 --verbose
或 -v
选项的值,这通常是一个整数,表示详细级别的数量(如果没有指定 -v
或类似的选项,则可能返回 0
或 None。
测试持续时间分析
如果你有一个运行缓慢的大型测试套件,你可能想要找出哪些测试是最慢的。让我们创建一个模拟的测试套件:
import time
def test_funcfast():
time.sleep(0.1)
def test_funcslow1():
time.sleep(0.2)
def test_funcslow2():
time.sleep(0.3)
现在我们可以分析哪些测试函数执行得最慢:
$ pytest --durations=3
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 3 items
test_some_are_slow.py ... [100%]
=========================== slowest 3 durations ============================
0.30s call test_some_are_slow.py::test_funcslow2
0.20s call test_some_are_slow.py::test_funcslow1
0.10s call test_some_are_slow.py::test_funcfast
============================ 3 passed in 0.12s =============================
我们使用了 pytest 的 --durations=N
选项,其中 N
是你希望显示的最慢测试的数量。在这个例子中,N
被设置为 3
,因此 pytest 列出了执行时间最长的三个测试函数,包括每个测试的名称和它们的执行时间。
增量测试 - 测试步骤
有时,你可能会遇到一种测试情况,它由一系列测试步骤组成。如果某个步骤失败了,那么继续执行后续步骤就没有意义了,因为后续步骤预计也会失败,而且它们的回溯信息也不会提供新的洞见。下面是一个简单的 conftest.py
文件示例,它引入了一个增量标记(incremental marker),该标记用于类上:
# content of conftest.py
from typing import Dict, Tuple
import pytest
# 用于存储每个测试类名和(如果使用parametrize)每个索引的失败历史
_test_failed_incremental: Dict[str, Dict[Tuple[int, ...], str]] = {}
def pytest_runtest_makereport(item, call):
# 如果测试项(item)使用了"incremental"标记
if "incremental" in item.keywords:
# 如果测试执行过程中发生了异常(即测试失败)
if call.excinfo is not None:
# 获取测试所在的类名
cls_name = str(item.cls)
# 如果测试使用了parametrize并且与incremental结合使用,则获取测试的参数索引
# 否则,索引为空元组
parametrize_index = (
tuple(item.callspec.indices.values())
if hasattr(item, "callspec")
else ()
)
# 获取测试函数的原始名称(如果存在),否则使用其名称
test_name = item.originalname or item.name
# 在_test_failed_incremental字典中,以类名和参数索引为键,存储失败的测试函数的名称
# 如果键不存在,则使用setdefault方法创建新的字典项
_test_failed_incremental.setdefault(cls_name, {}).setdefault(
parametrize_index, test_name
)
def pytest_runtest_setup(item):
# 如果测试项(item)使用了"incremental"标记
if "incremental" in item.keywords:
# 获取测试所在的类名
cls_name = str(item.cls)
# 检查之前是否有该类的测试失败
if cls_name in _test_failed_incremental:
# 如果使用了parametrize并且与incremental结合,则获取当前测试的索引
# 否则,索引为空元组
parametrize_index = (
tuple(item.callspec.indices.values())
if hasattr(item, "callspec")
else ()
)
# 从失败记录中获取该类和索引组合下第一个失败的测试函数名称
test_name = _test_failed_incremental[cls_name].get(parametrize_index, None)
# 如果找到了失败的测试名称,表示之前相同类名和索引的测试已经失败
# 因此,当前测试应该被标记为预期失败(xfail)
if test_name is not None:
pytest.xfail(f"previous test failed ({test_name})")
这两个钩子(hook)实现协同工作,用于在一个类中中断增量标记的测试。下面是一个测试模块的例子:
# content of test_step.py
import pytest
# 使用 @pytest.mark.incremental 装饰器来标记一个测试类,使其中的测试按顺序运行,并且一旦某个测试失败,后续的测试会被跳过。
@pytest.mark.incremental
class TestUserHandling:
def test_login(self):
# 假设这里实现了登录的逻辑,并且测试成功通过
pass
def test_modification(self):
# 这里故意制造一个断言失败,以模拟测试失败的情况
assert 0
def test_deletion(self):
# 由于 test_modification 测试失败,根据 incremental 标记,这个测试将不会被执行
pass
# 这是一个普通的测试函数,不受 incremental 标记的影响
def test_normal():
# 这个测试将正常执行,无论其他测试是否成功
pass
$ pytest -rx
=========================== 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_step.py .Fx. [100%]
================================= FAILURES =================================
____________________ TestUserHandling.test_modification ____________________
self = <test_step.TestUserHandling object at 0xdeadbeef0001>
def test_modification(self):
> assert 0
E assert 0
test_step.py:11: AssertionError
================================ XFAILURES =================================
______________________ TestUserHandling.test_deletion ______________________
item = <Function test_deletion>
def pytest_runtest_setup(item):
if "incremental" in item.keywords:
# retrieve the class name of the test
cls_name = str(item.cls)
# check if a previous test has failed for this class
if cls_name in _test_failed_incremental:
# retrieve the index of the test (if parametrize is used in combination with incremental)
parametrize_index = (
tuple(item.callspec.indices.values())
if hasattr(item, "callspec")
else ()
)
# retrieve the name of the first test function to fail for this class name and index
test_name = _test_failed_incremental[cls_name].get(parametrize_index, None)
# if name found, test has failed for the combination of class name & test name
if test_name is not None:
> pytest.xfail(f"previous test failed ({test_name})")
E _pytest.outcomes.XFailed: previous test failed (test_modification)
conftest.py:47: XFailed
========================= short test summary info ==========================
XFAIL test_step.py::TestUserHandling::test_deletion - reason: previous test failed (test_modification)
================== 1 failed, 2 passed, 1 xfailed in 0.12s ==================
我们将看到 test_deletion
没有被执行,因为 test_modification
失败了。它被报告为“预期失败”。
包/目录级别的夹具(设置)
如果你有嵌套的测试目录,你可以通过在每个目录中的conftest.py
文件中放置夹具函数来实现每个目录的夹具作用域。你可以使用所有类型的夹具,包括自动使用的夹具,它们相当于xUnit的setup/teardown概念。然而,建议在你的测试或测试类中显式地引用夹具,而不是依赖于隐式地执行setup/teardown函数,尤其是当它们远离实际测试时。这样做可以提高测试代码的可读性和可维护性。
以下是一个示例,展示了如何在一个目录中提供一个db
夹具:
# content of a/conftest.py
import pytest
class DB:
pass
# 使用 scope="package" 使得这个夹具在当前包(目录)内可用
@pytest.fixture(scope="package")
def db():
return DB()
然后,在该目录下的一个测试模块中:
# content of a/test_db.py
def test_a1(db):
assert 0, db # 这里使用断言失败来显示 db 的值
另一个测试模块:
# content of a/test_db2.py
def test_a2(db):
assert 0, db # 同样,这里使用断言失败来显示 db 的值
然后,在一个同级目录下(姐妹目录),该目录下的模块将无法看到db
夹具:
# content of b/test_error.py
def test_root(db): # 这里没有 db 夹具可用,将引发错误
pass
我们可以运行这个:
$ pytest
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 7 items
a/test_db.py F [ 14%]
a/test_db2.py F [ 28%]
b/test_error.py E [ 42%]
test_step.py .Fx. [100%]
================================== ERRORS ==================================
_______________________ ERROR at setup of test_root ________________________
file /home/sweet/project/b/test_error.py, line 1
def test_root(db): # no db here, will error out
E fixture 'db' not found
> available fixtures: cache, capfd, capfdbinary, caplog, capsys, capsysbinary, doctest_namespace, monkeypatch, pytestconfig, record_property, record_testsuite_property, record_xml_attribute, recwarn, tmp_path, tmp_path_factory, tmpdir, tmpdir_factory
> use 'pytest --fixtures [testpath]' for help on them.
/home/sweet/project/b/test_error.py:1
================================= FAILURES =================================
_________________________________ test_a1 __________________________________
db = <conftest.DB object at 0xdeadbeef0002>
def test_a1(db):
> assert 0, db # to show value
E AssertionError: <conftest.DB object at 0xdeadbeef0002>
E assert 0
a/test_db.py:2: AssertionError
_________________________________ test_a2 __________________________________
db = <conftest.DB object at 0xdeadbeef0002>
def test_a2(db):
> assert 0, db # to show value
E AssertionError: <conftest.DB object at 0xdeadbeef0002>
E assert 0
a/test_db2.py:2: AssertionError
____________________ TestUserHandling.test_modification ____________________
self = <test_step.TestUserHandling object at 0xdeadbeef0003>
def test_modification(self):
> assert 0
E assert 0
test_step.py:11: AssertionError
========================= short test summary info ==========================
FAILED a/test_db.py::test_a1 - AssertionError: <conftest.DB object at 0x7...
FAILED a/test_db2.py::test_a2 - AssertionError: <conftest.DB object at 0x...
FAILED test_step.py::TestUserHandling::test_modification - assert 0
ERROR b/test_error.py::test_root
============= 3 failed, 2 passed, 1 xfailed, 1 error in 0.12s ==============
在a
目录下的两个测试模块会共享同一个数据库fixture实例,而位于同级目录b
中的测试则不会看到它。当然,我们也可以在b
目录的conftest.py
文件中定义一个数据库fixture。请注意,每个fixture只有在至少有一个测试真正需要它时才会被实例化(除非你使用了“autouse”fixture,它会在第一个测试执行之前自动执行)。
测试报告/失败案例的后处理
如果你想要对测试报告进行后处理,并且需要访问执行环境,你可以实现一个钩子(hook),该钩子在测试“报告”对象即将被创建时被调用。在这里,我们编写代码来记录所有失败的测试调用,并且如果在测试中使用了fixture,我们还能访问它(如果你想在后处理期间查询或查看它)。在我们的例子中,我们只是将一些信息写入到一个名为failures
的文件中:
# content of conftest.py
import os.path
import pytest
@pytest.hookimpl(wrapper=True, tryfirst=True)
def pytest_runtest_makereport(item, call):
# 执行所有其他钩子以获得报告对象
rep = yield
# 我们只关注实际失败的测试调用,不包括设置/清理阶段
if rep.when == "call" and rep.failed:
mode = "a" if os.path.exists("failures") else "w" # 如果failures文件存在,则追加;否则,写入
with open("failures", mode, encoding="utf-8") as f:
# 为了演示,我们也尝试访问一个fixture
if "tmp_path" in item.fixturenames:
extra = " ({})".format(item.funcargs["tmp_path"]) # 如果测试使用了tmp_path fixture,则添加额外信息
else:
extra = ""
# 将失败的测试节点ID(可能带有额外信息)写入文件
f.write(rep.nodeid + extra + "\n")
return rep
定义了一个名为pytest_runtest_makereport
的钩子函数,它会在每个测试执行后并准备生成测试报告时被调用。如果测试失败(rep.failed
为True),则将该测试的唯一标识符(rep.nodeid
)写入到failures
文件中。如果测试使用了tmp_path
这个fixture(一个常见的用于临时文件存储的fixture),则还会在测试的唯一标识符后添加该fixture的值作为额外信息。注意,我们使用了tryfirst=True
参数来确保这个钩子尽可能早地被调用,但这不是必需的,因为wrapper=True
的使用已经允许我们“包装”并修改原始的钩子行为。
如果测试失败:
# content of test_module.py
def test_fail1(tmp_path):
assert 0
def test_fail2():
assert 0
然后运行它们:
$ pytest 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 FF [100%]
================================= FAILURES =================================
________________________________ test_fail1 ________________________________
tmp_path = PosixPath('PYTEST_TMPDIR/test_fail10')
def test_fail1(tmp_path):
> assert 0
E assert 0
test_module.py:2: AssertionError
________________________________ test_fail2 ________________________________
def test_fail2():
> assert 0
E assert 0
test_module.py:6: AssertionError
========================= short test summary info ==========================
FAILED test_module.py::test_fail1 - assert 0
FAILED test_module.py::test_fail2 - assert 0
============================ 2 failed in 0.12s =============================
你将有一个“failures”文件,其中包含失败的测试id:
$ cat failures
test_module.py::test_fail1 (PYTEST_TMPDIR/test_fail10)
test_module.py::test_fail2
在fixture中提供测试结果信息
如果你希望在fixture的终结器中获取测试结果报告,这里有一个通过本地插件实现的小例子:
# content of conftest.py
from typing import Dict
import pytest
from pytest import StashKey, CollectReport
# 创建一个StashKey来存储每个阶段的测试报告
phase_report_key = StashKey[Dict[str, CollectReport]]()
# 定义一个钩子函数,用于在每个测试报告生成时捕获报告
@pytest.hookimpl(wrapper=True, tryfirst=True)
def pytest_runtest_makereport(item, call):
# 执行其他钩子以获取报告对象
rep = yield
# 存储每个调用阶段(可以是"setup"、"call"、"teardown")的测试结果
item.stash.setdefault(phase_report_key, {})[rep.when] = rep
return rep
# 定义一个fixture
@pytest.fixture
def something(request):
yield
# request.node是一个"item",因为我们使用的是默认的"function"作用域
report = request.node.stash[phase_report_key]
# 检查设置阶段是否失败或被跳过
if report["setup"].failed:
print("设置测试失败或被跳过", request.node.nodeid)
# 检查执行阶段是否不存在或失败或被跳过
elif ("call" not in report) or report["call"].failed:
print("执行测试失败或被跳过", request.node.nodeid)
使用了pytest
的StashKey
来在测试项(item
)上存储与每个调用阶段("setup"、"call"、"teardown")相关的测试报告。然后,在something
这个fixture的终结器中,我们通过访问这些存储的报告来检查测试的设置和执行阶段是否成功。如果设置阶段失败或被跳过,或者执行阶段不存在或失败或被跳过,则会打印相应的信息。这种方法允许在fixture中根据测试结果来执行特定的清理或记录操作。
如果测试失败:
# content of test_module.py
import pytest
@pytest.fixture
def other():
assert 0
def test_setup_fails(something, other):
pass
def test_call_fails(something):
assert 0
def test_fail2():
assert 0
$ pytest -s 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 3 items
test_module.py Esetting up a test failed or skipped test_module.py::test_setup_fails
Fexecuting test failed or skipped test_module.py::test_call_fails
F
================================== ERRORS ==================================
____________________ ERROR at setup of test_setup_fails ____________________
@pytest.fixture
def other():
> assert 0
E assert 0
test_module.py:7: AssertionError
================================= FAILURES =================================
_____________________________ test_call_fails ______________________________
something = None
def test_call_fails(something):
> assert 0
E assert 0
test_module.py:15: AssertionError
________________________________ test_fail2 ________________________________
def test_fail2():
> assert 0
E assert 0
test_module.py:19: AssertionError
========================= short test summary info ==========================
FAILED test_module.py::test_call_fails - assert 0
FAILED test_module.py::test_fail2 - assert 0
ERROR test_module.py::test_setup_fails - assert 0
======================== 2 failed, 1 error in 0.12s ========================
你会发现,fixture的终结器可以使用精确的报告信息。
PYTEST_CURRENT_TEST 环境变量
有时测试会话可能会卡住,并且可能没有简单的方法来找出是哪个测试卡住了,例如,如果pytest以安静模式(-q)运行,或者你没有权限访问控制台输出。如果问题只是偶尔发生,特别是那些著名的“不稳定的”测试,这尤其是一个问题。
pytest在运行测试时会设置PYTEST_CURRENT_TEST环境变量,该变量可以被进程监控工具或像 psutil这样的库检查,以便在必要时发现哪个测试卡住了:
import psutil
for pid in psutil.pids(): # 遍历所有进程的PID
environ = psutil.Process(pid).environ() # 获取进程的环境变量
if "PYTEST_CURRENT_TEST" in environ: # 检查环境变量中是否存在PYTEST_CURRENT_TEST
print(f'pytest进程 {pid} 正在运行: {environ["PYTEST_CURRENT_TEST"]}') # 打印出正在运行的测试和阶段
在测试会话期间,pytest会将PYTEST_CURRENT_TEST设置为当前测试的nodeid和当前阶段,阶段可以是setup、call或teardown。这样,当测试卡住时,你就可以通过检查这个环境变量来确定是哪个测试卡住了以及它处于哪个阶段。
例如,当从foo_module.py
运行一个名为test_foo
的单个测试函数时,PYTEST_CURRENT_TEST
将按顺序设置为:
foo_module.py::test_foo (setup)
foo_module.py::test_foo (call)
foo_module.py::test_foo (teardown)
注意:PYTEST_CURRENT_TEST
的内容旨在便于人类阅读,并且其实际格式可能会在不同版本(甚至是修补程序)之间发生变化,因此不应依赖它进行脚本编写或自动化。
冻结pytest
如果你使用像PyInstaller这样的工具来冻结你的应用程序,以便将其分发给最终用户,那么同时打包你的测试运行器并使用冻结的应用程序运行测试也是一个好主意。这样,可以在早期发现诸如依赖项未包含在可执行文件中的打包错误,同时还可以向用户发送测试文件,以便他们可以在自己的计算机上运行这些文件,这对于获取难以重现的bug的更多信息非常有用。
幸运的是,最近的PyInstaller版本已经为pytest提供了一个自定义钩子,但如果你使用其他工具(如cx_freeze或py2exe)来冻结可执行文件,你可以使用pytest.freeze_includes()
来获取pytest内部模块的完整列表。不过,如何配置这些工具以找到内部模块因工具而异。
除了将pytest运行器作为单独的可执行文件冻结之外,你还可以通过在程序启动时巧妙地处理参数,使你的冻结程序充当pytest运行器的角色。这样,你就可以拥有一个单一的可执行文件,这通常更方便。但请注意,pytest用于插件发现的机制(setuptools入口点)不适用于冻结的可执行文件,因此pytest无法自动找到任何第三方插件。要包含像pytest-timeout这样的第三方插件,必须显式导入它们并将它们传递给pytest.main
。
# contents of app_main.py
import sys
import pytest_timeout # 导入第三方插件 pytest-timeout
# 检查命令行参数,判断是否要运行pytest测试
if len(sys.argv) > 1 and sys.argv[1] == "--pytest":
import pytest # 导入pytest
# 使用pytest的main函数运行测试,并传入剩余的命令行参数,同时指定使用pytest-timeout插件
sys.exit(pytest.main(sys.argv[2:], plugins=[pytest_timeout]))
else:
# 如果不是运行测试,则执行正常的应用程序逻辑
# 在这一点上,你可以使用你选择的参数解析库来解析argv
...
这段代码允许你使用标准的pytest命令行选项通过冻结的应用程序来执行测试。具体来说,当你运行./app_main --pytest
并跟上pytest的命令行选项(如--verbose
、--tb=long
、--junit=xml=results.xml
)以及要测试的文件或目录(如test-suite/
)时,它会启动pytest来运行这些测试。
例如,命令./app_main --pytest --verbose --tb=long --junit=xml=results.xml test-suite/
会执行以下操作:
- 检查是否传递了
--pytest
参数。 - 如果是,则导入pytest和pytest-timeout插件。
- 使用
pytest.main()
函数启动pytest,传入除--pytest
之外的所有命令行参数(即--verbose
、--tb=long
、--junit=xml=results.xml
和test-suite/
),并指定使用pytest-timeout插件。 - pytest将执行指定的测试,并根据提供的命令行选项生成输出(如详细的跟踪信息)和测试结果文件(如JUnit XML格式的
results.xml
)。 - pytest执行完毕后,程序通过
sys.exit()
退出,退出码由pytest决定,表示测试的成功或失败状态。
这种方式使得你可以在不改变现有应用程序结构的情况下,轻松地集成和运行测试。