【pytest 教學】#04 參數化測試:一次測試多種情況

測驗:pytest 參數化測試

共 5 題,點選答案後會立即顯示結果

1. 參數化測試主要解決什麼問題?

  • A. 加快測試執行速度
  • B. 減少重複的測試程式碼
  • C. 自動產生測試資料
  • D. 讓測試結果更準確

2. 在 @pytest.mark.parametrize 中,參數名稱應該使用什麼格式?

@pytest.mark.parametrize(???, [(1, 2), (3, 4)])
  • A. (a, b) – tuple 格式
  • B. [a, b] – list 格式
  • C. "a, b" – 字串格式
  • D. a, b – 直接使用變數名稱

3. 執行以下測試會產生幾個測試案例?

@pytest.mark.parametrize(“x”, [1, 2, 3]) def test_example(x): assert x > 0
  • A. 1 個
  • B. 2 個
  • C. 3 個
  • D. 6 個

4. 如果想要讓測試結果更好讀,可以使用哪個參數自訂測試 ID?

  • A. names=["case1", "case2"]
  • B. ids=["case1", "case2"]
  • C. labels=["case1", "case2"]
  • D. tags=["case1", "case2"]

5. 使用 pytest.param 搭配 marks=pytest.mark.xfail 的用途是什麼?

  • A. 完全跳過這組測試不執行
  • B. 讓這組測試執行更快
  • C. 標記這組測試預期會失敗
  • D. 讓這組測試最後執行

一句話說明

用一個測試函式跑多組資料,不用複製貼上寫很多個測試。

這篇會學到

讀完本篇後,你能夠:

  • 理解參數化測試解決的問題(減少重複程式碼)
  • 使用 @pytest.mark.parametrize 裝飾器
  • 設計多組測試資料
  • 處理參數化測試的 ID 與可讀性

前置知識

  • 會寫基本的 pytest 測試(def test_xxx(): + assert
  • 知道什麼是 fixture
  • 理解 Python 裝飾器的用法

問題:重複的測試程式碼

你可能會看到 AI 寫出這種程式碼:

def test_add_positive():
    assert add(1, 2) == 3

def test_add_negative():
    assert add(-1, -2) == -3

def test_add_zero():
    assert add(0, 0) == 0

def test_add_mixed():
    assert add(-1, 1) == 0

問題:四個測試做的事情一模一樣,只有輸入和預期結果不同。

翻譯:「這是在測試 add 函式的不同情況,但寫法很囉嗦」


解法:參數化測試

最小範例

import pytest

@pytest.mark.parametrize("a, b, expected", [
    (1, 2, 3),
    (-1, -2, -3),
    (0, 0, 0),
    (-1, 1, 0),
])
def test_add(a, b, expected):
    assert add(a, b) == expected
Code language: JavaScript (javascript)

一句話:用一個測試函式,跑四組不同的資料。

逐行翻譯

@pytest.mark.parametrize("a, b, expected", [  # 定義參數名稱和測試資料
    (1, 2, 3),                                 # 第一組:a=1, b=2, expected=3
    (-1, -2, -3),                              # 第二組:a=-1, b=-2, expected=-3
    (0, 0, 0),                                 # 第三組:a=0, b=0, expected=0
    (-1, 1, 0),                                # 第四組:a=-1, b=1, expected=0
])
def test_add(a, b, expected):                  # 函式參數對應裝飾器定義的名稱
    assert add(a, b) == expected               # 每組資料跑一次這行
Code language: PHP (php)

執行結果:

test_add[1-2-3] PASSED
test_add[-1--2--3] PASSED
test_add[0-0-0] PASSED
test_add[-1-1-0] PASSED
Code language: CSS (css)

翻譯:「pytest 會自動把這一個測試展開成四個獨立的測試案例」


語法解析

@pytest.mark.parametrize 基本結構

@pytest.mark.parametrize("參數1, 參數2", [
    (值1a, 值1b),
    (值2a, 值2b),
])
def test_xxx(參數1, 參數2):
    ...
Code language: JavaScript (javascript)
部分 意思
"參數1, 參數2" 參數名稱,用逗號分隔(字串格式)
[(值1a, 值1b), ...] 測試資料列表,每個 tuple 是一組
def test_xxx(參數1, 參數2) 函式參數要跟上面定義的對應

單一參數

如果只有一個參數,可以簡化:

@pytest.mark.parametrize("name", ["Alice", "Bob", "Charlie"])
def test_greet(name):
    result = greet(name)
    assert name in result
Code language: JavaScript (javascript)

翻譯:「用三個不同的 name 跑 test_greet」


常見變化

AI 可能會這樣寫:

變化 1:測試多種輸入格式

@pytest.mark.parametrize("input_date, expected", [
    ("2024-01-15", "2024/01/15"),
    ("2024/01/15", "2024/01/15"),
    ("20240115", "2024/01/15"),
])
def test_normalize_date(input_date, expected):
    assert normalize_date(input_date) == expected
Code language: JavaScript (javascript)

翻譯:「測試日期正規化函式,確保各種輸入格式都能轉成統一格式」

變化 2:邊界值測試

@pytest.mark.parametrize("age, is_adult", [
    (17, False),     # 未滿 18
    (18, True),      # 剛好 18
    (19, True),      # 超過 18
    (0, False),      # 邊界:最小值
    (150, True),     # 邊界:極大值
])
def test_is_adult(age, is_adult):
    assert check_adult(age) == is_adult
Code language: PHP (php)

翻譯:「測試年齡判斷,特別測試邊界情況」

變化 3:測試錯誤情況

@pytest.mark.parametrize("invalid_input", [
    "",              # 空字串
    None,            # None
    "   ",           # 只有空白
    -1,              # 負數
])
def test_validate_rejects_invalid(invalid_input):
    with pytest.raises(ValueError):
        validate(invalid_input)
Code language: PHP (php)

翻譯:「確保這些無效輸入都會拋出 ValueError」


進階用法

自訂測試 ID

預設的測試 ID 是用參數值組成,有時候不好讀:

test_add[-1--2--3] PASSED  # 參數是 -1, -2, -3,但看起來很亂
Code language: CSS (css)

ids 參數自訂:

@pytest.mark.parametrize("a, b, expected", [
    (1, 2, 3),
    (-1, -2, -3),
    (0, 0, 0),
], ids=["positive", "negative", "zero"])
def test_add(a, b, expected):
    assert add(a, b) == expected
Code language: JavaScript (javascript)

執行結果變成:

test_add[positive] PASSED
test_add[negative] PASSED
test_add[zero] PASSED
Code language: CSS (css)

翻譯:「給每組測試資料取個好讀的名字」

用 pytest.param 標記特殊情況

@pytest.mark.parametrize("a, b, expected", [
    (1, 2, 3),
    pytest.param(-1, -2, -3, id="negative"),           # 自訂 ID
    pytest.param(0, 0, 1, marks=pytest.mark.xfail),    # 預期會失敗
    pytest.param(999, 999, 0, marks=pytest.mark.skip), # 跳過這組
])
def test_add(a, b, expected):
    assert add(a, b) == expected
Code language: PHP (php)
用法 意思
pytest.param(..., id="xxx") 自訂這組的 ID
marks=pytest.mark.xfail 標記「這組預期會失敗」
marks=pytest.mark.skip 跳過這組不測

參數化 + Fixture 結合

範例:用不同設定測試

@pytest.fixture
def client():
    return APIClient()

@pytest.mark.parametrize("endpoint", ["/users", "/orders", "/products"])
def test_endpoints_return_200(client, endpoint):
    response = client.get(endpoint)
    assert response.status_code == 200
Code language: JavaScript (javascript)

翻譯:「用同一個 client(來自 fixture),測試三個不同的 endpoint」

範例:Fixture 參數化

@pytest.fixture(params=["sqlite", "postgres", "mysql"])
def database(request):
    db = create_database(request.param)
    yield db
    db.cleanup()

def test_save_user(database):
    database.save(User(name="Alice"))
    assert database.get("Alice") is not None
Code language: JavaScript (javascript)

翻譯:「用三種不同的資料庫跑同一個測試」


實際案例

案例 1:API 回應驗證

@pytest.mark.parametrize("status_code, should_retry", [
    (200, False),    # 成功,不重試
    (201, False),    # 成功,不重試
    (400, False),    # 客戶端錯誤,不重試
    (500, True),     # 伺服器錯誤,要重試
    (502, True),     # 閘道錯誤,要重試
    (503, True),     # 服務不可用,要重試
])
def test_should_retry(status_code, should_retry):
    assert check_should_retry(status_code) == should_retry
Code language: PHP (php)

案例 2:密碼強度檢查

@pytest.mark.parametrize("password, is_strong", [
    ("abc", False),                    # 太短
    ("abcdefgh", False),               # 沒有數字
    ("12345678", False),               # 沒有字母
    ("Abc12345", True),                # OK
    ("Abc12345!@#", True),             # OK,有特殊符號更好
], ids=["too_short", "no_number", "no_letter", "valid", "valid_with_symbols"])
def test_password_strength(password, is_strong):
    assert validate_password_strength(password) == is_strong
Code language: PHP (php)

案例 3:檔案格式解析

@pytest.mark.parametrize("filename, expected_type", [
    ("data.json", "json"),
    ("data.JSON", "json"),     # 大寫也要能處理
    ("config.yaml", "yaml"),
    ("config.yml", "yaml"),    # yml 和 yaml 都是 YAML
    ("script.py", "python"),
    ("unknown.xyz", None),     # 不認識的格式
])
def test_detect_file_type(filename, expected_type):
    assert detect_file_type(filename) == expected_type
Code language: PHP (php)

Vibe Coder 檢查點

看到 @pytest.mark.parametrize 時確認:

  • [ ] 參數名稱跟函式參數對得上嗎?
  • [ ] 測試資料有涵蓋邊界情況嗎?(0、負數、空值、極大值)
  • [ ] 測試 ID 好讀嗎?如果不好讀,有沒有加 ids 參數?
  • [ ] 每組測試資料的 tuple 長度是否一致?
  • [ ] 有沒有測試「應該失敗」的情況?

常見錯誤

錯誤 1:參數名稱不匹配

# 錯誤:裝飾器寫 a, b,函式寫 x, y
@pytest.mark.parametrize("a, b", [(1, 2)])
def test_add(x, y):  # 應該是 a, b
    assert x + y == 3
Code language: PHP (php)

錯誤 2:tuple 長度不一致

# 錯誤:有的 3 個值,有的 2 個值
@pytest.mark.parametrize("a, b, expected", [
    (1, 2, 3),
    (4, 5),      # 少了 expected!
])
def test_add(a, b, expected):
    assert a + b == expected
Code language: PHP (php)

錯誤 3:忘記是字串格式

# 錯誤:參數名稱要用引號包起來
@pytest.mark.parametrize(a, b, [(1, 2)])  # 應該是 "a, b"
def test_add(a, b):
    ...
Code language: PHP (php)

重點整理

概念 一句話
@pytest.mark.parametrize 用一個測試跑多組資料
"a, b" 參數名稱,字串格式用逗號分隔
[(1, 2), (3, 4)] 測試資料列表
ids=["case1", "case2"] 自訂測試 ID
pytest.param(..., marks=...) 標記特殊情況(xfail, skip)

延伸:知道就好

這些進階功能遇到再查:

  • 多重參數化:同時用多個 @pytest.mark.parametrize 做組合測試
  • indirect 參數:把參數傳給 fixture 而不是測試函式
  • 動態產生測試資料:用 pytestgeneratetests 鉤子函式

進階測驗:pytest 參數化測試

測驗目標:驗證你是否能在實際情境中應用所學。
共 5 題,包含情境題與錯誤診斷題。

1. 你需要測試一個密碼驗證函式,需要確保它能正確處理:太短的密碼、沒有數字的密碼、沒有字母的密碼、以及合法的密碼。最適合的測試方式是? 情境題

  • A. 寫四個獨立的測試函式,每個測試一種情況
  • B. 使用 @pytest.mark.parametrize 把四種情況整合成一個測試
  • C. 在一個測試函式中依序測試四種情況,用多個 assert
  • D. 使用 fixture 為每種情況建立不同的測試環境

2. 同事寫了以下測試,但執行時出現錯誤。問題出在哪裡? 錯誤診斷

@pytest.mark.parametrize(“a, b, expected”, [ (1, 2, 3), (4, 5), (0, 0, 0), ]) def test_add(a, b, expected): assert add(a, b) == expected
  • A. 參數名稱格式錯誤,應該用 list 而非字串
  • B. 函式參數順序與裝飾器定義不一致
  • C. 第二組測試資料的 tuple 長度不一致,缺少 expected 值
  • D. 測試資料列表應該用 tuple 而非 list

3. 你正在測試 API endpoint,需要用同一個 client 物件測試三個不同的路徑:/users/orders/products。最佳的實作方式是? 情境題

  • A. 在每個測試中分別建立新的 client 物件
  • B. 寫三個獨立的測試函式,複製相同的 client 建立程式碼
  • C. 使用 @pytest.fixture(params=["/users", "/orders", "/products"])
  • D. 結合 fixture 提供 client,使用 @pytest.mark.parametrize 提供不同的 endpoint

4. 執行測試後看到以下輸出,哪個做法可以讓測試結果更容易閱讀? 錯誤診斷

test_add[-1–2–3] PASSED test_add[0-0-0] PASSED test_add[100-200-300] PASSED
  • A. 把測試資料改成正數,避免負號造成混淆
  • B. 使用 ids 參數自訂每組測試的名稱
  • C. 使用 pytest -v 增加輸出詳細程度
  • D. 改用多個獨立的測試函式

5. 你的專案有一個已知的 bug,某個函式在輸入 0 時會回傳錯誤結果,但暫時無法修復。你想讓測試能繼續執行其他案例,同時記錄這個問題。應該怎麼做? 情境題

@pytest.mark.parametrize(“value, expected”, [ (1, 2), (0, 0), # 這個案例會失敗 (5, 10), ]) def test_double(value, expected): assert double(value) == expected
  • A. 直接刪除 (0, 0) 這組測試資料
  • B. 把整個測試標記為 @pytest.mark.skip
  • C. 使用 pytest.param(0, 0, marks=pytest.mark.xfail) 標記該組資料
  • D. 修改 expected 值讓測試通過

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *