如何在測試中編寫和報告斷言

使用 assert 語句進行斷言

pytest 允許您使用標準 Python assert 來驗證 Python 測試中的期望和值。例如,您可以編寫以下內容

# 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)

excinfoExceptionInfo 的實例,它是實際引發異常的包裝器。主要的感興趣的屬性是 .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

即使函數引發了 NotImplementedErrorpytest.raises() 調用也會成功,因為 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__ 匹配。

匹配異常組

您也可以使用 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)

替代形式(舊版)

有一種替代形式,您可以傳遞一個將被執行的函數,以及 *args**kwargs,並且 pytest.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”。

  • 對於記錄未修復的錯誤(其中測試描述了“應該”發生的情況)或依賴項中的錯誤,使用帶有 raises 參數的 pytest.mark.xfail 可能更好。

  • 對於您正在測試自己的代碼故意引發的異常的情況,使用 pytest.raises() 可能更好,這在大多數情況下都是如此。

關於預期警告的斷言

您可以使用 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)[來源]

返回失敗的斷言表達式中比較的解釋。

如果沒有自定義解釋,則返回 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

斷言內省詳情

報告關於失敗斷言的詳細信息是通過在運行之前重寫 assert 語句來實現的。重寫的 assert 語句將內省信息放入斷言失敗消息中。pytest 僅重寫其測試收集過程直接發現的測試模組,因此支持模組中本身不是測試模組的斷言將不會被重寫

您可以通過在導入模組之前調用 register_assert_rewrite,手動為導入的模組啟用斷言重寫(一個很好的位置是在您的根 conftest.py 中)。

有關更多信息,Benjamin Peterson 撰寫了 pytest 新斷言重寫的幕後花絮

斷言重寫將文件緩存在磁盤上

pytest 將把重寫的模組寫回磁盤以進行緩存。您可以通過將此添加到 conftest.py 文件的頂部來禁用此行為(例如,避免在大量移動文件的項目中留下過時的 .pyc 文件)

import sys

sys.dont_write_bytecode = True

請注意,您仍然可以獲得斷言內省的好處,唯一的變化是 .pyc 文件將不會緩存在磁盤上。

此外,如果重寫無法寫入新的 .pyc 文件(即在只讀文件系統或 zip 文件中),它將靜默跳過緩存。

禁用斷言重寫

pytest 在導入時通過使用導入 hook 來編寫新的 pyc 文件來重寫測試模組。大多數時候,這可以透明地工作。但是,如果您自己正在使用導入機制,則導入 hook 可能會干擾。

如果是這種情況,您有兩個選擇

  • 通過將字符串 PYTEST_DONT_REWRITE 添加到其文檔字符串中,禁用特定模組的重寫。

  • 通過使用 --assert=plain 禁用所有模組的重寫。