在實作任何功能或錯誤修正前,先撰寫測試程式碼時使用
測試驅動開發 (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
未經你的真人夥伴許可,不得有例外。






