你的測試通過了,程式碼卻還是錯的
你已經達到 100% 的行覆蓋率。每個分支都被執行過,每個函式都被呼叫過。然後有人把你的計價邏輯裡的 + 改成 -,跑了一下測試,全部通過。
這不是理論上的問題。這就是你的測試執行了程式碼,卻沒有真正驗證行為時會發生的事。Coverage 衡量的是哪些行被執行過,而不是哪些輸出被檢查過。Mutation testing 故意引入小型 bug,再驗證你的測試是否抓得到,藉此補上這個缺口。
對 Rust 團隊來說,問題不在於 mutation testing 是不是個好主意。問題在於,考量到 Rust 的編譯時間和型別系統,生態系中主要的工具 cargo-mutants 到底實不實用。答案是肯定的,但有一些必須留意的注意事項。
Mutation Testing 到底在做什麼
Mutation testing 的概念很簡單。工具會對你的原始碼做一個極小的改動,執行測試套件,然後檢查是否有任何測試失敗。
如果測試套件失敗了,這個 mutant 就被「killed」。這正是你想要的。這代表你的測試注意到了這個 bug。
如果測試套件通過了,這個 mutant 就「survived」。這代表你的測試執行了被突變過的程式碼,卻沒發現有任何問題。你有一個弱測試。
常見的 mutation 包括替換算術運算子(+ 變成 -)、交換比較運算子(> 變成 >=)、替換布林值字面量(true 變成 false),以及刪除會回傳值的函式呼叫。每一個改動都小到人類一眼就能看出是 bug。測試套件也應該要能發現。
cargo-mutants 如何對 Rust 程式碼運作
cargo-mutants 是專為 Rust 打造的 mutation testing 工具。它不需要你標註測試或更改建置系統。安裝完就能直接執行。
cargo install cargo-mutants
cargo mutants
這個工具會掃描你的原始碼檔案,透過對 AST 套用轉換規則來產生 mutant,然後為每一個 mutant 執行 cargo test。它會追蹤哪些 mutant 存活下來,並印出報告。
這裡有一個看起來很穩固、實際上卻有問題的函式和它的測試:
pub fn apply_discount(price: f64, rate: f64) -> f64 {
price * (1.0 - rate)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_apply_discount() {
let result = apply_discount(100.0, 0.2);
// We ran the function. Coverage is 100%.
// But we never asserted the result.
}
}
cargo mutants 會產生一個將 * 改成 / 的 mutant,或是把 1.0 - rate 替換成 1.0 + rate。測試仍然會通過,因為它從來沒有檢查 result。存活下來的 mutant 就標示出了這個問題。
一個能消滅 mutant 的真實測試長這樣:
#[test]
fn test_apply_discount() {
assert_eq!(apply_discount(100.0, 0.2), 80.0);
assert_eq!(apply_discount(50.0, 0.0), 50.0);
}
現在每個算術 mutant 都會失敗,因為 assertion 會抓到錯誤的輸出。
輸出結果長什麼樣子
執行 cargo mutants 後,你會得到一份摘要:
Found 42 mutants
Killed 38 mutants
Missed 4 mutants
Timeout 0 mutants
Unviable 0 mutants
Missed 的 mutant 就是存活下來的那些。cargo mutants 會把每一個存活者寫到 mutants.out/ 裡,附上 diff 和檔案路徑。你讀完 diff 後,補上缺少的 assertion 即可。
Timeout 發生在 mutant 導致無限迴圈的時候。cargo-mutants 會偵測到這個情況,並將它標記為 killed by timeout,這也算成功。
Unviable 的 mutant 是無法編譯的改動。Rust 的型別系統會在測試執行前就先拒絕它們。
Rust 的型別系統是把雙面刃
在 JavaScript 或 Python 中,mutation testing 工具幾乎可以替換任何運算子,程式碼依然能跑,只是會產生錯誤的結果。在 Rust 裡,很多 mutation 在測試執行前就被編譯器攔下了。
把 + 替換成 - 用在無號整數上,可能會造成溢位,但程式碼還是編得過。在泛型情境中把 > 替換成 <,如果 trait bound 不支援這個比較,編譯器可能就會拒絕。刪除一個回傳值的函式呼叫,編譯器也會報錯。
這表示 cargo-mutants 產生的 viable mutant 比其他語言的同等工具來得少。一個 Python 專案的某個模組可能出現 200 個 mutant,Rust 專案可能只有 40 個。能編譯過的 mutant,才是真正可能溜進生產環境的。型別系統幫你過濾掉了雜訊。
代價是編譯時間。每一個 viable mutant 都會觸發一次重新編譯。測試套件跑五分鐘的專案,執行 cargo mutants 可能得花一個小時。
編譯時間的代價是真實的
這是團隊猶豫的主要原因。Mutation testing 理論上是高度可平行化的。每個 mutant 都是獨立的。但實務上,Rust 的建置系統並沒辦法在同一個原始碼樹上,乾淨地平行化數十個編譯器實例。
cargo-mutants 有 --jobs 參數,但磁碟 I/O 和 crate graph 的lock 會成為瓶頸。在典型的雙核心 CI runner 上,擴展性很差。
你可以緩解這個問題。使用 --in-place 來避免每次突變都複製整份原始碼樹。使用 --file 或 --exclude 來lock 特定模組。把 mutation testing 改成每晚或每週執行,而不是每次 push 都跑。
cargo-mutants 會漏掉什麼
沒有任何 mutation testing 工具能抓出所有問題。cargo-mutants 有一些你應該知道的特定限制。
它不會突變 macro 展開後的程式碼。如果你的關鍵邏輯寫在 macro 裡,工具只看到呼叫點,看不到產生的程式碼。
它無法理解語義等價性。有些 mutant 會產生行為不同、但對所有合法輸入仍然正確的結果。一個多餘的 + 0 可能會存活,因為測試不在乎,即使這個 mutation 不算真正的 bug。這些你必須手動分類。
什麼時候值得付出 Mutation Testing 的成本
你不需要每次 commit 都跑 cargo mutants。你需要它的時機,是你的測試套件已經大到讓你不再相信自己的 assertion。
當某個關鍵模組覆蓋率很高,但你還是持續釋出 bug 時,就該跑它。或者當重構以微妙的方式改變了邏輯,你想確認 assertion 是否夠嚴謹時。
如果你的測試套件本來就不穩定,或者編譯時間已經是所有人都在抱怨的瓶頸,那就先別跑。先把基礎問題修好。
把 Mutation Testing 加入 CI,又不搞爛 Pipeline
實務上的設定是排程任務,而不是對每個 pull request 設下關卡。
這裡有一份每週執行的 GitHub Actions workflow:
name: Mutation Testing
on:
schedule:
- cron: "0 3 * * 1"
workflow_dispatch:
jobs:
mutants:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install cargo-mutants
run: cargo install cargo-mutants
- name: Run mutation testing
run: cargo mutants --in-place
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: mutants-report
path: mutants.out/
--in-place 參數讓磁碟使用量維持在合理範圍。rust-cache 縮短初始建置時間。排程觸發避免阻塞開發者。把報告上傳成 artifact,這樣你就可以直接檢視存活的 mutant,不用在 CI log 裡面翻找。
從單一模組開始
你不需要突變整個程式碼庫。挑一個具有關鍵商業邏輯、而且有 bug 歷史的模組。執行 cargo mutants --file src/pricing.rs。讀報告。修最弱的測試。
第一次執行總是最慘的。你會發現有些測試執行了程式碼,卻什麼都沒 assert。你會發現有些分支雖然被測試覆蓋了,但測試並沒有檢查分支的結果。你會納悶這些測試當初怎麼會讓人覺得夠用。
這正是重點所在。Mutation testing 不是在找程式碼裡的 bug。它找的是測試裡的 bug。在 Rust 這種編譯器已經能攔下明顯錯誤的語言裡,這正是你需要的回饋循環。
常見問題
什麼是 mutation testing?
Mutation testing 透過在你的原始碼中引入小型、刻意的 bug 來評估你的測試套件。如果你的測試失敗了,這個 mutant 就被「killed」。如果你的測試通過了,這個 mutant 就「survived」,表示你有一個缺口。
Mutation testing 和程式碼覆蓋率有什麼不同?
Coverage 衡量的是哪些行被執行過。Mutation testing 衡量的是你的測試是否能偵測到那些行產生的錯誤輸出。一個測試可以達到 100% coverage,卻完全消滅不了任何 mutant。
所有 Rust 專案的 mutation testing 都很慢嗎?
成本會隨著編譯時間和測試數量而增加。小型函式庫可能幾分鐘就跑完。大型 workspace 專案則需要更長的時間。使用 --file 和 --exclude 來把執行範圍縮小到特定模組。
我可以忽略誤判的 mutant 嗎?
可以。cargo-mutants 支援 mutants.toml 設定檔,讓你排除特定檔案、函式或 mutation 類型。請謹慎使用,以免遮蓋真正的測試缺口。