在 pytest 测试中编写断言与报告失败

使用 assert 语句

pytest 允许直接使用 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-9.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 能显示常见子表达式的值,包括函数调用、属性、比较、二元和一元运算符。因此,无须编写额外的样板代码,就能使用惯常的 Python 语法并保留详细的断言诊断信息。更多例子见失败报告演示。

如果断言指定了消息:

assert a % 2 == 0, "value was odd, should be even"

回溯会把该消息与断言分析结果一起显示。后文介绍断言分析的实现细节。

近似相等断言

浮点数或浮点数组常有微小舍入误差。除 assert abs(a - b) < tol 或 numpy.isclose 外,也可以使用 pytest.approx():

import pytest
import numpy as np


def test_floats():
    assert (0.1 + 0.2) == pytest.approx(0.3)


def test_arrays():
    a = np.array([1.0, 2.0, 3.0])
    b = np.array([0.9999, 2.0001, 3.0])
    assert a == pytest.approx(b)

pytest.approx 支持标量、列表、字典和 NumPy 数组,也提供涉及 NaN 的比较选项。具体容差与 NaN 行为见 API 文档。

断言预期异常

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

和普通 except 一样,pytest.raises 接受指定异常类型及其子类。如果要求异常类型完全相同,必须显式检查:

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

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

这里 pytest.raises(RuntimeError) 会成功,因为 NotImplementedError 是 RuntimeError 的子类;随后使用 is 的断言则会发现类型不完全相同。

匹配异常消息

向上下文管理器传入 match,可以用正则表达式匹配异常的字符串表示,类似 unittest 的 TestCase.assertRaisesRegex:

import pytest


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


def test_match():
    with pytest.raises(ValueError, match=r".* 123 .*"):
        myfunc()
  • 匹配使用 re.search(),所以上例用 match='123' 也可以。
  • match 还会检查 PEP 678 的 __notes__。

断言预期异常组

预期出现 BaseExceptionGroup 或 ExceptionGroup 时,可以使用 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")))

两者都提供 .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

未匹配时,.fail_reason 提供失败解释。更详细的行为和示例见相应 API 文档。

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.

不能靠穷举所有不希望出现的异常,用 group_contains() 证明只有预期异常。此时应使用 pytest.RaisesGroup。

下面展示如何检查异常组中的异常:

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() 默认递归搜索任意嵌套层级。只想匹配某一层时,可以指定 depth;直接位于最外层异常组中的异常对应 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 的旧式调用形式

还可以传入待执行函数及其 *args、**kwargs。pytest 会调用该函数,并断言它抛出指定异常:

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


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

这是 Python 引入 with 语句以前就存在的原始 API。如今较少使用,因为上下文管理器形式通常更易读。

xfail 标记与 pytest.raises

pytest.mark.xfail 可以指定 raises 参数,要求测试以某种特定异常失败,而非抛出任何异常都算预期失败:

def f():
    raise IndexError()


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

该测试只有抛出 IndexError 或其子类时才会被视为 xfail。

  • 对于尚未修复的缺陷或依赖库问题,带 raises 的 xfail 更适合记录“本来应该发生什么”。
  • 对于自己的代码有意抛出的异常,通常应使用 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-9.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 =============================

它会针对多种情况生成特殊比较信息:长字符串显示上下文差异;长序列显示第一个不一致的下标;字典显示不同的键值项。

字符串差异中,带 - 的行来自 assert left == right 左侧,带 + 的行来自右侧。更多案例见报告演示。

自定义断言失败说明

实现 pytest_assertrepr_compare 钩子,可以提供自定义解释:

pytest_assertrepr_compare(config, op, left, right)

这个钩子为失败的比较断言返回说明。不提供自定义解释时返回 None,否则返回字符串列表。各字符串将用换行连接,但字符串内部原有的换行会被转义。除第一行外,其余行会略微缩进,因此第一行适合作为概括。

参数如下:config 是 pytest 配置对象;op 是运算符,例如 ==、!=、not in;left、right 分别是左右操作数。

在 conftest 插件中使用

任何 conftest 文件都能实现该钩子。对于具体测试项,pytest 只查询其所在目录及父目录中的 conftest 文件。

例如,在 conftest.py 中给 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

测试函数返回非 None 值

测试函数返回非 None 值时,pytest 会发出 PytestReturnNotNoneWarning。它能帮助发现一种常见误用:初学者可能以为返回 True 或 False 就能决定测试是否成功。

错误示例:

@pytest.mark.parametrize(
    ["a", "b", "result"],
    [
        [1, 2, 5],
        [2, 3, 8],
        [5, 3, 18],
    ],
)
def test_foo(a, b, result):
    return foo(a, b) == result  # Incorrect usage, do not do this.

pytest 忽略测试函数的返回值,因此这些测试不会因为返回 False 而失败。正确做法是把 return 改为 assert:

@pytest.mark.parametrize(
    ["a", "b", "result"],
    [
        [1, 2, 5],
        [2, 3, 8],
        [5, 3, 18],
    ],
)
def test_foo(a, b, result):
    assert foo(a, b) == result

断言分析的实现细节

pytest 在执行前重写 assert 语句,使失败消息中包含分析信息。它只自动重写测试收集过程中直接发现的测试模块;辅助模块如果本身不是测试模块,其中的 assert 默认不会重写。

要为某个待导入模块启用重写,可以在导入之前调用 register_assert_rewrite。根目录 conftest.py 是适合注册的位置。

更多实现说明可阅读 Benjamin Peterson 的 Behind the scenes of pytest’s new assertion rewriting。

断言重写的磁盘缓存

pytest 会将重写后的模块写回磁盘缓存。若项目经常移动文件,不想留下旧 .pyc,可以在 conftest.py 顶部加入:

import sys

sys.dont_write_bytecode = True

断言分析仍然有效,变化只是不会在磁盘缓存 .pyc。如果无法写入新的 .pyc,例如只读文件系统或 ZIP 文件中,pytest 也会静默跳过缓存。

禁用断言重写

pytest 使用导入钩子,在导入测试模块时重写断言并生成新的 pyc 文件。通常这一过程透明,但自己操作 Python 导入机制时,钩子可能造成干扰。此时有两个选择:

  • 在某个模块的文档字符串中加入 PYTEST_DONT_REWRITE,只禁用该模块的重写。
  • 使用 --assert=plain,为所有模块禁用重写。

来源与许可

原文:pytest:How to write and report assertions in tests。Copyright © 2015, holger krekel and pytest-dev team。本文为中文翻译,示例输出来自原文,不代表本译稿环境执行结果。依据 MIT 许可证保留完整声明:

The MIT License (MIT)

Copyright (c) 2004 Holger Krekel and others

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容