pytest8.x版本 中文使用文档-------3.如何在测试中编写和报告断言

目录

使用 assert 语句进行断言

关于预期异常的断言

匹配异常消息

匹配异常组

备用形式(旧版)

Xfail mark和pytest.raise

关于预期警告的断言

上下文感知的比较

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

断言自省细节

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

禁用断言重写


使用 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() 调用仍然会成功,因为 NotImplementedErrorRuntimeError 的一个子类;然而,随后的断言语句将会捕获到问题。

匹配异常消息

你可以向上下文管理器传递一个 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 文件。大多数情况下,这个过程是透明的。然而,如果你自己在使用导入机制,那么导入钩子可能会产生干扰。

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

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

  2. 通过使用 --assert=plain 选项来禁用对所有模块的重写。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值