test-driven-development

test-driven-development

熱門

在實作任何功能或錯誤修正前,先撰寫測試程式碼時使用

23萬星標
2.2萬分支
更新於 2026/5/4
SKILL.md
readonlyread-only
name
test-driven-development
description

在實作任何功能或錯誤修正前,先撰寫測試程式碼時使用

測試驅動開發 (TDD)

概述

先寫測試。看著它失敗。寫最少的程式碼讓它通過。

核心原則: 如果你沒看到測試失敗,你就不知道它是否測對了東西。

違反規則的字面意義就是違反規則的精神。

何時使用

總是使用:

  • 新功能
  • 錯誤修正
  • 重構
  • 行為變更

例外(請詢問你的真人夥伴):

  • 一次性原型
  • 產生的程式碼
  • 設定檔

想著「就這次跳過 TDD」?停下來。那是在合理化。

鐵律

沒有先有失敗的測試,就不能有正式環境程式碼

先寫了程式碼?刪掉它。重新來過。

沒有例外:

  • 不要留著當「參考」
  • 不要在寫測試時「改編」它
  • 不要看它
  • 刪掉就是刪掉

從測試重新實作。句點。

紅-綠-重構

digraph tdd_cycle {
    rankdir=LR;
    red [label="紅\n寫失敗的測試", shape=box, style=filled, fillcolor="#ffcccc"];
    verify_red [label="確認失敗\n正確", shape=diamond];
    green [label="綠\n最少程式碼", shape=box, style=filled, fillcolor="#ccffcc"];
    verify_green [label="確認通過\n全部綠", shape=diamond];
    refactor [label="重構\n清理", shape=box, style=filled, fillcolor="#ccccff"];
    next [label="下一步", shape=ellipse];

    red -> verify_red;
    verify_red -> green [label="是"];
    verify_red -> red [label="錯誤\n失敗"];
    green -> verify_green;
    verify_green -> refactor [label="是"];
    verify_green -> green [label="否"];
    refactor -> verify_green [label="保持\n綠燈"];
    verify_green -> next;
    next -> red;
}

紅 - 寫失敗的測試

寫一個最小的測試來說明應該發生什麼事。

<好>

test('重試失敗操作 3 次', async () => {
  let attempts = 0;
  const operation = () => {
    attempts++;
    if (attempts < 3) throw new Error('fail');
    return 'success';
  };

  const result = await retryOperation(operation);

  expect(result).toBe('success');
  expect(attempts).toBe(3);
});

名稱清楚,測試真實行為,只測一件事
</好>

<壞>

test('重試有效', async () => {
  const mock = jest.fn()
    .mockRejectedValueOnce(new Error())
    .mockRejectedValueOnce(new Error())
    .mockResolvedValueOnce('success');
  await retryOperation(mock);
  expect(mock).toHaveBeenCalledTimes(3);
});

名稱模糊,測試的是 mock 而非程式碼
</壞>

要求:

  • 一個行為
  • 清楚的名稱
  • 真實程式碼(除非無法避免,否則不用 mock)

確認紅 - 看著它失敗

強制執行。絕不能跳過。

npm test path/to/test.test.ts

確認:

  • 測試失敗(不是錯誤)
  • 失敗訊息符合預期
  • 失敗是因為功能缺失(不是打字錯誤)

測試通過了? 你在測試既有的行為。修正測試。

測試報錯? 修正錯誤,重新執行直到它正確地失敗。

綠 - 最少程式碼

寫最簡單的程式碼來通過測試。

<好>

async function retryOperation<T>(fn: () => Promise<T>): Promise<T> {
  for (let i = 0; i < 3; i++) {
    try {
      return await fn();
    } catch (e) {
      if (i === 2) throw e;
    }
  }
  throw new Error('unreachable');
}

剛好夠通過
</好>

<壞>

async function retryOperation<T>(
  fn: () => Promise<T>,
  options?: {
    maxRetries?: number;
    backoff?: 'linear' | 'exponential';
    onRetry?: (attempt: number) => void;
  }
): Promise<T> {
  // YAGNI
}

過度設計
</壞>

不要加入功能、重構其他程式碼,或「改善」超出測試範圍的東西。

確認綠 - 看著它通過

強制執行。

npm test path/to/test.test.ts

確認:

  • 測試通過
  • 其他測試仍然通過
  • 輸出乾淨(沒有錯誤、警告)

測試失敗? 修正程式碼,不是測試。

其他測試失敗? 立即修正。

重構 - 清理

只有在綠燈後才做:

  • 移除重複
  • 改善命名
  • 提取輔助函式

保持測試綠燈。不要加入行為。

重複

下一個功能的失敗測試。

好的測試

品質
最小 一件事。名稱裡有「和」?拆開。 test('validates email and domain and whitespace')
清楚 名稱描述行為 test('test1')
顯示意圖 展示期望的 API 模糊了程式碼該做的事

為什麼順序很重要

「我之後再寫測試來驗證它有效」

事後寫的測試會立刻通過。立刻通過證明不了什麼:

  • 可能測錯東西
  • 可能測的是實作而非行為
  • 可能漏掉你忘記的邊界情況
  • 你從沒看過它抓到 bug

測試優先迫使你看到測試失敗,證明它真的在測試東西。

「我已經手動測試過所有邊界情況了」

手動測試是隨意的。你以為你測了全部,但:

  • 沒有測試記錄
  • 程式碼變更後無法重新執行
  • 壓力下容易忘記情況
  • 「我試過它有效」≠ 全面

自動化測試是系統性的。它們每次都以相同方式執行。

「刪掉 X 小時的工作很浪費」

沉沒成本謬誤。時間已經過去了。你現在可以選擇:

  • 刪掉並用 TDD 重寫(再花 X 小時,高信心)
  • 保留並事後加測試(30 分鐘,低信心,很可能有 bug)

「浪費」是保留你無法信任的程式碼。沒有真正測試的有效程式碼就是技術債。

「TDD 太教條,務實意味著要調整」

TDD 就是務實:

  • 在提交前找到 bug(比事後除錯更快)
  • 防止回歸(測試立即捕捉破壞)
  • 記錄行為(測試展示如何使用程式碼)
  • 允許重構(自由修改,測試捕捉破壞)

「務實」的捷徑 = 在正式環境除錯 = 更慢。

「事後測試也能達到相同目標——這是精神不是儀式」

不對。事後測試回答「這段程式碼做什麼?」測試優先回答「這段程式碼應該做什麼?」

事後測試被你實作的方式偏誤。你測試你建的東西,不是需要的東西。你驗證你記得的邊界情況,不是發現的邊界情況。

測試優先強迫你在實作前發現邊界情況。事後測試驗證你記得所有事情(但你沒有)。

30 分鐘的事後測試 ≠ TDD。你得到覆蓋率,但失去了測試有效的證明。

常見的合理化藉口

藉口 現實
「太簡單不用測」 簡單的程式碼也會壞。測試只要 30 秒。
「我之後再測」 立刻通過的測試證明不了什麼。
「事後測試也能達到相同目標」 事後測試 =「這段程式碼做什麼?」測試優先 =「這段程式碼應該做什麼?」
「已經手動測過了」 隨意 ≠ 系統性。沒有記錄,無法重新執行。
「刪掉 X 小時很浪費」 沉沒成本謬誤。保留未驗證的程式碼是技術債。
「留著當參考,先寫測試」 你會改編它。那就是事後測試。刪掉就是刪掉。
「需要先探索」 可以。丟掉探索結果,用 TDD 重新開始。
「測試難寫 = 設計不清楚」 聽測試的。難測 = 難用。
「TDD 會拖慢我」 TDD 比除錯快。務實 = 測試優先。
「手動測試更快」 手動測試無法證明邊界情況。每次變更你都要重新測試。
「現有程式碼沒有測試」 你正在改善它。為現有程式碼加測試。

紅旗 - 停下來重新開始

  • 先有程式碼再有測試
  • 實作後才寫測試
  • 測試立刻通過
  • 無法解釋測試為何失敗
  • 測試「之後」才加
  • 合理化「就這一次」
  • 「我已經手動測過了」
  • 「事後測試也能達到相同目的」
  • 「這是精神不是儀式」
  • 「留著當參考」或「改編現有程式碼」
  • 「已經花了 X 小時,刪掉很浪費」
  • 「TDD 太教條,我很務實」
  • 「這次不一樣因為...」

以上全部意味著:刪掉程式碼。用 TDD 重新開始。

範例:錯誤修正

Bug: 接受空白電子郵件

test('拒絕空白電子郵件', async () => {
  const result = await submitForm({ email: '' });
  expect(result.error).toBe('Email required');
});

確認紅

$ npm test
FAIL: expected 'Email required', got undefined

function submitForm(data: FormData) {
  if (!data.email?.trim()) {
    return { error: 'Email required' };
  }
  // ...
}

確認綠

$ npm test
PASS

重構
如果需要,提取驗證邏輯以處理多個欄位。

驗證檢查清單

在標記工作完成前:

  • [ ] 每個新函式/方法都有測試
  • [ ] 在實作前看過每個測試失敗
  • [ ] 每個測試因預期原因失敗(功能缺失,不是打字錯誤)
  • [ ] 寫了最少程式碼來通過每個測試
  • [ ] 所有測試通過
  • [ ] 輸出乾淨(沒有錯誤、警告)
  • [ ] 測試使用真實程式碼(除非無法避免,否則只用 mock)
  • [ ] 邊界情況和錯誤已涵蓋

無法勾選所有項目?你跳過了 TDD。重新開始。

卡住時

問題 解決方案
不知道怎麼測試 寫你想要的 API。先寫斷言。詢問你的真人夥伴。
測試太複雜 設計太複雜。簡化介面。
必須 mock 所有東西 程式碼耦合太緊。使用依賴注入。
測試設定太大 提取輔助函式。還是太複雜?簡化設計。

除錯整合

找到 bug?寫一個重現它的失敗測試。遵循 TDD 循環。測試證明修正並防止回歸。

絕不要沒有測試就修正 bug。

測試反模式

在加入 mock 或測試工具時,請閱讀 @testing-anti-patterns.md 以避免常見陷阱:

  • 測試 mock 行為而非真實行為
  • 在正式類別中加入僅供測試使用的方法
  • 在不了解依賴的情況下 mock

最終規則

正式環境程式碼 → 測試存在且先失敗
否則 → 不是 TDD

未經你的真人夥伴許可,不得有例外。