如何在测试中编写和报告断言

使用 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

如果你需要访问实际的异常信息,可以使用:

def test_recursion_depth():
    with pytest.raises(RuntimeError) as excinfo:

        def f():
            f()

        f()
    assert "maximum recursion" in str(excinfo.value)

excinfo 是一个 ExceptionInfo 实例,它是围绕实际抛出异常的包装器。主要感兴趣的属性是 .type.value.traceback

请注意,pytest.raises 将匹配异常类型或其任何子类(类似于标准的 except 语句)。如果你想检查代码块是否抛出确切的异常类型,你需要明确检查:

def test_foo_not_implemented():
    def foo():
        raise NotImplementedError

    with pytest.raises(RuntimeError) as excinfo:
        foo()
    assert excinfo.type is RuntimeError

pytest.raises() 调用将成功,即使函数抛出 NotImplementedError,因为 NotImplementedErrorRuntimeError 的子类;但是,下面的 assert 语句将捕获问题。

匹配异常消息

你可以向上下文管理器传递 match 关键字参数,以测试正则表达式是否匹配异常的字符串表示(类似于 unittest 中的 TestCase.assertRaisesRegex 方法):

import pytest


def myfunc():
    raise ValueError("Exception 123 raised")


def test_match():
    with pytest.raises(ValueError, match=r".* 123 .*"):
        myfunc()

注意

  • match 参数与 re.search() 函数进行匹配,因此在上面的示例中 match='123' 也能正常工作。

  • match 参数还会匹配 PEP-678 __notes__

关于预期异常组的断言

当期望 BaseExceptionGroupExceptionGroup 时,你可以使用 pytest.RaisesGroup

def test_exception_in_group():
    with pytest.RaisesGroup(ValueError):
        raise ExceptionGroup("group msg", [ValueError("value msg")])
    with pytest.RaisesGroup(ValueError, TypeError):
        raise ExceptionGroup("msg", [ValueError("foo"), TypeError("bar")])

它接受一个 match 参数,该参数检查组消息,以及一个 check 参数,该参数接受一个任意可调用对象,并将组传递给它,只有当可调用对象返回 True 时才成功。

def test_raisesgroup_match_and_check():
    with pytest.RaisesGroup(BaseException, match="my group msg"):
        raise BaseExceptionGroup("my group msg", [KeyboardInterrupt()])
    with pytest.RaisesGroup(
        Exception, check=lambda eg: isinstance(eg.__cause__, ValueError)
    ):
        raise ExceptionGroup("", [TypeError()]) from ValueError()

它对结构和未包装的异常是严格的,不像 except*,所以你可能需要设置 flatten_subgroups 和/或 allow_unwrapped 参数。

def test_structure():
    with pytest.RaisesGroup(pytest.RaisesGroup(ValueError)):
        raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),))
    with pytest.RaisesGroup(ValueError, flatten_subgroups=True):
        raise ExceptionGroup("1st group", [ExceptionGroup("2nd group", [ValueError()])])
    with pytest.RaisesGroup(ValueError, allow_unwrapped=True):
        raise ValueError

要指定有关所包含异常的更多详细信息,你可以使用 pytest.RaisesExc

def test_raises_exc():
    with pytest.RaisesGroup(pytest.RaisesExc(ValueError, match="foo")):
        raise ExceptionGroup("", (ValueError("foo")))

它们都提供了一个方法 pytest.RaisesGroup.matches()pytest.RaisesExc.matches(),如果你想在不将其用作上下文管理器的情况下进行匹配,这会很有帮助。在检查 .__context__.__cause__ 时,这会很有用。

def test_matches():
    exc = ValueError()
    exc_group = ExceptionGroup("", [exc])
    if RaisesGroup(ValueError).matches(exc_group):
        ...
    # helpful error is available in `.fail_reason` if it fails to match
    r = RaisesExc(ValueError)
    assert r.matches(e), r.fail_reason

有关更多详细信息和示例,请查看 pytest.RaisesGrouppytest.RaisesExc 的文档。

ExceptionInfo.group_contains()

警告

此助手使检查特定异常是否存在变得容易,但它对于检查组是否 包含 任何其他异常 来说非常糟糕。因此,这将通过:

class EXTREMELYBADERROR(BaseException):
    """This is a very bad error to miss"""


def test_for_value_error():
    with pytest.raises(ExceptionGroup) as excinfo:
        excs = [ValueError()]
        if very_unlucky():
            excs.append(EXTREMELYBADERROR())
        raise ExceptionGroup("", excs)
    # This passes regardless of if there's other exceptions.
    assert excinfo.group_contains(ValueError)
    # You can't simply list all exceptions you *don't* want to get here.

没有好的方法使用 excinfo.group_contains() 来确保你除了预期异常之外没有收到 任何 其他异常。你应该改用 pytest.RaisesGroup,请参阅关于预期异常组的断言

你还可以使用 excinfo.group_contains() 方法来测试作为 ExceptionGroup 一部分返回的异常:

def test_exception_in_group():
    with pytest.raises(ExceptionGroup) as excinfo:
        raise ExceptionGroup(
            "Group message",
            [
                RuntimeError("Exception 123 raised"),
            ],
        )
    assert excinfo.group_contains(RuntimeError, match=r".* 123 .*")
    assert not excinfo.group_contains(TypeError)

可选的 match 关键字参数的工作方式与 pytest.raises() 相同。

默认情况下,group_contains() 将递归搜索嵌套 ExceptionGroup 实例任何级别的匹配异常。如果你只想在特定级别匹配异常,可以指定 depth 关键字参数;直接包含在顶级 ExceptionGroup 中的异常将匹配 depth=1

def test_exception_in_group_at_given_depth():
    with pytest.raises(ExceptionGroup) as excinfo:
        raise ExceptionGroup(
            "Group message",
            [
                RuntimeError(),
                ExceptionGroup(
                    "Nested group",
                    [
                        TypeError(),
                    ],
                ),
            ],
        )
    assert excinfo.group_contains(RuntimeError, depth=1)
    assert excinfo.group_contains(TypeError, depth=2)
    assert not excinfo.group_contains(RuntimeError, depth=2)
    assert not excinfo.group_contains(TypeError, depth=1)

pytest.raises 的替代形式(旧版)

pytest.raises() 还有另一种形式,你可以传递一个将要执行的函数,以及 *args**kwargspytest.raises() 将使用这些参数执行函数,并断言抛出了给定的异常:

def func(x):
    if x <= 0:
        raise ValueError("x needs to be larger than zero")


pytest.raises(ValueError, func, x=-1)

在失败(例如 没有异常错误的异常)的情况下,报告器将为你提供有用的输出。

这种形式是原始的 pytest.raises() API,是在 with 语句添加到 Python 语言之前开发的。如今,这种形式很少使用,上下文管理器形式(使用 with)被认为更具可读性。尽管如此,这种形式仍然受到完全支持,并且没有以任何方式弃用。

xfail 标记和 pytest.raises

也可以为 pytest.mark.xfail 指定 raises 参数,它会检查测试是否以比简单抛出任何异常更具体的方式失败:

def f():
    raise IndexError()


@pytest.mark.xfail(raises=IndexError)
def test_f():
    f()

这只有在测试由于抛出 IndexError 或其子类而失败时才会“xfail”。

  • pytest.mark.xfailraises 参数一起使用可能更适合用于记录未修复的错误(测试描述了“应该”发生的情况)或依赖项中的错误。

  • 使用 pytest.raises() 可能更适合你正在测试自己代码故意抛出的异常的情况,这是大多数情况。

你也可以使用 pytest.RaisesGroup

def f():
    raise ExceptionGroup("", [IndexError()])


@pytest.mark.xfail(raises=RaisesGroup(IndexError))
def test_f():
    f()

关于预期警告的断言

你可以使用 pytest.warns 检查代码是否抛出特定警告。

利用上下文敏感的比较

pytest 对在遇到比较时提供上下文敏感信息有丰富的支持。例如:

# content of 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 =============================

对许多情况都进行了特殊比较:

  • 比较长字符串:显示上下文差异

  • 比较长序列:显示第一个失败的索引

  • 比较字典:显示不同的条目

有关更多示例,请参阅报告演示

为失败的断言定义自己的解释

可以通过实现 pytest_assertrepr_compare hook 来添加你自己的详细解释。

pytest_assertrepr_compare(config, op, left, right)[source]

返回失败断言表达式中比较的解释。

如果没有自定义解释,则返回 None,否则返回字符串列表。字符串将用换行符连接,但字符串 的任何换行符都将被转义。请注意,除第一行外的所有行都将略微缩进,目的是让第一行作为摘要。

参数:
  • config (Config) – pytest 配置对象。

  • op (str) – 运算符,例如 "==""!=""not in"

  • left (object) – 左操作数。

  • right (object) – 右操作数。

在 conftest 插件中使用

任何 conftest 文件都可以实现此 hook。对于给定的项,只查询该项所在目录及其父目录中的 conftest 文件。

例如,考虑在 conftest.py 文件中添加以下 hook,它为 Foo 对象提供替代解释:

# content of conftest.py
from test_foocompare import Foo


def pytest_assertrepr_compare(op, left, right):
    if isinstance(left, Foo) and isinstance(right, Foo) and op == "==":
        return [
            "Comparing Foo instances:",
            f"   vals: {left.val} != {right.val}",
        ]

现在,给定这个测试模块:

# content of 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

你可以运行测试模块并获取在 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 新断言重写幕后》一文。

断言重写将文件缓存到磁盘上

pytest 将重写后的模块写回磁盘以进行缓存。你可以禁用此行为(例如,为了避免在经常移动文件的项目中留下陈旧的 .pyc 文件),方法是在你的 conftest.py 文件顶部添加以下内容:

import sys

sys.dont_write_bytecode = True

请注意,你仍然可以获得断言内省的好处,唯一的改变是 .pyc 文件不会被缓存到磁盘上。

此外,如果无法写入新的 .pyc 文件(例如在只读文件系统或 zipfile 中),重写将静默跳过缓存。

禁用断言重写

pytest 在导入时通过使用导入 hook 来重写测试模块,以写入新的 pyc 文件。大多数情况下,这都是透明工作的。但是,如果你自己处理导入机制,导入 hook 可能会产生干扰。

如果是这种情况,你有两个选择:

  • 通过在其文档字符串中添加字符串 PYTEST_DONT_REWRITE 来禁用特定模块的重写。

  • 通过使用 --assert=plain 来禁用所有模块的重写。