如果你的 mutation testing suite 要跑四小時,恭喜你。你證實了大家早就猜到的事:你的測試有漏洞。

你不會在每次 push 都跑這個。沒有團隊這樣做。問題不是你能不能負擔每次 commit 四小時,而是你能不能承受程式碼測試通過了,卻根本沒有驗證任何東西。

100% code coverage 只是虛榮指標

Code coverage 測量的是測試執行時跑過哪些行。它沒有測量那些行是不是真的被正確測試到。

一個測試可以執行某一行、不 assert 任何有意義的東西,仍然被算成 covered。Mutation testing 解決了這個問題:它對程式碼做小修改、跑測試、看會不會掛掉。如果程式碼被故意搞壞之後測試還是過,那這個測試就是廢的。

問題在規模。一個中等大小的 JavaScript 專案,一萬行程式碼、五百個測試,可能產生八千個 mutations。對每個 mutation 跑完整測試 suite 計算成本很高。在一般的 CI runner 上,這就是四小時的由來。

每次 commit 跑完整 suite 根本不現實。但這不代表你要完全跳過 mutation testing。

Incremental mutation testing 是唯一務實的做法

現代的 mutation testing 工具支援 incremental analysis。它們不會 mutate 整個 codebase,只會 mutate 當前 pull request 裡改到的程式碼。

一個典型的 PR 改了兩百行程式碼,工具可能只產生 40 到 80 個 mutations。對這些 mutations 跑相關的測試子集只要幾分鐘,不是幾小時。這才是團隊實際在 CI 裡使用 mutation testing 的方式。

StrykerJS 是最廣泛使用的 JavaScript mutation testing 框架之一,透過 incremental 選項支援 incremental 模式。它會把 mutation 結果存進 incremental.json,只重新分析改過的檔案。

以下是一個給 incremental CI 跑法用的最小化 stryker.conf.json

{
  "packageManager": "npm",
  "reporters": ["html", "clear-text", "json"],
  "testRunner": "jest",
  "coverageAnalysis": "perTest",
  "incremental": true,
  "incrementalFile": "reports/stryker-incremental.json",
  "mutate": [
    "src/**/*.js",
    "!src/**/*.test.js",
    "!src/**/__tests__/**"
  ],
  "thresholds": {
    "high": 80,
    "low": 60,
    "break": 50
  }
}

coverageAnalysis: perTest 這個設定是關鍵。它告訴 Stryker 只跑會覆蓋到每個被 mutate 檔案的測試,不是整個 suite。單這一項就能讓執行時間減少一個數量級。

thresholds 區塊定義什麼時候 build 會失敗。在這個例子裡,mutation score 低於 50% 會讓 CI pipeline 掛掉。50% 到 60% 之間會發警告。超過 80% 就是綠燈。

三種實際可行的 CI 模式

成功使用 mutation testing 的團隊不會把它當成 unit test 來跑。他們用以下三種模式之一。

Main branch 每晚完整跑一次。 完整的 mutation suite 每天跑一次,通常在半夜。結果會發布到 dashboard 並長期追蹤。這能抓出系統性的測試品質問題,又不會擋住日常開發。團隊看的是趨勢,不是單一分數。

Pull request 跑 incremental。 只 mutate 改過的檔案。CI job 會讓 PR pipeline 多跑 3 到 8 分鐘。如果改動的程式碼 mutation score 低於 threshold,PR 就會被擋住。Mutation testing 最大的價值就在這裡:在新程式碼進入 codebase 的當下把關。

重大部署前的 release gate。 有些團隊會在上線前或發新版本前跑完整的 mutation analysis。這被當作品質檢查點,類似安全稽核或效能回歸測試。不是每次 release 都跑,而是重要的版本才跑。

最能獲得價值的團隊會混合前兩種模式。 nightly run 追蹤整個 codebase 的健康度。Incremental PR run 對新程式碼強制品質把關。

Mutation score 不是目標

Mutation testing 在這裡會變得很政治化。如果你公開團隊的 mutation score 並把它跟績效考核綁在一起,工程師就會針對這個指標優化。

他們會寫出能 kill mutations 但沒測到實際行為的測試。他們會爭論 equivalent mutants(語意上跟原始程式碼完全一樣的變異)應該被排除在計分之外。他們會花好幾小時調 threshold,而不是寫有用的測試。

Mutation testing 是診斷工具,不是排行榜。分數是用來調查的訊號,不是要打到的目標。

更有用的做法是追蹤 mutation score 的趨勢,並把新程式碼的低分當成對話的開頭。「這個 PR 產生了 12 個 mutations,只 kill 掉 4 個。我們來看看缺了什麼。」這遠比一個顯示全倉庫 73% 的 dashboard 有價值得多。

一個可實際運作的 GitHub Actions workflow

以下是一個可用於生產環境的 GitHub Actions workflow,它會在 pull request 上跑 incremental mutation testing,並在每次執行之間儲存 incremental 狀態。

name: Mutation Testing

on:
  pull_request:
    branches: [main]

jobs:
  stryker:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Download previous incremental report
        uses: actions/download-artifact@v4
        with:
          name: stryker-incremental
          path: reports/
        continue-on-error: true

      - name: Run Stryker (incremental)
        run: npx stryker run

      - name: Upload incremental report for next run
        uses: actions/upload-artifact@v4
        with:
          name: stryker-incremental
          path: reports/stryker-incremental.json
        if: always()

關鍵細節是 fetch-depth: 0。Stryker 需要完整的 Git history 才能判斷 PR branch 和 target branch 之間哪些檔案改過。沒有這個設定,incremental 模式會退化成完整跑一次。

這個 workflow 會在執行前下載前一次的 stryker-incremental.json artifact。如果 artifact 不存在,第一次執行就相當於完整分析。後續執行會使用快取的結果。

Upload 步驟上的 if: always() 確保即使 mutation testing job 因為 threshold 未過而失敗,incremental 狀態仍然會被儲存。沒有這個設定,下一個 PR 會從頭開始跑。

Equivalent mutants 仍然是個問題

沒有任何 mutation testing 工具能可靠地偵測 equivalent mutants。這些 mutations 改變了程式碼的語法但沒有改變語意。經典例子是把 a = b + c 換成 a = c + b,在交換律運算中。這個 mutation 技術上不同,但行為完全一樣。

Equivalent mutants 浪費 CI 時間也讓工程師很幹。目前最好的做法還是透過工具專屬的設定手動排除。Stryker 允許你忽略特定的 mutators 或檔案。PIT for Java 支援 excludedMethodsexcludedClasses

沒有完美的解決方案。使用 mutation testing 的團隊會接受一定程度的噪音,並定期檢視他們的排除清單。

你的團隊該不該做?

Mutation testing 不是免費的。它需要 CI 計算資源、工具設定,以及持續維護 thresholds 和 exclusions。對原型專案或只有兩個工程師的專案來說,這是大材小用。

當你的 codebase 大到沒有人監督測試品質就會退化,而且團隊大到不是每個人都會詳細 review 每個 PR 的時候,它才值得投入。如果你曾經在生產環境發現一個應該被測試抓到的 bug,而測試明明存在卻根本沒有 assert 任何東西,mutation testing 本來可以抓到他。

先從你最關鍵的 service 在 PR 上跑 incremental 開始。追蹤一個月的趨勢。如果數字告訴你有用的資訊,再擴大。如果沒有,你只損失了一些 CI 分鐘,不是四小時。

對於剛起步的團隊,Stryker handbook 有 JavaScript、C# 和 Scala 的平台專屬指南。JVM 專案的標準工具仍然是 PIT。兩者都內建支援 incremental analysis。