你的測試通過了,程式碼卻還是錯的

你已經達到 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 類型。請謹慎使用,以免遮蓋真正的測試缺口。