覆蓋率(code coverage)回答的是「哪些 code 被測試執行過」。但它回答不了「那些測試夠不夠嚴格」。

// 覆蓋率 100%,但這個測試什麼都沒驗
test('calculate discount', () => {
  const result = calculateDiscount(100, 0.2)
  expect(result).toBeDefined()  // 只確認有回傳值,不管是不是 80
})

calculateDiscount 改成永遠回傳 0,這個測試還是綠的。這就是覆蓋率的問題——它量的是「跑過」,不是「驗過」。

Mutation Testing 用一個不同的問題來衡量測試品質:如果我故意在你的程式碼裡插入一個 bug,你的測試會不會失敗?


Mutation Testing 的運作方式

工具自動對你的程式碼做小幅修改(稱為 mutant),然後跑你的測試套件:

  • > 改成 >=
  • + 改成 -
  • return true 改成 return false
  • if (a && b) 改成 if (a || b)

每個 mutant 如果讓至少一個測試失敗,稱為「被殺死」(killed)。如果沒有任何測試失敗,稱為「存活」(survived)。

Mutation Score = 被殺死的 mutant 數 / 總 mutant 數

Mutation score 80% 意味著你的測試能抓到 80% 的程式碼小改動。存活的 mutant 代表你的 assertion 有漏洞——那些修改被悄悄引入,你的測試不會發現。


工具

JavaScript / TypeScript — Stryker:最廣泛使用。在 stryker.conf.json 設定後,stryker run 跑完會輸出 HTML 報告,清楚顯示哪些 mutant 存活、在哪一行。

Java — PIT(Pitest):Maven / Gradle plugin,整合進 CI 時在報告裡顯示 mutation coverage。

Python — mutmut / Cosmic Ray:相對輕量,整合成本低。


Mutation Testing 的成本

Mutation Testing 比一般測試貴得多——每個 mutant 都要跑一遍完整的測試套件。100 個 mutant × 10 秒的測試套件 = 1000 秒。實務上有幾個做法控制成本:

只跑受影響的測試:Stryker 支援 --testRunner 加上增量分析,只跑覆蓋到被 mutate 那段 code 的測試。

只對核心業務邏輯跑:不是所有 code 都值得 mutation test。把它集中在訂單計算、折扣規則、權限判斷這類高風險的 domain logic 上。

在 PR review 流程裡跑,不是每次 commit:mutation testing 適合作為 quality gate,不適合塞進每次 push 的 CI 裡。


覆蓋率是測試的充分條件嗎?不是。Mutation testing 才是真正在問「如果有人悄悄改了這行 code,你會不會發現」。