---
url: /blog-program/what-is-mutation-testing-and-stryker.md
description: 測試覆蓋率蓋好蓋滿，卻還是有蟲蟲，有沒有一個更誠實的分數？
---

![what-is-mutation-testing-and-stryker](/what-is-mutation-testing-and-stryker.webp){.cover}

# 測試覆蓋了卻還是有蟲蟲？來點突變吧！

大家好，我是鱈魚。%( ´ ▽ \` )ﾉ%

有沒有遇過 AI 洋洋灑灑寫了一卡車的測試，跑起來全部亮綠燈，覆蓋率甚至高達 100%，心滿意足地收工回家。

結果馬上就被 bug 炸飛了。%( ´•̥̥̥ ω •̥̥̥\` )%

覆蓋率只代表你的測試**有經過那幾行程式**，不代表那幾行**有被好好驗證**。

有沒有一種指標，能真正告訴我們「測試到底擋不擋得住 bug」？

有喔，這就是「突變測試」（Mutation Testing）。%(ﾉ>ω<)ﾉ%

## 什麼是突變測試

突變測試的主要目的是**測試你的測試**，聽起來很饒舌，其實概念很簡單。

其概念很壞壞，也很聰明。%(´,,•ω•,,)%

簡單來說就是故意把你的程式碼改壞，製造出一堆有 bug 的版本，這些被改壞的版本就叫做「突變體」（Mutant）。

改壞的方式五花八門，例如：

* 把 `>=` 改成 `>`
* 把 `+` 改成 `-`
* 把 `&&` 改成 `||`
* 直接把某行程式刪掉

接著拿你原本的測試，跑每一個突變體，有兩種結果：

* **突變體被殺死（Killed）**：測試失敗了，代表測試成功抓到這個被改壞的地方，這是好事。
* **突變體存活（Survived）**：測試竟然還是全綠，代表程式被改壞了測試卻沒發現，這就是漏洞。

最後它會把「被殺死的比例」算成一個「突變分數」（Mutation Score）：

```text
突變分數 = 被殺死的突變體 / 總突變體數量
```

覆蓋率回答的是「這行有沒有被跑過」，突變分數回答的是「這行如果寫錯了，你的測試攔不攔得住」。

後者才能看出測試是否全面。%(ゝ∀・)b%

## 來個案例吧

光講概念太無聊了，大家可能都跑光了。

甚麼？已經走了？別走啊，程式端上來了啊。%ლ（´口\`ლ）%

假設有個計算運費的函數，規則稍微複雜一點：

* 一般會員：滿 1000 免運，否則收 60 元
* VIP 門檻較低：滿 500 免運，否則收 30 元

`shipping.ts`

```ts
interface Order {
  amount: number;
  isVip: boolean;
}

export function getShippingFee(order: Order): number {
  const threshold = order.isVip ? 500 : 1000
  const base = order.isVip ? 30 : 60
  return order.amount >= threshold ? 0 : base
}
```

接著來點測試，一般會員與 VIP 各測「免運」跟「要付運費」兩種，這裡用 [Vitest](https://vitest.dev/) 示範：

`shipping.test.ts`

```ts
import { describe, expect, it } from 'vitest'
import { getShippingFee } from './shipping'

describe('getShippingFee', () => {
  it('一般會員金額足夠免運', () => {
    expect(getShippingFee({ amount: 1500, isVip: false })).toBe(0)
  })

  it('一般會員金額不足要收運費', () => {
    expect(getShippingFee({ amount: 800, isVip: false })).toBe(60)
  })

  it('VIP 門檻較低，同樣金額就免運', () => {
    expect(getShippingFee({ amount: 800, isVip: true })).toBe(0)
  })

  it('VIP 金額不足，收較低運費', () => {
    expect(getShippingFee({ amount: 300, isVip: true })).toBe(30)
  })
})
```

跑一下測試，全綠。覆蓋率 100%，完美，收工。%ᕕ( ᐛ )ᕗ%

一般會員、VIP、免運、要運費，四種組合全測過了，可以安心下班了吧？%ლ(╹ε╹ლ)%

## 讓 Stryker 上場踢館

再多綠燈也沒用，來點突變測試吧。

TypeScript 最有名的突變測試工具是 [Stryker](https://stryker-mutator.io/)。

名字取自漫威裡專門獵殺變種人的反派 Stryker，用來獵殺程式裡的突變體，這命名真的很會。%(´,,•ω•,,)%

因為我們用 Vitest，所以搭配 Vitest runner，先安裝套件：

```bash
npm install --save-dev @stryker-mutator/core @stryker-mutator/vitest-runner
```

接著加上設定檔：

`stryker.config.json`

```json
{
  "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
  "testRunner": "vitest",
  "mutate": ["src/**/*.ts", "!src/**/*.test.ts"]
}
```

然後執行：

```bash
npx stryker run
```

Stryker 會自動把程式改壞成一堆突變體，逐一丟給測試跑。%( •̀ ω •́ )✧%

```text
#1. [Survived] EqualityOperator
src/shipping.ts:9:10
- return order.amount >= threshold ? 0 : base
+ return order.amount > threshold ? 0 : base
```

結果出爐，竟然溜掉一隻突變體。%ლ(・´ｪ\`・ლ)%

Stryker 把 `>=` 改成 `>`，測試全都沒有發現。

這個改動只影響一種情況，就是金額「剛好等於門檻」的時候。原本剛好 1000 元的一般會員可以免運，改成 `>` 之後就要多付 60 元，這在電商可是會直接引爆客訴的 S 級重大 bug。%(;´༎ຶД༎ຶ\`)%

## 難道不是你太菜嗎？

路人：「本來就該測邊界啊，你確定不是你漏掉案例？%(´・ω・\`)%」

鱈魚：「是...是沒錯啦，只是程式碼一多、邏輯一複雜，很難確保每一個邊界都注意到嘛！%(;´༎ຶД༎ຶ\`)%」

路人：「剛剛的程式碼又不複雜...%ლ(╹ε╹ლ)%」

鱈魚：「不管啦，繼續。%ヽ(́◕◞౪◟◕‵)ﾉ%」

突變測試真正的價值，不是「挖出神秘的 bug」，而是底下這兩點：

1. **比覆蓋率更誠實的指標**：覆蓋率只回答「這行有沒有被跑過」；突變分數回答的是「這行如果寫錯了，測試攔不攔得住」。

2. **真正的價值是規模**：當專案裡有上千個判斷式或你接手一份看不懂的 legacy 測試時，根本沒辦法用肉眼一條一條確認。突變測試可以系統性地幫你把「漏掉的那幾條」標出來，讓你不必再賭自己的細心和運氣。

現在我們補上兩條門檻線：

```ts
it('一般會員剛好 1000 免運', () => {
  expect(getShippingFee({ amount: 1000, isVip: false })).toBe(0) // [!code ++]
})

it('VIP 剛好 500 免運', () => {
  expect(getShippingFee({ amount: 500, isVip: true })).toBe(0) // [!code ++]
})
```

再跑一次 Stryker，這隻突變體立刻被擊殺，突變分數也跟著補回來惹。

甚麼？你說殺突變體好麻煩？當然是叫 AI 殺啊！%ԅ(´∀\` ԅ)%

## 還有一種洞，斷言太鬆

剛剛那個洞是「**案例漏了**」，補一條測試就好。

但還有另一種更陰險的洞，案例明明有寫，卻等於沒寫。%( ˘•ω•˘ )%

回到開頭那個情境，AI 幫你生了一卡車測試。它很可能寫出這種東西：

```ts
it('一般會員金額不足要收運費', () => {
  expect(getShippingFee({ amount: 800, isVip: false })).toBeGreaterThan(0)
})
```

看起來很正常對吧？案例有了、跑得過、那行程式碼也確實被執行到，覆蓋率照樣 100%。%(・∀・)%

問題是 `toBeGreaterThan(0)` 根本沒在驗運費算得對不對，它只驗了「有收錢」。收 60 會過，收 30 會過，收 9999 也會過。%(°ㅂ°)%

這種斷言有正式名稱，叫做**寬鬆斷言**（overly general assertion），`toBeTruthy()`、`toBeDefined()`、`toBeInstanceOf(Object)` 都是常客，本身就被歸類為測試異味。

因為變數幾乎都有定義、物件幾乎都是物件，這種斷言等於什麼都沒驗。

而 Stryker 對這種洞特別敏感，因為它根本不需要你多寫什麼，直接動手改就對了。

```text
#2. [Survived] ConditionalExpression
src/shipping.ts:8:16
- const base = order.isVip ? 30 : 60
+ const base = true ? 30 : 60
```

它把運費強制改成 VIP 價 30 元，一般會員的運費從 60 變 30。

原本 `toBe(60)` 會當場翻臉，但 `toBeGreaterThan(0)` 看到 30 覺得很滿意，於是突變體大搖大擺地活了下來。%ლ(・´ｪ\`・ლ)%

這件事之所以重要，倒不是因為人看不出來。

`toBeGreaterThan(0)` 跟 `toBe(60)` 擺在一起，任誰都知道哪個比較鬆。

真正的問題是，**這種事根本不該由人來做**。%( ˘•ω•˘ )%

逐條檢查兩百行 diff 裡每個斷言有沒有驗到東西，是純粹的機械勞動，做完也不會變聰明，注意力倒是先燒光了。

而人的注意力很貴，應該留給機器做不到的事，例如這條商業規則本身對不對、有沒有漏掉什麼情境、這個設計三個月後會不會後悔。%( •̀ ω •́ )%

所以這跟前面講的「規模」是同一件事，**能自動化的就別交給人**。

斷言一放寬，突變體立刻復活，Stryker 不會累也不會分心，你就可以把腦袋留給真正需要思考的部分。%( •̀ ω •́ )✧%

路人：「所以斷言愈嚴愈好囉？%(｢･ω･)｢%」

鱈魚：「也不是，往另一邊歪一樣有事。%( ˘•ω•˘ )%」

把整個物件連 `createdAt` 時間戳一起 `toEqual` 下去，測試會因為完全無關的欄位變動一直壞掉，這在 xUnit Test Patterns 裡叫做 [Fragile Test](http://xunitpatterns.com/Fragile%20Test.html)。

判斷標準不是「愈嚴愈好」，而是**斷言有沒有正對著這條測試要驗的行為**。太寬驗不到東西，太窄綁到不相干的欄位，兩種都是沒對準。%( •̀ ω •́ )%

## 常見的 mutator 對照表

剛剛 Stryker 對 `getShippingFee` 動的那一刀（`>=` 改成 `>`），只是它眾多招式裡的其中一種。

它到底還會怎麼壞壞？這裡整理一張常見的 mutator 對照表，讓大家心裡有個概念：

| Mutator | 做什麼 | 範例（原始 → 變異） |
|---------|--------|---------------------|
| ArithmeticOperator | 算術運算子對調 | `a + b` → `a - b`、`a * b` → `a / b` |
| EqualityOperator | 比較運算子對調 | `a < b` → `a <= b`、`a === b` → `a !== b` |
| LogicalOperator | 邏輯運算子對調 | `a && b` → `a \|\| b` |
| BooleanLiteral | 布林值翻轉 | `true` → `false`、`!a` → `a` |
| ConditionalExpression | 條件式強制真假 | `if (a > b)` → `if (true)` 或 `if (false)` |
| StringLiteral | 字串內容抽換 | `'hi'` → `''` |
| UnaryOperator | 一元運算子翻轉 | `-a` → `+a` |
| UpdateOperator | 遞增遞減對調 | `a++` → `a--` |
| BlockStatement | 清空整個程式區塊 | `{ ... }` → `{}` |
| ArrayDeclaration | 清空陣列內容 | `[1, 2, 3]` → `[]` |
| OptionalChaining | 拿掉可選鏈 | `foo?.bar` → `foo.bar` |

完整清單可以參考 [Stryker 官方文件](https://stryker-mutator.io/docs/mutation-testing-elements/supported-mutators/)。%( •̀ ω •́ )✧%

## 突變一定要殺掉嗎？

前面一直說「存活的突變體代表漏洞」，但這句話其實有例外。

有些突變體根本**殺不死**，或者**殺了也沒意義**。%( ˘•ω•˘ )%

### 殺不死：等價突變體

先看一段把庫存正規化的函數，負數一律當作 0：

`normalize.ts`

```ts
export function normalizeStock(count: number): number {
  // 庫存不會是負數，負數一律當作 0
  if (count < 0) {
    return 0
  }
  return count
}
```

我們把正數、0、負數三種情況都測好測滿：

```ts
expect(normalizeStock(5)).toBe(5)
expect(normalizeStock(0)).toBe(0)
expect(normalizeStock(-3)).toBe(0)
```

看起來滴水不漏，但 Stryker 就是有辦法生出一隻殺不死的突變體：

```text
[Survived] EqualityOperator
src/normalize.ts:3:7
- if (count < 0) {
+ if (count <= 0) {
```

它把 `count < 0` 改成 `count <= 0`，唯一有差別的地方，是 `count` 剛好等於 0 的時候。

可是你仔細想想，`count` 是 0 時，原本走 `else` 回傳 `count`（也就是 0），改成 `<=` 之後走 `if` 回傳 `0`，兩邊算出來都是 0，結果一模一樣。%(・∀・)%

所以不管丟什麼進去，這兩種寫法的行為**完全相同**。

這種「改了程式碼卻沒改行為」的突變體，就叫做「等價突變體」（Equivalent Mutant）。

它本質上不是 bug，任何測試都不可能讓它失敗，你再怎麼補測試都殺不死它。

而且要判斷一隻突變體算不算等價突變體，工具也幫不了你。

以前判斷這個很麻煩，不過現在你可以叫 AI 看。%(´,,•ω•,,)%

### 殺得死，但不值得殺

另一種情況，是那些「殺得死，但殺了也沒意義」的突變體，最典型的就是 log 訊息：

```ts
export function checkout(order: Order): void {
  logger.info(`結帳開始，訂單編號 ${order.id}`) // Stryker 會把字串改成 ''
  // ...實際結帳邏輯
}
```

Stryker 會把那串 log 改成空字串，然後它就這麼活了下來，因為根本沒有測試在乎 log 印了什麼。

但你真的要為了殺它，特地寫一個「檢查 log 有沒有印出這串字」的測試嗎？

這種測試又脆弱又沒營養，~~就像上司強迫你寫精準到分的工作日誌~~

這種突變體放它一馬就好。%(ゝ∀・)b%

如果這些雜訊看了礙眼，Stryker 也能用 `// Stryker disable next-line all` 註解，或在設定檔排除特定 mutator，直接把它們消音。

所以看到存活突變體，先別急著補測試，先問自己兩個問題，這隻殺得死嗎？值得殺嗎？~~或是丟給 AI 殺。~~%( •̀ ω •́ )%

這也是為什麼突變分數不用硬追求 100%，和人生一樣，夠了就好。%(｢･ω･)｢%

## 有用歸有用，但是別用過頭

突變測試雖然強，但它有個明顯的缺點，就是慢到不行。

因為它要把程式改壞成幾十、幾百個突變體，每一個都得重跑一次測試，時間會指數級上升。

拿本文的範例實際量了一下（Node 22 + Vitest 3 + Stryker 9，11 條測試程序並行）：

| 規模 | 突變體數量 | 一般測試 | 突變測試 |
|------|-----------|---------|---------|
| 單一函數 | 5 | 約 1.8 秒 | 約 6～7 秒（首次冷啟動要 26 秒）|
| 五個函數 | 58 | 約 4.8 秒 | 約 28 秒 |

光是五個小函數，時間就從 5 秒膨脹到 28 秒，足足六倍，而且純突變執行階段會隨突變體數量幾乎線性成長。

真實專案動輒上萬個突變體，跑一輪從幾分鐘到幾小時都有可能。%(°ㅂ°)%

所以實務上通常不會拿它跟一般單元測試一樣，每次 commit 都跑全部。

比較常見的做法，是只針對核心邏輯、金流計算這類特別重要的模組使用，或是放在 CI 排程裡定期跑一次就好。

## 總結 🐟

* 覆蓋率只回答「這行被跑過沒」，不回答「這行寫錯了測試攔不攔得住」。
* **突變測試在測試你的測試**，故意把程式改壞，看測試抓不抓得到，最後給你一個比覆蓋率誠實的「突變分數」。
* 它抓得到兩種洞，**案例漏了**，還有**案例有寫但斷言太鬆**。後者不是看不出來，而是**這種機械檢查不該由人做**，人的注意力該留給「規則本身對不對」這種問題。
* JavaScript / TypeScript 可以用 **Stryker**，搭配 Vitest runner 幾乎無痛上手。
* 突變測試很吃效能，而且存活突變體不一定都要殺（**等價突變體**殺不死、log 訊息不值得殺），鎖定核心邏輯、放進 CI 排程，分數別強求 100%。

所以有突變測試後，系統就 100 分，蟲蟲絕跡，世界和平了嗎？

當然沒有，系統還是會有流程漏洞、規格外的 edge case 等等問題，那又是另一個故事啦。%乁( ◔ ௰◔)「% 。

有錯誤還請多多指教，感謝您讀到這裡，如果您覺得有收穫，歡迎分享出去。
