
測試覆蓋了卻還是有蟲蟲?來點突變吧!
大家好,我是鱈魚。( ´ ▽ ` )ノ
有沒有遇過 AI 洋洋灑灑寫了一卡車的測試,跑起來全部亮綠燈,覆蓋率甚至高達 100%,心滿意足地收工回家。
結果馬上就被 bug 炸飛了。( ´•̥̥̥ ω •̥̥̥` )
覆蓋率只代表你的測試有經過那幾行程式,不代表那幾行有被好好驗證。
有沒有一種指標,能真正告訴我們「測試到底擋不擋得住 bug」?
有喔,這就是「突變測試」(Mutation Testing)。(ノ>ω<)ノ
什麼是突變測試
突變測試的主要目的是測試你的測試,聽起來很饒舌,其實概念很簡單。
其概念很壞壞,也很聰明。(´,,•ω•,,)
簡單來說就是故意把你的程式碼改壞,製造出一堆有 bug 的版本,這些被改壞的版本就叫做「突變體」(Mutant)。
改壞的方式五花八門,例如:
- 把
>=改成> - 把
+改成- - 把
&&改成|| - 直接把某行程式刪掉
接著拿你原本的測試,跑每一個突變體,有兩種結果:
- 突變體被殺死(Killed):測試失敗了,代表測試成功抓到這個被改壞的地方,這是好事。
- 突變體存活(Survived):測試竟然還是全綠,代表程式被改壞了測試卻沒發現,這就是漏洞。
最後它會把「被殺死的比例」算成一個「突變分數」(Mutation Score):
突變分數 = 被殺死的突變體 / 總突變體數量覆蓋率回答的是「這行有沒有被跑過」,突變分數回答的是「這行如果寫錯了,你的測試攔不攔得住」。
後者才能看出測試是否全面。(ゝ∀・)b
來個案例吧
光講概念太無聊了,大家可能都跑光了。
甚麼?已經走了?別走啊,程式端上來了啊。ლ(´口`ლ)
假設有個計算運費的函數,規則稍微複雜一點:
- 一般會員:滿 1000 免運,否則收 60 元
- VIP 門檻較低:滿 500 免運,否則收 30 元
shipping.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
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,先安裝套件:
npm install --save-dev @stryker-mutator/core @stryker-mutator/vitest-runner接著加上設定檔:
stryker.config.json
{
"$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
"testRunner": "vitest",
"mutate": ["src/**/*.ts", "!src/**/*.test.ts"]
}然後執行:
npx stryker runStryker 會自動把程式改壞成一堆突變體,逐一丟給測試跑。( •̀ ω •́ )✧
#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」,而是底下這兩點:
比覆蓋率更誠實的指標:覆蓋率只回答「這行有沒有被跑過」;突變分數回答的是「這行如果寫錯了,測試攔不攔得住」。
真正的價值是規模:當專案裡有上千個判斷式或你接手一份看不懂的 legacy 測試時,根本沒辦法用肉眼一條一條確認。突變測試可以系統性地幫你把「漏掉的那幾條」標出來,讓你不必再賭自己的細心和運氣。
現在我們補上兩條門檻線:
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 + 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 官方文件。( •̀ ω •́ )✧
突變一定要殺掉嗎?
前面一直說「存活的突變體代表漏洞」,但這句話其實有例外。
有些突變體根本殺不死,或者殺了也沒意義。( ˘•ω•˘ )
殺不死:等價突變體
先看一段把庫存正規化的函數,負數一律當作 0:
normalize.ts
export function normalizeStock(count: number): number {
// 庫存不會是負數,負數一律當作 0
if (count < 0) {
return 0
}
return count
}我們把正數、0、負數三種情況都測好測滿:
expect(normalizeStock(5)).toBe(5)
expect(normalizeStock(0)).toBe(0)
expect(normalizeStock(-3)).toBe(0)看起來滴水不漏,但 Stryker 就是有辦法生出一隻活的:
[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 訊息:
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 等等問題,那又是另一個故事啦。乁( ◔ ௰◔)「 。
有錯誤還請多多指教,感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。