測試會不會抓到 bug,關鍵不在你點了幾個按鈕、打了幾支 API,而在斷言寫得夠不夠好。沒有斷言的測試只證明「程式沒 crash」,斷言太弱的測試給你綠燈卻放 bug 過關。這篇專講斷言設計,UI 和 API 都適用。
斷言的本質:把「應該」變成可驗證的判斷
一個斷言就是一句「我宣稱此刻系統處於某個狀態」,成立就過、不成立就失敗。它是你把腦中「這裡應該顯示 500 元」這種期望,翻譯成機器能檢查的判斷。
沒有斷言 = 沒有測試。看看這個假測試:
def test_checkout():
result = checkout(cart) # 只呼叫,沒斷言
它只證明 checkout 不會丟例外。金額算錯、庫存沒扣、回傳 None,它一律綠燈。能跑 ≠ 正確,這條線就是斷言劃的。
AAA 模式:測試的骨架
好的測試有清楚三段,中間空行隔開,一眼看懂:
| 階段 | 做什麼 | 例 |
|---|---|---|
| Arrange | 準備前置資料與狀態 | 建購物車、建使用者 |
| Act | 執行「被測的那一個動作」 | 呼叫 checkout |
| Assert | 驗證結果 | 斷言金額、狀態、庫存 |
def test_下單成功後庫存扣除且金額正確():
# Arrange
product = create_product(name="滑鼠", price=500, stock=10)
cart = Cart(items=[CartItem(product, qty=2)])
# Act
order = checkout(cart)
# Assert
assert order.status == "PAID"
assert order.total == 1000
assert product.reload().stock == 8
重點:Act 只有一個動作。如果你的 Act 有好幾個互不相干的操作,通常代表這個測試想驗太多事,該拆。
一個測試該有幾個斷言?
流傳的「一個測試一個斷言」被誤解很久。真正的原則是「一個測試驗證一個行為」,而描述一個行為的完整狀態,常常需要多個斷言。
上面那個下單測試有 3 個斷言,但它們共同描述「下單成功」這一個行為(狀態對、金額對、庫存對),這完全合理。
該拆開的是這種——把不相干的行為塞在一起:
# ❌ 一個測試驗了三件不相干的事
def test_user():
assert register(...).ok # 註冊
assert login(...).token # 登入
assert reset_password(...).ok # 改密碼
問題:hard assertion 是 fail-fast,第一行掛了,你永遠不知道登入和改密碼有沒有壞。拆成三個測試,失敗時一眼定位。
判準:這些斷言是不是在描述同一件事的不同面向? 是就留一起,不是就拆。
soft vs hard assertion
| hard(預設) | soft | |
|---|---|---|
| 失敗時 | 立刻中止 | 記錄後繼續 |
| 適合 | 前置條件、後面依賴它 | 同畫面多個獨立欄位 |
| 風險 | 後面斷言被跳過 | 可能連鎖噴一堆假錯 |
hard 用在前置條件——條件不成立,後面測了也是白測:
resp = api.get("/orders/42")
assert resp.status_code == 200 # 沒 200,下面驗 body 沒意義
data = resp.json()
assert data["total"] == 1000
soft 用在多個獨立檢查——一次看到全部錯誤,不用修一個跑一次:
# pytest 用 pytest-check
import pytest_check as check
def test_個人資料頁全欄位():
check.equal(profile.name, "張三")
check.equal(profile.email, "[email protected]")
check.equal(profile.phone, "0912345678")
# 三個都錯會一次全報,而不是停在第一個
Playwright 對應的是 expect.soft():
await expect.soft(page.getByTestId('name')).toHaveText('張三');
await expect.soft(page.getByTestId('email')).toHaveText('[email protected]');
await expect.soft(page.getByTestId('phone')).toHaveText('0912345678');
Playwright web-first assertion vs pytest assert
這是最容易寫錯 flaky 的地方。核心差別一句話:Playwright 的 expect 會自動重試,pytest 的 assert 不會。
Playwright expect(locator) |
pytest assert |
|
|---|---|---|
| 求值 | timeout 內反覆 retry | 當下一次求值 |
| 適合 | 非同步 UI 狀態 | 同步、確定性的資料/API |
| 典型 | toBeVisible toHaveText toHaveURL |
== in pytest.raises |
UI 是非同步的,狀態不會立刻到,所以要用會 retry 的 web-first assertion:
// ✅ 會在 timeout 內反覆檢查,等到出現為止
await expect(page.getByText('付款成功')).toBeVisible();
// ❌ 一次性快照,非同步內容必 flaky
expect(await page.getByText('付款成功').isVisible()).toBe(true);
API 和純邏輯是同步、確定的,用 pytest 原生 assert 就好,不需要也不該加 retry(同步的東西還 retry 只是拖慢並掩蓋真 bug):
resp = client.post("/api/orders", json=payload)
assert resp.status_code == 201
body = resp.json()
assert body["total"] == 1000
assert body["items"][0]["name"] == "滑鼠"
常見錯誤
1. 斷言太弱——過了卻沒證明什麼
# ❌ 只證明「有回東西」,算錯照樣過
assert resp.status_code == 200
assert resp.json() is not None
# ✅ 具體到值
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 1000
assert body["currency"] == "TWD"
assert len(result) > 0、assertTrue(response) 都是這類——給你安全感,不給你保護。
2. 斷言太多、越權——一個測試想管天下
在一個測試裡把不相干的頁面、流程全斷一遍。失敗時定位困難,維護時牽一髮動全身。回到「一個測試一個行為」。
3. 斷到實作細節——重構就假性壞掉
# ❌ 綁內部方法呼叫次數、私有欄位
assert service._cache_hits == 3
mock_repo.save.assert_called_once() # 過度指定「怎麼做」
# ✅ 斷可觀察的結果(做出來對不對)
assert order.status == "PAID"
assert get_order(order.id).total == 1000
斷「行為的結果」而非「內部怎麼實作」,重構才不會逼你改一堆本來沒壞的測試。UI 同理:斷使用者看得到的文字/狀態,別斷內部 class name。
4. 反向斷言沒設好等待
// ❌ 元素本來就還沒出現,這個「不可見」是假通過
expect(await page.getByText('錯誤').isVisible()).toBe(false);
// ✅ 給它機會出現後才判定不存在
await expect(page.getByText('錯誤')).toBeHidden();
5. 把錯誤訊息也寫死到脆弱
斷 assert msg == "系統發生未預期的錯誤,請稍後再試(代碼 5xx-A03)" 這種一字不差,文案微調就壞。改斷關鍵語意:assert "稍後再試" in msg,或斷結構化的 error code 而非整句文案。
一句話總結
好斷言的三個標準:具體(斷到值,不是「有就好」)、單一行為(一個測試講清楚一件事)、斷結果不斷實作(驗做出來對不對,不驗它怎麼做)。再配合場景選對工具——非同步 UI 用會 retry 的 web-first assertion,同步邏輯用一次性的 assert——你的測試才會既抓得到 bug、又不會動不動假性壞掉。