目录
使用 assert
语句进行断言
pytest 允许你在 Python 测试中使用标准的 Python assert
语句来验证预期值和实际值。例如,你可以编写如下内容:
# content of test_assert1.py
def f():
return 3
def test_function():
assert f() == 4
来断言你的函数返回某个特定值。如果这个断言失败,你将看到函数调用返回的值:
$ pytest test_assert1.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 1 item
test_assert1.py F [100%]
================================= FAILURES =================================
______________________________ test_function _______________________________
def test_function():
> assert f() == 4
E assert 3 == 4
E + where 3 = f()
test_assert1.py:6: AssertionError
========================= short test summary info ==========================
FAILED test_assert1.py::test_function - assert 3 == 4
============================ 1 failed in 0.12s =============================
pytest 支持显示最常见子表达式(包括调用、属性、比较以及二元和一元运算符)的值。(请参阅 pytest 的 Python 失败报告演示)。这允许你在不丢失自省信息的情况下,使用 Python 的惯用构造而无需编写样板代码。
如果在断言中像这样指定了一条消息:
assert a % 2 == 0, "value was odd, should be even"
那么这条消息将与回溯中的断言自省信息一起打印出来。
要了解更多关于断言自省的信息,请参阅断言自省详情。
关于预期异常的断言
为了编写关于抛出的异常的断言,你可以将 pytest.raises()
用作上下文管理器,如下所示:
import pytest
def test_zero_division():
with pytest.raises(ZeroDivisionError):
1 / 0
test_zero_division
函数测试了当尝试除以零时是否会抛出 ZeroDivisionError
异常。通过使用 pytest.raises(ZeroDivisionError)
作为上下文管理器,pytest 会捕获在 with
块中抛出的任何异常,并检查它是否是 ZeroDivisionError
。如果是,测试将通过;如果不是,或者没有抛出任何异常,测试将失败。
如果你需要访问实际的异常信息,你可以使用如下方式:
def test_recursion_depth():
with pytest.raises(RuntimeError) as excinfo:
def f():
f() # 这将导致无限递归,最终引发RuntimeError
f()
# 检查异常消息中是否包含"maximum recursion"
assert "maximum recursion" in str(excinfo.value)
test_recursion_depth
函数定义了一个内部函数 f
,该函数通过调用自身来触发无限递归。由于 Python 有一个递归深度限制(默认在大多数平台上为 1000),因此这个递归调用最终会触发 RuntimeError
异常。通过使用 pytest.raises(RuntimeError) as excinfo
,我们可以捕获这个异常,并将其存储在 excinfo
变量中。excinfo
是一个 ExceptionInfo
对象,它包含了关于异常的详细信息,比如异常的类型、值(即异常对象本身)和回溯信息。在 with
块之后,我们通过 assert "maximum recursion" in str(excinfo.value)
来检查捕获的异常消息中是否包含特定的字符串 "maximum recursion"
。这样做可以验证我们是否捕获了预期的异常,并且异常消息是否包含了我们期望的内容。如果断言为真(即异常消息中确实包含了 "maximum recursion"
),则测试通过;否则,测试失败。
请注意,pytest.raises
会匹配异常类型或其任何子类(类似于标准的 except
语句)。如果你想要检查某段代码是否抛出了精确类型的异常,你需要明确地进行检查:
def test_foo_not_implemented():
def foo():
raise NotImplementedError
with pytest.raises(RuntimeError) as excinfo:
foo()
assert excinfo.type is RuntimeError
虽然函数抛出了 NotImplementedError
,但 pytest.raises()
调用仍然会成功,因为 NotImplementedError
是 RuntimeError
的一个子类;然而,随后的断言语句将会捕获到问题。
匹配异常消息
你可以向上下文管理器传递一个 match
关键字参数,以测试正则表达式是否与异常的字符串表示匹配(类似于 unittest
中的 TestCase.assertRaisesRegex
方法):
import pytest
def myfunc():
raise ValueError("Exception 123 raised")
def test_match():
with pytest.raises(ValueError, match=r".* 123 .*") as excinfo::
myfunc()
# 此时不需要额外的断言,因为 match 参数已经验证了异常消息
# 但如果你需要访问异常对象,可以通过 excinfo 变量来访问
assert excinfo.type is ValueError
pytest.raises(ValueError, match=r".*123.*")
会捕获 ValueError
异常,并检查异常的消息是否包含字符串 "123"。如果包含,测试将继续执行;如果不包含,测试将失败。注意,虽然在这个例子中我也包含了额外的断言来验证异常类型和消息,但通常 match
参数已经足够用来验证异常消息了。
注释:
match
参数与 re.search()
函数进行匹配,所以在上面的例子中,match='123'
也能正常工作。
match
参数还与 PEP-678 中的 __notes__
进行匹配。
匹配异常组
你也可以使用excinfo.group_contains()
方法来测试作为ExceptionGroup
一部分返回的异常。这在处理一组相关的异常时特别有用,例如,当你有多个不同的异常需要被捕获和处理,但它们被一起抛出时。
def test_exception_in_group():
# 使用pytest的raises上下文管理器来捕获抛出的ExceptionGroup
with pytest.raises(ExceptionGroup) as excinfo:
# 抛出一个包含单个RuntimeError的ExceptionGroup
raise ExceptionGroup(
"Group message", # 这是异常组的总体消息
[
RuntimeError("Exception 123 raised"), # 这是异常组中包含的具体异常
],
)
# 使用excinfo.group_contains()检查异常组中是否包含RuntimeError
# 并且其消息是否匹配正则表达式".* 123 .*"
assert excinfo.group_contains(RuntimeError, match=r".* 123 .*")
# 检查异常组中是否不包含TypeError
assert not excinfo.group_contains(TypeError)
ExceptionGroup
被用于表示一组异常的集合,可选的 match
关键字参数在与 pytest.raises()
一起使用时,其工作方式保持不变。在测试过程中,我们通过pytest.raises(ExceptionGroup)
捕获了这个异常组,并使用excinfo.group_contains()
来验证它是否包含了我们期望的异常类型(在这个例子中是RuntimeError
)以及该异常的消息是否符合特定的正则表达式模式。此外,我们还验证了异常组中不包含TypeError
,以进一步确认ExceptionGroup
的内容符合预期。
默认情况下,group_contains()
方法会递归地在任何嵌套的 ExceptionGroup
实例的层级中搜索匹配的异常。如果你只想在特定层级匹配异常,你可以指定一个 depth
关键字参数;直接包含在顶层 ExceptionGroup
中的异常将匹配 depth=1
。
以下是一个测试示例,演示了如何在给定深度下检查 ExceptionGroup
中的异常:
def test_exception_in_group_at_given_depth():
with pytest.raises(ExceptionGroup) as excinfo:
# 抛出一个包含 RuntimeError 和嵌套 ExceptionGroup(包含 TypeError)的 ExceptionGroup
raise ExceptionGroup(
"Group message",
[
RuntimeError(), # 顶层 ExceptionGroup 中的异常
ExceptionGroup(
"Nested group",
[
TypeError(), # 嵌套 ExceptionGroup 中的异常
],
),
],
)
# 检查顶层 ExceptionGroup 中是否包含 RuntimeError(深度为 1)
assert excinfo.group_contains(RuntimeError, depth=1)
# 检查嵌套深度为 2 的 ExceptionGroup 中是否包含 TypeError
assert excinfo.group_contains(TypeError, depth=2)
# 检查顶层 ExceptionGroup 中是否不包含 RuntimeError(错误地指定了深度为 2)
assert not excinfo.group_contains(RuntimeError, depth=2)
# 检查顶层 ExceptionGroup 中是否不包含 TypeError(因为 TypeError 在更深的层级)
assert not excinfo.group_contains(TypeError, depth=1)
在这个测试中,我们抛出了一个包含 RuntimeError
和一个嵌套 ExceptionGroup
(后者包含 TypeError
)的 ExceptionGroup
。然后,我们使用 excinfo.group_contains()
方法来验证在不同深度下是否包含特定的异常类型。通过这种方式,我们可以精确地控制我们想要检查的异常层级。
备用形式(旧版)
还有一种备用形式(或称为旧版形式),其中你传递一个将要执行的函数,以及 *args 和 **kwargs,然后 pytest.raises()
会使用这些参数执行该函数,并断言是否抛出了指定的异常:
def func(x):
if x <= 0:
raise ValueError("x needs to be larger than zero")
# 使用 pytest.raises 的备用形式来断言 func(x=-1) 抛出了 ValueError
pytest.raises(ValueError, func, x=-1)
pytest.raises()
接收了三个参数:期望的异常类型(ValueError
)、要执行的函数(func
)以及该函数的一个参数(x=-1
)。如果 func(x=-1)
确实抛出了 ValueError
异常,则测试通过;否则,测试失败。
这种形式是原始的 pytest.raises()
API,它在 Python 语言中添加 with
语句之前就已经被开发出来了。现在,这种形式很少被使用,因为使用上下文管理器形式(即使用 with
)被认为更加易读。尽管如此,这种形式是完全支持的,并且没有任何废弃的迹象。
Xfail mark和pytest.raise
还可以在 pytest.mark.xfail
上指定一个 raises
参数,用于以比仅仅抛出任何异常更具体的方式检查测试是否失败:
def f():
raise IndexError()
@pytest.mark.xfail(raises=IndexError)
def test_f():
f()
这个测试只有在 f()
函数抛出 IndexError
或其子类异常时,才会被标记为“预期失败”(xfail)。如果 f()
抛出的是其他类型的异常或成功执行而没有抛出异常,则测试将不会被视为“预期失败”,而是根据异常类型或成功结果来正常地通过或失败。
使用带有 raises
参数的 pytest.mark.xfail
可能更适合于记录未修复的bug(其中测试描述了“应该”发生的情况)或依赖项中的bug。
而使用 pytest.raises()
可能更适合于测试你自己的代码中故意抛出的异常的情况,这也是大多数情况。
关于预期警告的断言
您可以使用pytest. warnings检查代码是否会引发特定的警告。
上下文感知的比较
pytest 在遇到比较时提供了丰富的上下文感知信息支持。例如:
# test_assert2.py 文件的内容
def test_set_comparison():
set1 = set("1308")
set2 = set("8035")
assert set1 == set2
如果运行这个模块:
$ pytest test_assert2.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 1 item
test_assert2.py F [100%]
================================= FAILURES =================================
___________________________ test_set_comparison ____________________________
def test_set_comparison():
set1 = set("1308")
set2 = set("8035")
> assert set1 == set2
E AssertionError: assert {'0', '1', '3', '8'} == {'0', '3', '5', '8'}
E
E Extra items in the left set:
E '1'
E Extra items in the right set:
E '5'
E Use -v to get more diff
test_assert2.py:4: AssertionError
========================= short test summary info ==========================
FAILED test_assert2.py::test_set_comparison - AssertionError: assert {'0'...
============================ 1 failed in 0.12s =============================
对一些情况进行了特殊比较:
- 比较长字符串时:会显示上下文差异。
- 比较长序列时:会显示第一个失败的索引。
- 比较字典时:会显示不同的条目。
请查看报告演示(reporting demo)以获取更多示例。
为失败的断言定义自己的解释
通过实现pytest_assertrepr_compare
钩子,可以添加你自己详细的解释说明。
pytest_assertrepr_compare(config, op, left, right) [source]
该钩子函数用于在断言表达式失败时,为比较操作返回详细的解释说明。
参数解释:
config
:一个pytest配置对象,可以用来访问pytest的配置信息。op
:一个字符串,表示比较操作符(如==
、!=
、<
等)。left
:比较操作符左边的值。right
:比较操作符右边的值。
这个函数应该返回一个字符串,该字符串包含了关于为什么断言失败的详细解释。如果你没有实现这个钩子,pytest将使用其默认的解释机制。
实现这个钩子可以让你对特定的比较类型或值提供自定义的、更友好的错误消息,从而提高测试的可读性和维护性。例如,你可以为自定义的数据类型或复杂的数据结构提供详细的差异比较,而不是仅仅显示它们的字符串表示。
假设我们想要在所有整数比较失败时都添加一个“魔法数字”的注释,
# 假设这个代码位于一个名为conftest.py的文件中
def pytest_assertrepr_compare(config, op, left, right):
# 检查是否为整数类型的比较
if isinstance(left, int) and isinstance(right, int):
# 创建一个包含自定义信息的错误消息列表
# 注意:这里我们简单地添加了一个额外的解释行,但在实际应用中,你可能想要更复杂的逻辑
explanation = [
f"整数比较失败: {left} {op} {right}",
"(注意:这可能是因为你没有找到正确的魔法数字!)",
]
# 返回自定义的错误消息列表
return explanation
# 注意:pytest将自动调用conftest.py中的pytest_assertrepr_compare钩子
# 如果它检测到有断言失败,并且该断言涉及整数类型的比较
# 假设这个代码位于一个测试文件中
def test_integer_comparison():
assert 5 == 3 # 这个断言会失败
运行测试时,你将看到类似以下的输出
E AssertionError: 整数比较失败: 5 == 3
E (注意:这可能是因为你没有找到正确的魔法数字!)
在测试插件中使用
任何conftest文件都可以实现这个钩子。对于给定的测试项,只有位于该项的目录及其父目录中的conftest文件会被考虑。
这意味着pytest_assertrepr_compare
钩子可以在项目的任何conftest.py
文件中实现,但pytest在查找钩子实现时会根据测试项的目录结构来确定哪些conftest.py
文件是相关的。如果测试项位于某个特定的目录结构中,pytest会从该目录开始向上搜索父目录,直到找到包含钩子实现的conftest.py
文件为止。这样设计的好处是允许你根据项目的不同部分或模块来定制断言失败时的行为。
作为示例,考虑在conftest.py
文件中添加以下钩子,该钩子为Foo
对象提供了另一种解释:
# conftest.py 文件的内容
from test_foocompare import Foo # 注意:这里假设 Foo 类定义在 test_foocompare.py 中,实际项目中可能需要调整导入路径
def pytest_assertrepr_compare(op, left, right):
if isinstance(left, Foo) and isinstance(right, Foo) and op == "==":
return [
"正在比较 Foo 实例:",
f" 值: {left.val} 不等于 {right.val}",
]
现在,给定以下测试模块:
# test_foocompare.py 文件的内容
class Foo:
def __init__(self, val):
self.val = val
def __eq__(self, other):
return self.val == other.val
def test_compare():
f1 = Foo(1)
f2 = Foo(2)
assert f1 == f2 # 这里断言会失败,因为 f1.val != f2.val
你可以运行test模块并获得conftest文件中定义的自定义输出:
$ pytest -q test_foocompare.py
F [100%]
================================= FAILURES =================================
_______________________________ test_compare _______________________________
def test_compare():
f1 = Foo(1)
f2 = Foo(2)
> assert f1 == f2
E assert Comparing Foo instances:
E vals: 1 != 2
test_foocompare.py:12: AssertionError
========================= short test summary info ==========================
FAILED test_foocompare.py::test_compare - assert Comparing Foo instances:
1 failed in 0.12s
断言自省细节
报告失败的断言的详细信息是通过在断言执行之前重写它们来实现的。重写的断言语句将自省信息放入断言失败的消息中。pytest
仅重写其测试收集过程直接发现的测试模块中的断言,因此,那些不是测试模块本身的支持模块中的断言将不会被重写。
你可以通过在导入模块之前调用register_assert_rewrite来手动为导入的模块启用断言重写(在根目录的 conftest.py
文件中进行此操作是一个好位置)。
有关更多信息,Benjamin Peterson 撰写了关于 pytest
新断言重写机制的幕后文章(Behind the scenes of pytest’s new assertion rewriting.)。
断言重写将文件缓存到磁盘上
pytest 会将重写后的模块写回磁盘以进行缓存。如果你想要禁用这种行为(例如,为了避免在经常移动文件的项目中留下过时的 .pyc
文件),你可以在你的 conftest.py
文件顶部添加以下内容来实现:
import sys
sys.dont_write_bytecode = True
这行代码会告诉 Python 解释器不要在运行时生成 .pyc
文件,从而阻止 pytest 缓存重写后的模块到磁盘上。这有助于保持项目目录的整洁,特别是在文件结构经常变动的项目中。
请注意,你仍然可以获得断言自省的好处,唯一的改变是 .pyc
文件不会被缓存到磁盘上。
另外,如果重写过程无法写入新的 .pyc
文件(例如,在只读文件系统或压缩文件中),则重写将静默地跳过缓存过程。
禁用断言重写
pytest 使用一个导入钩子来在导入测试模块时重写它们,以生成新的 pyc 文件。大多数情况下,这个过程是透明的。然而,如果你自己在使用导入机制,那么导入钩子可能会产生干扰。
如果是这种情况,你有两个选项:
-
通过在模块的文档字符串中添加字符串
PYTEST_DONT_REWRITE
来禁用对特定模块的重写。 -
通过使用
--assert=plain
选项来禁用对所有模块的重写。