參數化測試
參數化測試(parametrized test)是用 @pytest.mark.parametrize 讓同一個測試函式,以多組不同的輸入與預期值各執行一次。它能把十幾個長得幾乎一樣的測試濃縮成一個,而且每組資料仍會獨立顯示通過或失敗。
基本用法
不使用參數化時,測試多組資料只能一直複製:
def test_discount_20():
assert apply_discount(100, 20) == 80
def test_discount_0():
assert apply_discount(100, 0) == 100
改用參數化:
import pytest
from shop import apply_discount
@pytest.mark.parametrize(
("price", "percent", "expected"),
[
(100, 20, 80),
(100, 0, 100),
(100, 100, 0),
(59.9, 10, 53.91),
],
)
def test_apply_discount(price, percent, expected):
assert apply_discount(price, percent) == expected
第一個參數列出參數名稱,第二個是資料清單,每個 tuple 是一組。執行結果會分成四個測試:
test_shop.py::test_apply_discount[100-20-80] PASSED
test_shop.py::test_apply_discount[100-0-100] PASSED
test_shop.py::test_apply_discount[100-100-0] PASSED
test_shop.py::test_apply_discount[59.9-10-53.91] PASSED
方括號裡是自動產生的測試 ID,失敗時一眼就知道是哪一組資料出錯。
讓測試 ID 更好讀
資料較複雜時,自動產生的 ID 不易閱讀。用 pytest.param(..., id=...) 為每組資料命名:
@pytest.mark.parametrize(
("price", "percent", "expected"),
[
pytest.param(100, 20, 80, id="normal"),
pytest.param(100, 0, 100, id="no-discount"),
pytest.param(100, 100, 0, id="free"),
],
)
def test_apply_discount(price, percent, expected):
assert apply_discount(price, percent) == expected
有了 ID,輸出會變成 test_apply_discount[free],也可以用 pytest -k free 只跑那一組。
測試 ID 請用英文
pytest 預設會把 ID 中的非 ASCII 字元跳脫,id="免費" 會顯示成 \u514d\u8cbb,也無法用 -k 免費 選取。因此 ID 建議使用英文、數字與連字號。
參數化錯誤輸入
正常輸入與錯誤輸入通常分成兩個測試,各自參數化,比在一個測試裡用 if 判斷清楚:
@pytest.mark.parametrize("percent", [-1, 101, 1000])
def test_invalid_percent(percent):
with pytest.raises(ValueError):
apply_discount(100, percent)
個別資料加上 marker
已知某組資料目前會失敗(例如等待修正的 bug),可以只對那一組加上 xfail,不影響其他資料:
@pytest.mark.parametrize(
("text", "expected"),
[
("hello", "HELLO"),
pytest.param("ß", "SS", marks=pytest.mark.xfail(reason="尚未處理德文字元")),
],
)
def test_upper(text, expected):
assert my_upper(text) == expected
xfail 的意義見 Marker。
疊加參數化:組合測試
疊加多個 parametrize 時,pytest 會產生所有組合(笛卡兒積)。下例會執行 2 × 3 = 6 個測試:
@pytest.mark.parametrize("currency", ["TWD", "USD"])
@pytest.mark.parametrize("amount", [0, 1, 999])
def test_format_price(currency, amount):
assert format_price(amount, currency).startswith(currency)
組合數會快速成長,只在每個組合都有測試價值時使用。
參數化 fixture
Fixture 也能參數化,讓所有使用它的測試都對每個參數各跑一次,適合「同一套測試要對多種資料庫或設定執行」的情境:
@pytest.fixture(params=["sqlite", "postgres"])
def db_url(request):
return f"{request.param}://test"
def test_connect(db_url):
assert "://" in db_url # 會執行兩次
request 是 pytest 內建的 fixture,request.param 取得目前這一輪的參數值。
推薦影音
pytest's parametrize
pytest 核心開發者 Anthony Sottile 約 12 分鐘講解 parametrize 的用法:如何用一個測試函式涵蓋多組輸入,適合讀完本頁「基本用法」後觀看。
Multiply your Testing Effectiveness with Parameterized Testing(PyCon US 2020)
Brian Okken 在 PyCon US 的演講,從一個簡單測試出發,比較函式參數化、fixture 參數化與 pytest_generate_tests 三種做法,並介紹測試 ID 與用產生器(generator)提供資料。