如何在測試中撰寫和回報斷言

使用 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 關鍵字參數給 context-manager,以測試正規表示式是否與例外的字串表示相符(類似於 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 參數,因此在上述範例中,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,在 Python 語言新增 with 陳述式之前開發。如今,此形式很少使用,使用 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.xfail 搭配 raises 參數,可能比較適合用來記錄未修正的錯誤(測試描述「應該」發生的情況)或相依項中的錯誤。

  • 使用 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 掛勾來新增您自己的詳細說明。

pytest_assertrepr_compare(config, op, left, right)[原始碼]

傳回失敗 assert 表達式中比較項的解釋。

如果沒有自訂解釋,傳回 None,否則傳回字串清單。這些字串會以換行符號串接,但字串的換行符號會被跳脫。請注意,除了第一行之外,其他行都會稍微縮排,目的是讓第一行成為摘要。

參數:
  • config (Config) – pytest config 物件。

  • op (str) – 運算子,例如 "==""!=""not in"

  • left (object) – 左運算元。

  • right (object) – 右運算元。

在 conftest 外掛程式中使用

任何 conftest 檔案都可以實作這個掛鉤。對於給定的項目,只會諮詢項目目錄及其父目錄中的 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

斷言內省詳細資料

透過在執行斷言陳述前改寫它們,來報告有關失敗斷言的詳細資料。改寫後的斷言陳述會將內省資訊放入斷言失敗訊息中。 pytest 只會改寫其測試收集程序直接發現的測試模組,因此支援模組(本身不是測試模組)中的斷言不會被改寫

你可以透過在匯入模組前呼叫 register_assert_rewrite 來手動啟用對匯入模組的斷言改寫(一個執行此動作的好地方是根目錄中的 conftest.py)。

Benjamin Peterson 寫了一篇 揭開 pytest 新斷言改寫的幕後秘辛,提供進一步的資訊。

斷言改寫會快取檔案到磁碟

pytest 會將改寫後的模組寫回磁碟以進行快取。你可以停用這個行為(例如,避免在經常移動檔案的專案中留下過期的 .pyc 檔案),方法是在 conftest.py 檔案的頂端加入以下內容

import sys

sys.dont_write_bytecode = True

請注意,您仍可獲得斷言內省的好處,唯一變更為 .pyc 檔案不會快取在磁碟上。

此外,如果無法寫入新的 .pyc 檔案,例如在唯讀檔案系統或 zipfile 中,重寫會靜默略過快取。

停用斷言重寫

pytest 透過使用匯入掛鉤來寫入新的 pyc 檔案,在匯入時重寫測試模組。大多數時候,這會透明地運作。但是,如果您自己處理匯入機制,匯入掛鉤可能會造成干擾。

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

  • 透過將字串 PYTEST_DONT_REWRITE 加入文件字串,停用特定模組的重寫。

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