Pytest 參數化

已完成

這個裝飾器使 @pytest.mark.parametrize() 的測試函式以不同的輸入值多次執行。 當斷言的邏輯是相同的但範例不同時,可以使用。 參數化可以減少重複,並讓 pytest 將每個輸入作為獨立的測試項目報告。

裝飾師通常會考慮兩個必要的論點:

  • argnames:一個逗號分隔的字串,或一個串列或字串元組,並標示要傳遞給測試函式的參數名稱。 範例包括 "item"、 "test_input, expected"、 ["test_input", "expected"]和 ("test_input", "expected")。
  • argvalues:用於這些參數的可迭代值或參數集。 當只有一個參數名稱時,每個項目都會以一個測試值進行傳遞,即使該值為元組。 在多個參數名稱中,每個項目必須為每個參數名稱提供一個值,通常是元組、清單或 pytest.param(...) 呼叫。

可選 ids 參數讓你自訂 pytest 用來標記每個產生測試項目的測試 ID。 更進階的選項,如 indirect 和 scope,對於參數化夾具或昂貴資源等情況非常有用;你可以在 pytest 的 參數化操作 與 @pytest.mark.parametrize 參考中閱讀相關資訊。

備註

pytest 將參數值原封不動地傳遞給測試,它不會複製它們。 如果測試改變了可變參數值,例如清單或字典,該突變在後續產生的接收相同物件的案例中可見。

使用參數化的時機

以下是您可能會想使用 @pytest.mark.parametrize() 的兩種常見情況:

  • 當測試循環輸入並重複相同的斷言時
  • 當多個測試函數以不同輸入斷言相同行為時

我們先回顧每個範例,不使用 @pytest.mark.parametrize(),再用它來展示如何讓測試更易維護和診斷。

For 迴圈

以下是具有 for 循環的測試函式範例:

def test_string_is_digit():
    items = ["1", "10", "33"]
    for item in items:
        assert item.isdigit()

當一個輸入失效時,這種模式可能很難診斷:

  • 不完整的回饋: 第一次失敗的斷言會停止迴圈,因此後續的值不會在該執行中被評估。
  • 單次測試結果: Pytest 報告的是一個收集到的測試,而不是針對每個輸入的測試案例。
  • 重複重播: 修復第一次失敗後,可能只有在你再次執行測試後才會發現另一個失敗。

我們修改檢定,加入兩個會使該斷言失敗的項目:

def test_string_is_digit():
    items = ["No", "1", "10", "33", "Yes"]
    for item in items:
        assert item.isdigit()

執行測試只顯示第一個失敗,儘管清單中有兩個無效項目。 指令範例使用 python -m pytest,當 Python 以 python 形式可用時,該指令有效。 如果你的 shell 在 macOS 或 Linux 上使用 python3,或在 Windows 上用 py,請將該指令替換成該指令。

python -m pytest test_items.py
=================================== FAILURES ===================================
_____________________________ test_string_is_digit _____________________________

    def test_string_is_digit():
        items = ["No", "1", "10", "33", "Yes"]
        for item in items:
>           assert item.isdigit()
E           AssertionError: assert False
E            +  where False = <built-in method isdigit of str object at 0x...>()
E            +    where <built-in method isdigit of str object at 0x...> = 'No'.isdigit

test_items.py:4: AssertionError
=========================== short test summary info ============================
FAILED test_items.py::test_string_is_digit - AssertionError: assert False
============================== 1 failed in 0.01s ===============================

Pytest 的判斷提示輸出會顯示繫結的 str.isdigit 方法,以及觸發失敗的字串值。 會話標頭、平台細節、記憶體位址與執行時間可能有所不同,因此範例中會以...和0x...來省略或縮短這些內容。 相關細節是迴圈在第一次失敗'No'時停止,且永遠不會評估 'Yes'。

這是一個有用的@pytest.mark.parametrize()案例。 在更新測試之前,讓我們先探討另一種不涉及 for 迴圈的常見情況。

斷言相同行為的測試

進行相同判斷的一組測試也很適合使用 @pytest.mark.parametrize()。 如果先前的測試被重寫為對每個項目進行一次測試,那麼這將允許更好的失敗報告,但會重複:

def test_is_digit_1():
    assert "1".isdigit()

def test_is_digit_10():
    assert "10".isdigit()

def test_is_digit_33():
    assert "33".isdigit()

這些測試較為細緻,因為失敗可能與單一輸入相關聯。 雖然有多個類似測試看似罕見,但這種模式在嘗試精確報告失敗的生產測試套件中很常見。

然而,重複性測試會帶來以下問題:

  • 程式碼重複性強,造成維護負擔。
  • 類似的功能也很容易不一致地更新。
  • 新增輸入需要複製另一個測試實體,因此重要案例可能會被忽略。

參數化保留了獨立測試項目的報告效益,而不會複製同一個測試實體。

如何使用參數設定

現在您已經了解 @pytest.mark.parametrize() 的一些使用案例,讓我們更新使用 for 迴圈且包含失敗項目的測試。

匯入 pytest,然後直接套用 @pytest.mark.parametrize() 在測試函式上方:

import pytest

@pytest.mark.parametrize("item", ["No", "1", "10", "33", "Yes"])
def test_string_is_digit(item):
    assert item.isdigit()

在執行測試之前,我們來回顧一下變更。

在此範例中,裝飾者會收到兩個參數。 第一個參數 "item",表示 pytest 傳遞到測試函式的參數。 名稱必須與 中的item參數相符test_string_is_digit(item)。 第二個參數是 pytest 用於產生測試案例的數值清單。

豐富的錯誤報告

在幕後,pytest 會為參數列表中的每個值收集一個測試項目。 這表示通過與不及格的案例會分開報告。 讓我們看看執行測試時會發生什麼:

python -m pytest test_items.py
============================= test session starts ==============================
...
collected 5 items

test_items.py F...F                                                      [100%]

=================================== FAILURES ===================================
___________________________ test_string_is_digit[No] ___________________________

item = 'No'

    @pytest.mark.parametrize("item", ["No", "1", "10", "33", "Yes"])
    def test_string_is_digit(item):
>       assert item.isdigit()
E       AssertionError: assert False
E        +  where False = <built-in method isdigit of str object at 0x...>()
E        +    where <built-in method isdigit of str object at 0x...> = 'No'.isdigit

test_items.py:5: AssertionError
__________________________ test_string_is_digit[Yes] ___________________________

item = 'Yes'

    @pytest.mark.parametrize("item", ["No", "1", "10", "33", "Yes"])
    def test_string_is_digit(item):
>       assert item.isdigit()
E       AssertionError: assert False
E        +  where False = <built-in method isdigit of str object at 0x...>()
E        +    where <built-in method isdigit of str object at 0x...> = 'Yes'.isdigit

test_items.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_items.py::test_string_is_digit[No] - AssertionError: assert False
FAILED test_items.py::test_string_is_digit[Yes] - AssertionError: assert False
========================= 2 failed, 3 passed in 0.07s ==========================

測試報告中有幾項值得注意的項目。 首先,pytest 報告從單一測試函數中收集的五個項目:三個通過,兩個未通過。 故障會分別報告,包括導致失敗的輸入值。 標頭 test_string_is_digit[No] 顯示參數集合,參數 local item = 'No' 則顯示在原始資料檢視上方。 綁定方法的內省行(記憶體位址顯示為 0x...)確認了觸發斷言失敗的值:

___________________________ test_string_is_digit[No] ___________________________

item = 'No'
[...]
FAILED test_items.py::test_string_is_digit[No] - AssertionError: assert False

回報的位置這麼多,因此您不太可能錯過造成失敗的值。

使用詳細資訊輸出旗標

當測試通過時,命令列報告預設為簡明模式。 以下是更新後用來修正失敗的測試樣貌 (請繼續編輯相同的 test_items.py,因此 import pytest 已經存在):

@pytest.mark.parametrize("item", ["0", "1", "10", "33", "9"])
def test_string_is_digit(item):
    assert item.isdigit()

執行測試產生的輸出最小:

python -m pytest test_items.py
============================= test session starts ==============================
...
collected 5 items

test_items.py .....                                                      [100%]

============================== 5 passed in 0.01s ===============================

提高詳細度會顯示每個產生的測試案例及其生成的參數 ID。

python -m pytest -v test_items.py
============================= test session starts ==============================
...
collected 5 items

test_items.py::test_string_is_digit[0] PASSED                            [ 20%]
test_items.py::test_string_is_digit[1] PASSED                            [ 40%]
test_items.py::test_string_is_digit[10] PASSED                           [ 60%]
test_items.py::test_string_is_digit[33] PASSED                           [ 80%]
test_items.py::test_string_is_digit[9] PASSED                            [100%]

============================== 5 passed in 0.01s ===============================

如何使用多個自變數名稱

目前我們看到的例子只使用一個參數名稱: "item"。 你也可以在第一個論元中包含多個參數名稱。 使用字串時,請用逗號分隔名字。 你也可以將名稱以列表或元組的形式傳遞,例如 ["item", "attribute"]("item", "attribute")或 。

多個參數名稱的一個使用情境是傳遞輸入值和期望值以進行測試。 在第二個參數中,每個參數集合需要為每個參數名稱分配一個值。 例如,若參數名稱為 "test_input, expected_value",參數值可能為 [("3+5", 8), ("3*4", 12)]。

下一個範例使用 Python 的 hasattr() 函式,根據物件是否有命名屬性,回傳一個布林值:

>>> hasattr(dict(), "keys")
True
>>> hasattr("string", "append")
False

每個測試案例都需要一個物件和一個屬性名稱,因此我們可以用以下方式使用 @pytest.mark.parametrize() 。 請繼續在相同的 test_items.py 檔案中操作,因此 import pytest 已經就緒:

@pytest.mark.parametrize("item, attribute", [("", "format"), (list(), "append")])
def test_attributes(item, attribute):
    assert hasattr(item, attribute)

@pytest.mark.parametrize()裝飾器仍使用單一字串作為第一個參數,但該字串現在包含兩個以逗號分隔的參數名稱。 這些名稱成為測試函數的參數。 在此情況下,它們是 item 和 attribute。 名稱清單或元組的運作方式相同。

第二個參數是包含兩個參數集合的列表。 每個參數集合包含一個 item 值和一個 attribute 值。 如果參數集合的參數名稱值過少或過多,pytest 會在執行測試案例前觸發集合錯誤。

Pytest 為每個參數集建立一個測試 ID。 對於簡單的數值,如數字、字串、布林值等 None,pytest 使用該值通常的字串表示法。 針對其他物件,pytest 會改用根據引數名稱和參數集零基底索引所產生的名稱。 在此輸出中,空字串會形成空的 ID 部分,因此兩個 ID 之間的連字號分隔符號仍會顯示在 [-format] 中。 由於 list() 沒有簡單的生成ID,pytest 會用 item1 來替換:

python -m pytest -v test_items.py::test_attributes
============================= test session starts ==============================
...
collected 2 items

test_items.py::test_attributes[-format] PASSED                           [ 50%]
test_items.py::test_attributes[item1-append] PASSED                      [100%]

============================== 2 passed in 0.01s ===============================

如果產生的 ID 難以辨識,你可以傳遞可選 ids 參數,或將參數集包裝進 pytest.param(..., id="name") 去,讓名稱更清晰。 例如:

import pytest


@pytest.mark.parametrize(
    "item, attribute",
    [
        pytest.param("", "format", id="empty-string-format"),
        (list(), "append"),
    ],
)
def test_attributes(item, attribute):
    assert hasattr(item, attribute)

呼叫提供 pytest.param("", "format", id="empty-string-format") 該參數集的整個 ID,取代自動產生 -format 的 ID。 在冗長輸出中,產生的測試命名為 test_attributes[empty-string-format] 和 test_attributes[item1-append]。 清楚的 ID 可讓詳細輸出和失敗訊息更容易瀏覽。