在整個 codebase 強制套用單一的 mutation score,是讓團隊討厭寫測試的絕招。

拿 PIT 或 Stryker 跑一個典型的 repo,你會看到同樣的模式:auth 模組只有 40%,string utilities 衝到 95%,ORM 層則卡在 60 幾趴。本能反應是設一個 70% 的全域閘門,任何低於這個數字的 PR 都擋下來。兩個 sprint 後,就會有人把 CI 裡的檢查關掉,然後推給「mutator 不穩定」。

真正的問題不是工具。是假設所有程式碼在 mutant 存活時造成的衝擊都一樣大。

Mutation testing 到底在量什麼

Code coverage 告訴你哪些行被執行過。Mutation testing 告訴你,如果那些行被改掉了,你的測試會不會發現。

Mutation framework 會在原始碼裡注入小錯誤(mutants)。它可能把 > 翻成 <、移除某個方法呼叫、或改變回傳值。如果你的測試套件抓到了這個變化,mutant 就被殺死了。如果測試還是過了,mutant 就存活下來。你的 mutation score 就是被殺死的 mutant 所佔的百分比。

一個在 password hash comparison 裡存活的 mutant,是個等著上 production 的安全性漏洞。一個在 capitalizeFirstLetter helper 裡存活的 mutant,頂多就是個有點怪的 UI 標籤。

把它們當成一樣的東西,就是團隊犯錯的地方。

為什麼 auth 值得 90% 以上的閘門

Authentication 和 authorization 程式碼有兩個特性,讓它特別適合嚴格的 mutation testing。

第一,邏輯通常是離散且state machine 式的。Token 過期了嗎?Role 在允許的清單裡嗎?簽章驗證過了嗎?每個分支都有明確的安全意義,而且每個都應該被測試。

第二,存活 mutant 的代價是災難性的。一個在 role check 裡被翻轉的 boolean 就能暴露 admin endpoint。一個在 token validation routine 裡被漏掉的 not 就能接受偽造的 JWT。這些不是理論。CVE 資料庫裡滿滿都是 auth bypass,而這些邏輯錯誤 mutation testing 本來可以抓到。

在 Sentry,我們對 authn/authz/ 模組裡的任何東西都強制 90% mutation score。低於這個數字就會讓 CI 失敗。沒有例外,沒有「我們下個 sprint 再修」。這個模組夠小,所以做得到,不用每行 production code 都寫 40 行測試。

以下是實務上的樣子。這是一個簡化的 JWT validation routine:

import time
from typing import Optional

def verify_token(token: dict, expected_aud: str, leeway: int = 30) -> bool:
    now = time.time()

    if token.get("aud") != expected_aud:
        return False

    exp = token.get("exp")
    if exp is not None and now > exp + leeway:
        return False

    return True

Mutation framework 可能會在過期檢查裡把 > 翻成 >=。如果沒有一個測試使用剛好在 now + leeway 時過期的 token,這個 mutant 就會存活。這表示你的測試其實沒有驗證邊界條件。有 90% mutation coverage 的話,那個測試就會存在。

Utility code 有 60% 就夠了

你的 StringUtilsDateHelpersMathExtensions 則是在光譜的另一端。

這些模組通常是 pure、被大量重用、而且容易理解的。truncate(str, maxLen) 裡一個把 > 改成 >= 的存活 mutant,頂多就是多剪掉一個字元。那是 UI 小瑕疵,不是安全性事件。

風險與回報的算式變了。這些模組通常有幾十個小函式。追求 90% mutation coverage 表示要為 padLeft 裡的每個 off-by-one 變體寫測試。測試會變得比它們保護的程式碼還長,維護負擔開始超過價值。

我們對 utility 模組設了 60% 的下限。這能抓到明顯的漏洞(缺少 null check、錯誤的回傳值),但不會逼團隊窮盡測試每種字串切分的排列組合。

重點是要誠實面對 60% 代表什麼。它代表「我們已經測過常見案例和明顯的失敗情況」。不代表「這段程式碼不重要」。如果一個 utility function 被用在安全性敏感的路徑上,它就要繼承使用方更高的門檻。

中間地帶:business logic

大部分程式碼都落在這兩個極端之間。Payment processing、data validation、workflow orchestration。這些模組影響正確性和使用者信任,但單一存活 mutant 通常不會直接把資料庫交給攻擊者。

我們使用分層系統:

模組類型Mutation threshold理由
AuthN / AuthZ90%高衝擊範圍,離散邏輯
Business logic75%關鍵正確性,中等複雜度
Utilities / helpers60%低衝擊範圍,高重用性,簡單函式
Generated / boilerplate排除不要測試不是你寫的程式碼

這不是僵化的規則。Payment calculation 模組可能會提升到 85%。一個被 auth 程式碼使用的廣泛使用 JSON helper 可能會升級到 75%。這些層級是起點,不是牢籠。

如何實作分層 mutation gates

Stryker 和 PIT 都支援 per-module 設定。以下是我們如何把 mutmut 搭配 custom config 接到 Python 專案裡:

# mutation_config.py
THRESHOLDS = {
    "src/authn/": 90,
    "src/authz/": 90,
    "src/billing/": 85,
    "src/workflows/": 75,
    "src/utils/": 60,
}

EXCLUDE_PATHS = [
    "src/generated/",
    "src/migrations/",
]

在 CI 裡,一個小腳本會讀取這個設定,然後對每個模組執行 mutation tester:

#!/usr/bin/env bash
# ci/check-mutation.sh
set -e

python -m mutmut run --paths-to-mutate=src/authn/
python -m mutmut results || true
python -m mutmut run --paths-to-mutate=src/utils/
python -m mutmut results || true

python ci/verify_thresholds.py

驗證腳本會檢查每個模組的 score 是否達到門檻。如果 src/authn/ 拿到 87%,build 就會失敗,並附上明確的訊息:authn/ scored 87%, threshold is 90%

對於 Stryker(JavaScript/TypeScript),使用 stryker.conf.js 搭配 mutator groups:

// stryker.conf.js
module.exports = {
  thresholds: {
    high: 90,
    low: 75,
    break: null, // we handle this per-module
  },
  mutate: [
    "src/auth/**/*.ts",
    "src/billing/**/*.ts",
    "src/utils/**/*.ts",
  ],
  ignorePatterns: ["src/generated/**"],
};

我們把 Stryker 包在一個腳本裡,用不同的 path globs 跑三次,每次跑完後強制執行 per-directory threshold。這有點笨拙,但有用。

追求 100% 的陷阱

有些團隊把 mutation testing 當成要贏的遊戲。他們寫測試只是為了殺死 mutants,而不是驗證行為。

最糟的例子是測試某個 exception message 是否包含某個子字串,只是為了殺死一個改變 message 文字的 mutant。那個測試沒帶來任何價值。它沒有驗證 exception 是否在正確的時機被拋出,也沒有驗證類型是否正確。它只驗證了那個字串。

如果你發現自己寫測試只是為了推高百分比,你就把目標搞反了。Mutation testing 是診斷工具,不是排行榜。Score 告訴你該往哪裡看。它沒告訴你什麼時候做完了。

我們用慘痛代價學到的教訓

我們一開始設了全域 80% 的閘門。不到一個月,三個團隊就在 feature branch 裡把它「暫時」關掉了。其中兩個暫時關閉變成了永久關閉。

問題不在數字本身。而是 80% 對 auth 程式碼來說太低(我們漏掉了一個 role-check bug,讓它一路進到 staging),而對一個 4,000 行的 utility 模組來說又太高(團隊花了兩週為 isValidEmail 的變體寫測試)。

改成層級制後,採用率就穩住了。Auth 團隊接受 90% 的門檻,因為範圍是明確的。Platform 團隊接受 utility 的 60%,因為不用發瘋就做得到。分層做法把 mutation testing 從懲罰變成了關於風險的對話。

從哪裡開始

如果你要在現有 codebase 導入 mutation testing,第一週不要設任何閘門。跑工具、看分數,然後問自己:哪裡的存活 mutant 會造成最大的傷害?

從 auth 開始。在那裡設 90%,讓它變綠,並證明價值。等團隊信任這個信號後,再擴展到 business logic。Utility 保持較低的門檻,或是先排除,等養成習慣再說。

記住:一個 60% 分數搭配誠實的測試,勝過 95% 分數搭配為了應付 mutator 而寫的測試。目標是抓到真正的 bug,不是讓你的 metrics dashboard 好看。

如果你想自己試試,Python 的 mutmut 和 JavaScript 的 Stryker 都支援上面提到的 per-directory 模式。從小開始。一個 auth 模組。一個禮拜。看看什麼會存活下來。