Skip to content

what-is-mutation-testing-and-stryker

測試覆蓋了卻還是有蟲蟲?來點突變吧!

大家好,我是鱈魚。( ´ ▽ ` )ノ

有沒有遇過 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 示範:

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

名字取自漫威裡專門獵殺變種人的反派 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) 
})

it('VIP 剛好 500 免運', () => {
  expect(getShippingFee({ amount: 500, isVip: true })).toBe(0) 
})

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

甚麼?你說殺突變體好麻煩?當然是叫 AI 殺啊!ԅ(´∀` ԅ)

常見的 mutator 對照表

剛剛 Stryker 對 getShippingFee 動的那一刀(>= 改成 >),只是它眾多招式裡的其中一種。

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

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

完整清單可以參考 Stryker 官方文件( •̀ ω •́ )✧

突變一定要殺掉嗎?

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

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

殺不死:等價突變體

先看一段把庫存正規化的函數,負數一律當作 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 等等問題,那又是另一個故事啦。乁( ◔ ௰◔)「

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