測試驅動開發
核心原則
行為變更唯有在測試因正確原因先失敗、再因實作而通過後,才能獲得信任。
測試應透過公開介面驗證行為,而非實作細節。程式碼可以完全改變,但測試不應隨之變動。
好的測試是整合風格的:它們透過公開 API 執行真實的程式碼路徑。它們描述的是系統做什麼,而不是如何做。好的測試讀起來像一份規格說明:「使用者可以使用有效的購物車結帳」精確告訴你存在哪個功能。這類測試在重構後依然存活,因為它們不在乎內部結構。
壞測試與實作緊密耦合。它們模擬內部協作者、測試私有方法、斷言呼叫次數,或透過外部手段而非公開介面來驗證。警告訊號:重構後行為不變,測試卻失敗了。
參見 tests.md、mocking.md 和 testing-anti-patterns.md。
鐵律
未經先寫一個失敗的測試,不得變更任何自有生產行為。
含義:
- 為一個行為撰寫一個測試。
- 執行它,觀察它失敗。
- 確認失敗證明了缺失的行為,而非打字錯誤或設定錯誤。
- 撰寫能讓它通過的最小生產程式碼變更。
- 僅在綠燈後重構。
如果實作程式碼在測試之前已經存在:
- 未經許可不得刪除使用者工作。
- 將其視為探索性原型/參考,而非經過驗證的實作。
- 建議擱置它,先寫出失敗的測試,再從測試出發進行實作。
- 若必須保留現有程式碼,則先加入回歸/特徵化測試,再用新的失敗測試來變更行為。
反模式:水平切片
不要先寫所有測試,再寫所有實作。那是水平切片。
錯誤:
紅:測試1, 測試2, 測試3, 測試4
綠:實作1, 實作2, 實作3, 實作4
正確:
紅 -> 綠 -> 重構:行為1
紅 -> 綠 -> 重構:行為2
紅 -> 綠 -> 重構:行為3
原因:批次測試編碼了想像中的設計,超出了回饋週期,而且經常測試的是形狀而非行為。
工作流程
1. 規劃行為
在撰寫程式碼之前:
- 若不明顯,先與使用者確認公開介面的變更。
- 列出要測試的行為,而非實作步驟。
- 優先處理關鍵路徑與複雜邏輯;不要追求虛假的完整性。
- 辨識 深度模組 的機會。
- 為可測試性設計介面。
- 選擇第一個示蹤子彈行為。
問:「必須存在哪些公開行為?哪個行為最重要?」
2. 紅:一個失敗的測試
為一個可觀察的行為撰寫一個最小的測試。
需求:
- 僅使用公開介面。
- 名稱描述行為。
- 在實際可行的情況下測試真實程式碼。
- 僅在系統邊界或無法避免的接縫處進行模擬。
- 在實作前失敗。
執行最狹窄的有用指令,例如:
npm test path/to/test.test.ts
驗證失敗:
- 測試失敗,而不是設定/打字錯誤導致的錯誤。
- 失敗原因與缺失的行為相符。
- 如果測試立即通過,表示測試有誤或行為已存在。修正測試或選擇下一個缺失的行為。
3. 綠:最小實作
僅撰寫足以通過當前測試的程式碼。
不要:
- 加入推測性的功能。
- 重構無關的程式碼。
- 加入未來選項。
- 因為「順便做一下」而擴大範圍。
執行:
npm test path/to/test.test.ts
然後執行相關的周邊測試。輸出應乾淨無誤:沒有新的警告、不穩定性或隱藏錯誤。
4. 重構:綠燈時清理
僅在測試通過後重構。如果沒有有用的重構,簡短說明後繼續。
尋找重構候選:
- 重複
- 命名不佳
- 淺層模組
- 令人困惑的邊界
- 可以隱藏在小型介面背後的複雜性
規則:
- 行為和期望不變。
- 每次重構步驟後測試保持綠燈。
- 測試重構可以重新命名、消除重複、提取 fixture/建構器,或改善失敗訊息。
- 如果斷言改變了預期行為,請停止:那是新的紅循環。
5. 垂直重複
根據上一個循環學到的經驗,選擇下一個行為。一次一個行為。
每循環檢查清單
[ ] 測試描述可觀察的行為,而非實作
[ ] 測試僅使用公開介面
[ ] 測試在實作前失敗
[ ] 失敗原因是正確的
[ ] 此測試的程式碼是最小的
[ ] 未加入推測性行為
[ ] 相關測試乾淨通過
[ ] 僅在綠燈時考慮重構
提交閘門
為了可稽核的工作,在可行情況下分階段提交:
- 紅提交:僅測試或最小 scaffold 加上失敗的斷言。提交說明記錄失敗的指令和原因。
- 綠提交:最小實作。提交說明記錄通過的目標測試,並說明已考慮重構。
- 重構提交:行為或期望不變。提交說明說明變更了什麼、什麼保持不變。
提交中不要包含 AI 歸屬。
CLI 契約測試
對於 CLI 工具,指令表面是產品契約。建議在最低可靠層級採用由外而內的測試:
- CLI 契約測試:使用虛擬 provider 或 fixture 執行根/子指令;斷言 stdout、stderr、退出行為、JSON 形狀。
- 單元測試:解析、比較、分類、上下文選擇、錯誤分類。
- Provider 整合測試:檔案系統 fixture 和虛擬外部服務;不需網路/認證。
- 即時冒煙測試:僅選擇加入;斷言形狀/不變量,而非不穩定的確切輸出。
手動 QA 回歸規則
手動 QA 是探索性的,不是驗證主幹。當它發現行為缺口時:
- 撰寫能重現該問題的最低層級紅燈回歸測試。
- 驗證失敗證據。
- 實作綠燈。
- 重新執行自動化測試。
- 僅在測試通過後才重新執行手動 QA。
如果輸出/文案/CLI 形狀改變,則加入或更新 CLI 契約測試。
合理化陷阱
| 宣稱 | 回應 |
|---|---|
| 「太簡單了,不用測試」 | 簡單行為仍會回歸。加入這個小測試。 |
| 「之後再測」 | 事後通過的測試無法證明測試能失敗。 |
| 「我手動檢查過了」 | 手動檢查不是可重複的回歸測試。 |
| 「需要先探索」 | 沒問題。先做探索性原型,然後擱置並開始 TDD。 |
| 「測試很難」 | 介面可能難用。簡化接縫/設計。 |
| 「用模擬就很簡單」 | 也許是在測試模擬行為。請閱讀反模式。 |
| 「一個大測試就夠了」 | 偏好一次測試一個行為,除非端到端契約需要更多。 |
| 「重構需要失敗的測試」 | 純重構從綠燈開始且保持綠燈;行為變更需要紅燈。 |
紅旗:停止並修正
- 生產行為在失敗測試之前就變更了。
- 測試立即通過,但卻聲稱是紅燈。
- 未檢查失敗原因。
- 測試名稱描述的是實作。
- 測試斷言私有呼叫、呼叫順序或內部協作者。
- 模擬設定主導了測試邏輯。
- 存在僅供測試使用的新方法。
- 在綠燈階段修正測試,因為實作不同意。
- 在任何實作之前批次撰寫測試。
卡住時
| 問題 | 行動 |
|---|---|
| 不知道如何測試 | 寫出期望的公開 API 和第一個斷言。 |
| 測試設定龐大 | 提取 fixture/輔助函式;如果仍然龐大,簡化設計。 |
| 必須模擬所有東西 | 程式碼很可能耦合過度。引入依賴注入/接縫。 |
| 現有程式碼沒有測試 | 為當前行為加入特徵化測試,然後為變更執行紅燈。 |
| 錯誤不明確 | 先手動重現/檢視日誌,然後編碼為最小的失敗回歸測試。 |
完成前驗證
- 新/變更的行為有測試。
- 每個新行為測試都已觀察到因正確原因而失敗。
- 所有相關測試都通過。
- 測試驗證公開行為。
- 模擬僅限於邊界或合理範圍。
- 未加入僅供測試的生產 API。
- 手動 QA 的發現已轉換為回歸測試。