「反正最後測試都是綠的,中間有沒有真的紅過一次,有差嗎?」
有差,而且差別很大。昨天講完假陽性測試的三種樣貌——空斷言、恆真斷言、Mock 掉待測物件本身——今天要指出一個更根本的問題:這三種假陽性,幾乎都能用同一個動作提早抓到:讓測試先真的紅一次。 如果一個測試從被寫出來的那一刻起就是綠的,代表沒有人(不管是 AI 還是人)真正驗證過「當這段邏輯是錯的、或還不存在時,這個測試會不會抓到」。
Red-Green-Refactor 的第一步之所以叫 Red,不是因為「測試失敗」這件事本身有價值,而是因為它是唯一能證明「這個測試有能力偵測到問題」的時刻。跳過這一步,後面不管寫得再仔細,都只是在賭這個測試真的有效。
嚴格的 TDD 循環是:先寫一個會失敗的測試(因為實作還不存在或還沒做對)→ 看著它真的失敗 → 寫最少量的程式碼讓它通過 → 重構。這個循環裡,「看著測試失敗」是一個獨立的、需要主動去做的動作。
但 AI coding agent 最常見的工作模式,是接到一個需求後,一次性生成完整的實作程式碼跟對應的測試程式碼——兩者幾乎同時出現,測試從被建立的那一刻,面對的就已經是「正確」的實作。這個測試從頭到尾沒有經歷過「失敗」這個狀態,因為失敗的前提(不完整或錯誤的實作)根本沒有存在過。
這不是 AI 偷懶,而是「一次到位」正是 AI 產出程式碼時最自然的模式——它不會主動分成「先寫一個註定失敗的骨架」跟「再補上讓它通過的邏輯」兩個步驟,除非流程明確要求它這樣做。
這裡要區分兩件容易被混為一談的事:
「有寫測試」不代表「測試被驗證過」。一份從沒紅過的測試,通過的原因可能是邏輯真的對,也可能是斷言太弱、恆真、或核心邏輯被 mock 掉——這正好對應昨天講的三種假陽性樣貌。Red 階段做的事,就是把「這個測試看起來會抓到問題」跟「這個測試真的會抓到問題」這兩件事之間的落差找出來。
用一組對照來看這件事:
❌ 一次到位,測試從沒紅過:
AI 在同一輪回應裡,同時產出:
function calculateDiscount(order) { /* 完整實作 */ }
test("計算折扣", () => {
assert calculateDiscount(order) == 150
})
→ 測試建立的當下,實作已經是「正確」的版本,
這個測試從沒有機會證明自己有偵測失敗的能力
✅ 先讓測試紅過,再補實作:
第一步:只寫測試,此時 calculateDiscount 還不存在(或回傳寫死的錯誤值)
執行測試 → 失敗,且失敗訊息明確指出「calculateDiscount 未定義」
或「預期 150,實際 0」
第二步:補上正確實作
執行測試 → 通過
→ 這個測試曾經失敗過一次,且失敗的原因跟預期一致,
代表它真的有能力偵測到「邏輯不對」這件事
光是看到測試變紅還不夠,紅燈的原因要跟你的預期一致,才算真正驗證過這個測試。如果你預期測試會因為「回傳值不對」而失敗,結果它卻是因為語法錯誤、或呼叫了一個根本不存在的函式而失敗,這代表測試失敗的原因跟你以為的完全不同——你以為自己驗證了斷言的有效性,實際上只是驗證了程式碼能不能跑起來。
這也是為什麼經驗豐富的工程師在做 TDD 時,看到紅燈的第一反應不是立刻動手修,而是先讀一遍失敗訊息,確認「這是我預期的那種失敗」。AI 協作時這一步特別容易被跳過,因為 AI 產出測試跟實作的速度太快,人來不及在中間插入「先看一次失敗長什麼樣」這個動作。
具體做法不複雜,但需要主動要求,不能指望 AI 自己這樣做:
Red 階段本身不產出任何最終會留在程式碼庫裡的東西——它產出的是「這個測試值得信任」這件事的證據。 少了這個步驟,接下來的 Green 跟 Refactor,都是建立在一個沒被驗證過的假設上。
回想你最近一次讓 AI 幫你寫測試的經驗:那個測試在通過之前,有沒有真的失敗過一次?如果答案是「不知道」,代表你現在也不知道那個測試是不是能真的抓到未來的迴歸。
明天要回答一個更根本的問題:如果「先看紅燈」聽起來只是一個額外的驗證步驟,為什麼要說它比「有沒有寫測試」這件事本身更重要?