現在要讓 Claude Code、Codex 這類 Coding Agent 補單元測試,已經不算困難。真正麻煩的是下一個問題:它寫出來的測試全綠,Coverage 也很好看,你到底能不能相信這組測試真的抓得到錯誤?

這件事很容易被「測試通過」遮住。Agent 補了一組 case,CI 是綠的,line coverage、branch coverage 都往上走,看起來任務已經做完。但這些指標最多只能證明程式碼被執行過,不能直接回答更重要的問題:如果實作被悄悄改錯一點,這組測試會不會真的報警?

最近我看到一個剛發布的專案 mutagate,它把 mutation testing 直接接進 Coding Agent 的完成流程。原作者在 Reddit 原文 裡的想法很直接:Agent 寫完測試後先做 mutation,如果分數沒過門檻,就先別讓它結束,而是把 surviving mutants 再丟回去讓它補測試。

我很在意這個差別。對 Agent 來說,「Coverage 82%」其實是一條很模糊的回饋;「把 age >= 18 改成 age > 18 後你的測試還是全綠」就具體很多。後者直接告訴模型:18 這個邊界沒有被測試鎖住。

所以這次我沒有直接安裝 mutagate,而是在自己的 Content Lab 裡寫了一個很小的 Node fixture,只測這個機制本身。結果比我原本預期的更有意思:baseline 測試全綠,line / branch / function coverage 都是 100%,但 6 個受控 mutant 只殺掉 3 個。

測試全綠之後,還缺一件事:錯誤行為會不會被拒絕

先看一個很普通的函式:

export function isAdult(age) {
  return age >= 18;
}

如果測試只有:

isAdult(20) === true
isAdult(10) === false

這兩個 case 足以跑到 true / false 兩邊,branch coverage 完全可以是 100%。

但如果我把實作偷偷改成:

return age > 18;

上面兩條測試還是會通過。

這就是 Coverage 很容易讓人產生錯覺的地方。它回答的是「哪些程式路徑有被執行」,並不保證「這些斷言真的把行為邊界固定住」。

Mutation testing 問的是另一個問題。它主動製造一些小而明確的錯誤版本,例如:

>=  ->  >
>   ->  >=
條件反轉
交換分支結果

然後重新跑現有測試。

如果測試失敗,這個 mutant 被 kill,代表目前的測試至少能分辨這次行為變化;如果測試依然全綠,這個 mutant 就 survived。後者特別適合拿來餵給 Coding Agent,因為它不是一句模糊的「測試還不夠好」,而是一個非常具體的缺口。

這個方向也不是 mutagate 才第一次出現。較早的 MuTAP 研究 已經把 surviving mutants 回饋到 LLM 的測試生成流程裡,用 mutation information 繼續強化測試。現在把這個 loop 接進 Coding Agent 的生命週期,工程上反而更自然。

Claude Code 目前的 Hooks reference 本身就提供 PostToolUse、SubagentStop、TaskCompleted、Stop 等 lifecycle event,也支援 async hook。也就是說,「Agent 寫完測試 -> 背景做確定性檢查 -> 完成前再決定是否放行」不必繼續靠 prompt 提醒模型記得自我檢查。

這和我之前寫的 CLAUDE.md、AGENTS.md、Skills 和 CI 的邊界 是同一條邏輯:只要一條規則必須每次執行,它最後就應該逐漸從「希望模型記得」移到 executable check。

我跑了一次 A/B:Coverage 都是 100%,Mutation Score 從 50% 變成 100%

為了把問題切得乾淨,我沒有執行第三方 repository 的程式碼,也沒有安裝 mutation framework。實驗只用了自己寫的 Node fixture,裡面有三個很小的邊界函式:

isAdult(age)          // age >= 18
discount(total)       // total > 100
shipping(total)       // total >= 50 ? 0 : 5

接著我手動定義了 6 個可以清楚解釋的 mutant。裡面有明顯的條件反轉,也有 Coverage 最容易漏掉的邊界變化。

Baseline 一共有 6 條測試,刻意把正反分支都跑到,但不測剛好落在邊界上的值。

GitHub Actions 最後跑出的結果是:

指標Baseline
一般測試PASS
Line coverage100%
Branch coverage100%
Function coverage100%
Mutants killed3 / 6
Mutants survived3 / 6
Mutation score50%

三個 survived mutant 剛好全部是邊界變化:

age >= 18        -> age > 18
total > 100      -> total >= 100
total >= 50      -> total > 50

對我來說,這份 survivor 清單比「50% mutation score」本身更有價值,因為下一步幾乎一眼就看得出來。

我只補了三條斷言:

isAdult(18) === true
discount(100) === false
shipping(50) === 0

production code 沒有改,mutation matrix 也完全沒動。再跑一次以後,6 個 mutant 全部被 kill,score 從 50% 變成 100%。

這裡真正值得看的不是「100%」。如果太執著這個數字,很容易又掉進另一種指標崇拜。更有用的是這個閉環:

Agent 寫測試
  -> 一般測試通過
  -> 注入受控錯誤
  -> 找出 survivors
  -> 把 survivors 變成具體回饋
  -> Agent 補邊界 / 負向斷言
  -> 再驗證

Coverage 在兩輪裡一直都是 100%,沒有提供新的資訊;survivor 清單卻準確指出三個缺口。

跑完這組實驗之後,我反而更不想把 mutation testing 當成「再多一個分數」。放進 Coding Agent 流程裡,它更像是一個故障回饋產生器。

survivors 比固定 80% 門檻更值得餵回 Agent

mutagate 目前的預設配置很有代表性:在我核對的版本裡,預設是 gate 模式,mutation threshold 是 0.8,maxRounds 是 2。低於門檻時,它可以阻止 Agent 結束,再給 Agent 機會強化測試;如果 mutation infrastructure 自己出錯,設計上偏向 fail-open,避免一個壞掉的 gate 把 Agent 永久卡住。

這個方向我認同,但我不會把「80%」直接複製成所有專案的標準。

原因很簡單:mutation score 仍然只是一個 proxy。

2026 年一篇針對 LLM 產生測試的 replication study 結論相當克制:Coverage 和 mutation score 能不能代表真實 bug detection,和評估情境高度相關。如果是在假設原始程式碼正確的前提下比較不同測試生成方法,它們可以提供有意義的訊號;但如果 code under test 本身就可能帶著目標 bug,這些 proxy 就沒那麼直接。

另一篇 2026 年的 Python class-level 測試研究 也指出,結構 Coverage 很容易接近天花板,區分能力有限。比較實際的做法,是先把 executability 當成 gate,再把 Coverage、mutation testing 和結構品質一起看,而不是把任何一個數字當成答案。

所以如果真要把 mutation testing 接進 Coding Agent,我會讓一般測試先處理基本可執行性與回歸;mutation 再去找「目前斷言無法區分的錯誤行為」;integration、E2E、property、security 和 domain test 繼續負責 mutation testing 無法取代的層次。

門檻也最好從自己的 repository baseline 長出來,而不是看到工具預設 80%,所有專案就都用 80%。舊專案可能有大量 legacy code、equivalent mutants 或非常慢的 test suite;小型新 library 又可能很容易達到 90% 以上。更值得觀察的是:哪些重要路徑長期出現 survivors,以及這些 survivors 能不能轉化成有意義的新斷言。

這也是我覺得 survivor feedback 比 score 更適合餵給 Agent 的原因。數字只能說「你沒達標」,具體 mutant 才能告訴它「哪個行為沒有被目前的測試約束住」。

這層 gate 有成本,而且可能比一般測試貴不少

Mutation testing 必須反覆修改程式碼、反覆跑測試,所以它不可能是免費的。

這次我在 GitHub Actions 上把一般測試和完整 mutation loop 各跑了 5 次。fixture 已經非常小,最後的中位數仍然是:

執行中位耗時
一般測試98.331 ms
Mutation loop711.798 ms
額外耗時+613.467 ms
比例7.24×

這個 7.24× 不能外推成「mutation testing 通常就是慢 7 倍」。它只代表這個小型 synthetic Node fixture 的結果。能安全下的結論只有一個:即使程式很少,只要要針對多個 mutant 重跑測試,成本就會明顯增加。

到了真實 repository,我不會一開始就把全倉 mutation 放進每一次 Agent stop。更實際的方式是按修改範圍縮小 target,只對 Agent 剛碰過的模組或測試做快速 gate;完整 mutation suite 留給 CI、排程任務或風險較高的改動。

另外還有兩個很容易污染 mutation score 的東西:equivalent mutants 和 unreachable mutants。前者改了程式碼,但語意其實沒變;後者根本無法執行到。它們存活不代表測試差。如果工具處理不好,Agent 就可能被迫追一個實際上殺不掉的分數。

Infrastructure error 也應該另外處理。mutation runner crash、測試環境缺 dependency、process timeout,都不是「測試品質不合格」的證據。mutagate 選擇 fail-open,至少避免一種很糟糕的結果:為了提高可靠性,最後卻做出一個經常把開發流程鎖死的 gate。

安全邊界也不能忽略。mutagate 的 security 文件明確提醒,它觸發的 build / test 會以使用者權限執行,並不天然待在 Agent sandbox 裡。對一個剛看到的第三方 hook,我不會因為概念很好就直接塞進長期開發環境。至少先讀程式碼、固定版本、看清楚它會執行哪些命令,再決定要不要接入。

我會把 Mutation Testing 放在「完工證據」這一層

我現在會把 Coding Agent 寫測試拆成兩件事。

第一件是「產生測試」。現在的模型越來越擅長這件事,happy path、mock、常見邊界都能很快補出來。

第二件是「證明這組測試值得信」。這一層不應該繼續全部交給模型自己評分。

測試全綠可以當第一道門;Coverage 幫你看結構盲區;mutation survivors 再去檢查目前的斷言能不能區分合理的錯誤行為。到了業務關鍵路徑,integration、E2E、property 或領域測試還是要保留。

這樣看,Mutation Testing 在 Agent workflow 裡的位置其實很清楚。它不是新的真理分數,也不是用來取代 Coverage,而是幫「我已經把測試寫完了」這句話補一份更難敷衍的證據。

mutagate 現在還非常新,我不會因為這次小實驗就把它寫成所有 production repository 的標準方案。但它提出的工作流我認為成立:Agent 想結束之前,不只問測試有沒有通過,再故意把實作改錯一點,看看這些測試到底會不會反對。

這個問題本來就值得拿來檢查人寫的測試。當 Coding Agent 開始自動生成越來越多測試後,我覺得它只會更重要。