AI 產生的測試可靠嗎?用 Mutation Testing 檢查 Coding Agent 測試品質
現在要讓 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 coverage | 100% |
| Branch coverage | 100% |
| Function coverage | 100% |
| Mutants killed | 3 / 6 |
| Mutants survived | 3 / 6 |
| Mutation score | 50% |
三個 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 loop | 711.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 開始自動生成越來越多測試後,我覺得它只會更重要。