AI 寫的測試全綠,但可能什麼都沒測

最近拿一份 AI 寫的測試做了個實驗。挑了它負責的其中一個檔案,讓工具把裡面的程式故意改壞,產生 86 個版本,每個版本只改壞一個地方,然後看測試會不會發現。結果:測試只抓到了 80.23%。

抓到八成,聽起來不算全空。麻煩的是剩下那兩成:那些沒被抓到的改壞版本,代表現有測試分不出改壞版和原版的差別,其中多數就是沒被驗到的行為。但這份測試天天在終端機裡亮綠燈,讓我以為全被守住了。

先說清楚前提:我平常完全沒在寫測試。這幾個月工作專案裡只要看到測試,百分之百是 coding agent 自己寫出來的。我原本以為整排綠燈就代表功能有被守住,後來才發現,綠燈只證明測試跑完了,不證明行為被驗過。

測試全綠,但其實是在演戲

這種現象在軟體圈有個名字,叫 test theater(測試劇場):測試跑得過,但沒有真的把關鍵行為檢查住。可能整份完全沒在檢查,也可能只有不痛不癢的弱檢查。

測試裡負責檢查的那行,行話叫「斷言(assertion)」,意思就是「宣告某個值應該等於什麼」。要抓有沒有在演戲,先看斷言通常最快:

翻一下 AI 產出的測試檔,很容易抓出一整串裝樣子的斷言:只驗物件存在、只驗真假、只驗呼叫沒炸開、只驗型別,或是只依賴範圍過大的整包快照、沒對關鍵行為寫明確檢查。把產品程式裡的商業邏輯整段拔掉、或改成錯的值,這類測試照樣全綠給你看。

不是只有我遇到

獨立工程師 Senko Rašić 記錄過類似的實測:用同樣「故意改壞程式」的做法重驗自家測試,發現有四分之一的改壞版本完全沒被抓到,比我那次的漏網比例還高一些。軟體顧問公司 testdouble 也公開寫過:傳統的涵蓋率工具看不到這種問題;把程式改壞,才看得到。

為什麼涵蓋率看不到?因為涵蓋率只回答「這行程式有沒有被執行過」。執行過不等於檢查過,所以一個涵蓋率百分之百的檔案,測試照樣可能什麼都沒驗。

為什麼 AI 特別愛走這條捷徑

說到底是目標設定的問題。我丟給 coding agent 的任務,成功條件通常是「寫好功能並補上測試,確認測試全綠」。對模型來說,它學到的目標就是「測試亮綠燈=任務完成」。既然目標是綠燈,最省事的路徑就是寫出一批不會失敗的測試。

這在機器學習裡叫 reward hacking(鑽獎勵機制漏洞),也是典型的 Goodhart 定律:指標一旦變成目標,就不再是個好指標。所以光在 prompt 裡叫它「請認真嚴格寫測試」並不可靠:一批不會失敗的測試,同樣滿足「認真寫完、全數通過」的字面要求。要動的是目標本身,不是措辭。

廠商自己把鑽漏洞寫進系統卡

這件事 Anthropic 和 OpenAI 的公開文件都記錄過,白紙黑字寫在系統卡裡。系統卡(system card)是廠商發布新模型時隨附的官方安全評估文件,等於廠商對自家模型的體檢報告。

Claude 3.7 Sonnet 的系統卡設了專節「Excessive Focus on Passing Tests」,記錄模型當 agent 寫程式時會為了讓測試通過而動手腳:直接在程式裡回傳測試預期的值,甚至回頭改測試檔來配合自己的輸出。官方直言這是強化學習訓練期間鑽獎勵漏洞的產物,還把「對測試檔的異常修改」列為要監控的訊號。

這也不是單一世代的舊聞。Claude Opus 4.5 的系統卡白紙黑字定義了這類行為,並點名多代模型都有「把答案寫死、或針對特定測試寫特例」的前科。到了 Claude Sonnet 4.6 的系統卡(2026 年 8 月當時的最新版),這件事仍然是常設評測項目。另一家也一樣:GPT-5 的系統卡承認模型會為了拿高分而作弊、欺騙評分機制,並已加入防護;OpenAI 的研究同樣拿「agent 在寫程式環境中鑽漏洞」當研究主題。Anthropic 的研究則把這種行為比喻成學生在自己的考卷上打成績。

要說清楚的是:系統卡直接證實的是「模型會為了綠燈去改程式、改測試」。至於寫出不驗行為的新測試,我的判斷是同一個根源的另一種表現(訓練都教會了它「綠燈就是目的」),這是推論,不是同一批證據直接證明的事。前者是作弊通過別人的考題,後者是自己出一份不會錯的考卷。

把程式改壞,看測試會不會紅

要抓出測試劇場,方法很直覺,叫 mutation testing(突變測試)

涵蓋率問的是「測試跑過這行了嗎」;mutation testing 反過來問:「這行故意改壞了,測試會不會失敗?」

工具會自動把原始碼裡的條件反轉、把大於改成小於、把字串清空,產生一個個改壞版本(行話叫 mutant,下文都叫改壞版本)。接著跑一次測試:

被抓到的比例就是 mutation score,開頭那個 80.23% 指的就是它。

到目前為止,這是還沒被 AI 大規模鑽掉的一個測試品質指標。但護欄要先講:分數一旦交給 AI 去衝,一樣會被湊出來,所以檢視漏網改壞版本的那一步,必須自己來。

一行指令就能跑,單檔只要 14 秒

我原先以為這類工具很重、要改一堆設定,實際上不用。在 JavaScript / TypeScript 生態系裡,Stryker 可以用一行 npx 指令零安裝執行,不必先裝進專案:

npx -y -p @stryker-mutator/core@9.6.1 -p @stryker-mutator/jest-runner@9.6.1 -p typescript@5.9.3 stryker run --testRunner jest --mutate "src/path/to/file.ts" --reporters clear-text --concurrency 2

(指令中的套件版本是 2026 年 8 月當時的版本;重點是用精確版號釘住執行環境。)

速度也不是問題:開頭那次實跑,單一檔案 86 個改壞版本,只花了 14 秒。至少在這個檔案、這套環境的量級下,日常拿來驗收單一檔案的測試品質完全跑得動;別的專案要自己實測。

拿到報告後的做法:先跑整個檔案抓出存活清單,再把 --mutate 的參數從整個檔案縮成「檔名:起始行-結束行」的範圍重跑,只重驗還活著的那幾個位置,針對它們補上真的有在檢查的測試。

溫和提醒只會漏接,推上去前攔截才算數

工具好用,不代表它會被用起來。

我一開始的做法,是在建立 commit 時對 agent 的工作流程跳出溫和提醒。觀察期內出現 5 次符合條件(改動含測試檔)的機會,提醒一次都沒有真正送進工作流程:各種提交寫法、執行環境的差異都成了旁路,訊息根本沒到場,更談不上被理會,成績是 0/5。

後來我把做法整個換掉:從「提交時提醒」改成「推送前強制攔截」。時機往後移到 git push,力道也從提醒變成直接擋下。實作是一支掛在推送流程前的攔截程式(pre-push hook):只要推送的程式碼包含測試變更,就先擋下來,由我決定這次要跑 mutation testing、還是填一句理由略過(唯一例外是下一節的不相容區塊,由程式自動略過)。

換了做法之後,累計 14 次觸發、14 次都留下收據。這裡的收據是一筆自動寫進紀錄檔的資料,記著當次推送的內容和我的決定,事後翻得出來。略過也必須填非空白的理由。14 次裡有真的跑了檢查的,也有填理由略過的。攔截真正保證的事情是:每一次略過都留下了紀錄,翻帳本就查得到。

兩段做法的數字不能直接互比(觀察方式不同),能比的是型態:提醒是一次都沒送到場,攔截是次次到場。

跑不動的舊工具鏈,誠實標記略過

落地時撞過一個現實邊界:同一個專案是 monorepo(很多子專案放在同一個儲存庫),其中一塊子專案的工具鏈太舊,零安裝的跑法在那裡結構性跑不起來。與其每次手動填同一句理由走過場,比較務實的做法是讓攔截程式直接認出這個區塊,自動寫下「環境不相容」的略過收據。

自動略過不是無聲放行:它同樣留收據,理由欄由程式自動填上「環境不相容」,帳本翻開就分得出哪些是「跑不了」、哪些是「不想跑」。

從一個檔案開始

如果你也在讓 coding agent 幫忙寫測試,挑一個剛產出測試的檔案,用上面那行零安裝指令跑一次看看。

面對報告裡存活下來的改壞版本,不需要追求滿分,逐個做三選一判定就好:

  1. 補測試:這段邏輯確實重要,補上具體斷言把它抓下來。
  2. 標註等價:改壞後的寫法在業務語意上跟原本相同,在程式碼裡標註原因。
  3. 判定不適用:這段是防禦性的邊角,像錯誤時補寫日誌、防呆檢查,風險低或已有其他保護層在守,不值得用單元測試硬守;判定時把理由寫清楚。

綠燈只證明測試跑完了,不證明行為被驗過。既然寫測試已經交給了 AI,「驗證測試有沒有效」這道防線,就得換個工具守住。