---
title: 測試斷言怎麼寫才對 — assertion 設計完全指南
description: assert 的本質、AAA 模式、一個測試該有幾個斷言、soft 與 hard assertion 的取捨、斷言太弱太多或斷到實作細節的反模式，以及 Playwright web-first assertion 與 pytest assert 的對照。
category: automation
tags: [assertion, 斷言, 測試設計, pytest, playwright]
date: 2026-08-14
faq:
  - q: 一個測試應該放幾個斷言？
    a: 沒有硬性數字，原則是「一個測試驗證一個行為」。這個行為可能需要好幾個斷言一起描述一個完整狀態（例如下單後同時檢查狀態、金額、庫存），那是合理的。真正要避免的是「一個測試裡塞好幾個不相干的行為」——那應該拆成多個測試，這樣失敗時你一眼知道壞在哪，而不是一路 fail-fast 到某一行才停。
  - q: soft assertion 和 hard assertion 差在哪、何時用？
    a: hard assertion 失敗會立刻中止測試，適合前置條件——條件不成立後面測了也沒意義（例如頁面根本沒載入）。soft assertion 失敗會記錄但繼續跑，最後一起報，適合同一個畫面上多個獨立欄位的檢查（表單五個欄位、儀表板多張卡片），讓你一次看到全部錯誤而不是修一個跑一次。Playwright 用 expect.soft()，pytest 可用 pytest-check。
  - q: Playwright 的 expect 和 pytest 的 assert 有什麼不同？
    a: 最大差別是 auto-retry。Playwright 的 web-first assertion（如 expect(locator).toBeVisible()）會在 timeout 內反覆重試，專門對付非同步 UI，不用自己寫 wait。pytest 的 assert 是一次性求值、當下不成立就失敗，適合資料與 API 這類同步、確定性的斷言。用錯場景就會 flaky——UI 別用 pytest 式的一次性比對，同步邏輯也不需要 retry。
  - q: 斷言「太弱」是什麼意思、為什麼危險？
    a: 太弱指斷言通過了卻沒真正證明行為正確。典型是只斷 status code 200 卻不看 body、只斷「有回東西」卻不看內容、或用 assertTrue(len(result) > 0) 這種只證明非空的斷言。它們給你綠燈的安全感，實際上 bug 照樣溜過去。好的斷言要具體到值：金額是多少、清單第一筆是誰、錯誤訊息長什麼樣。
---

測試會不會抓到 bug，關鍵不在你點了幾個按鈕、打了幾支 API，而在**斷言寫得夠不夠好**。沒有斷言的測試只證明「程式沒 crash」，斷言太弱的測試給你綠燈卻放 bug 過關。這篇專講斷言設計，UI 和 API 都適用。

## 斷言的本質：把「應該」變成可驗證的判斷

一個斷言就是一句「**我宣稱此刻系統處於某個狀態**」，成立就過、不成立就失敗。它是你把腦中「這裡應該顯示 500 元」這種期望，翻譯成機器能檢查的判斷。

沒有斷言 = 沒有測試。看看這個假測試：

```python
def test_checkout():
    result = checkout(cart)  # 只呼叫，沒斷言
```

它只證明 `checkout` 不會丟例外。金額算錯、庫存沒扣、回傳 None，它一律綠燈。**能跑 ≠ 正確**，這條線就是斷言劃的。

## AAA 模式：測試的骨架

好的測試有清楚三段，中間空行隔開，一眼看懂：

| 階段 | 做什麼 | 例 |
|------|--------|----|
| **Arrange** | 準備前置資料與狀態 | 建購物車、建使用者 |
| **Act** | 執行「被測的那一個動作」 | 呼叫 checkout |
| **Assert** | 驗證結果 | 斷言金額、狀態、庫存 |

```python
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 個斷言，但它們共同描述「下單成功」這**一個**行為（狀態對、金額對、庫存對），這完全合理。

該拆開的是這種——把不相干的行為塞在一起：

```python
# ❌ 一個測試驗了三件不相干的事
def test_user():
    assert register(...).ok          # 註冊
    assert login(...).token          # 登入
    assert reset_password(...).ok    # 改密碼
```

問題：hard assertion 是 fail-fast，第一行掛了，你**永遠不知道**登入和改密碼有沒有壞。拆成三個測試，失敗時一眼定位。

判準：**這些斷言是不是在描述同一件事的不同面向？** 是就留一起，不是就拆。

## soft vs hard assertion

| | hard（預設） | soft |
|---|---|---|
| 失敗時 | 立刻中止 | 記錄後繼續 |
| 適合 | 前置條件、後面依賴它 | 同畫面多個獨立欄位 |
| 風險 | 後面斷言被跳過 | 可能連鎖噴一堆假錯 |

**hard 用在前置條件**——條件不成立，後面測了也是白測：

```python
resp = api.get("/orders/42")
assert resp.status_code == 200   # 沒 200，下面驗 body 沒意義
data = resp.json()
assert data["total"] == 1000
```

**soft 用在多個獨立檢查**——一次看到全部錯誤，不用修一個跑一次：

```python
# pytest 用 pytest-check
import pytest_check as check

def test_個人資料頁全欄位():
    check.equal(profile.name, "張三")
    check.equal(profile.email, "a@b.com")
    check.equal(profile.phone, "0912345678")
    # 三個都錯會一次全報，而不是停在第一個
```

Playwright 對應的是 `expect.soft()`：

```typescript
await expect.soft(page.getByTestId('name')).toHaveText('張三');
await expect.soft(page.getByTestId('email')).toHaveText('a@b.com');
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：

```typescript
// ✅ 會在 timeout 內反覆檢查，等到出現為止
await expect(page.getByText('付款成功')).toBeVisible();

// ❌ 一次性快照，非同步內容必 flaky
expect(await page.getByText('付款成功').isVisible()).toBe(true);
```

API 和純邏輯是同步、確定的，用 pytest 原生 assert 就好，**不需要也不該加 retry**（同步的東西還 retry 只是拖慢並掩蓋真 bug）：

```python
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. 斷言太弱——過了卻沒證明什麼**

```python
# ❌ 只證明「有回東西」，算錯照樣過
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. 斷到實作細節——重構就假性壞掉**

```python
# ❌ 綁內部方法呼叫次數、私有欄位
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. 反向斷言沒設好等待**

```typescript
// ❌ 元素本來就還沒出現，這個「不可見」是假通過
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、又不會動不動假性壞掉。
