AI 幫忙寫測試後,回來總是顯示「所有測試都通過了」。最近這已經變成日常了。只是,當被問到這些測試 是不是真的有在驗證什麼 時,老實說,我沒辦法很有自信地回答。因為那些綠色勾勾排排站,可能只是就算壞掉也不會發現的測試。

於是我試了 mutation testing(突變測試),也就是用來衡量測試強度的方法,以及它的工具 Stryker。這次我把它用在多租戶 SaaS 的情境中:做一個「依公司自動篩選資料的 Prisma client」。如果寫錯,就可能看見其他公司的資料,這是絕對不能出錯的機制。

這篇文章會先說明 mutation testing 與 Stryker 的基本概念,再整理實際開發中用了之後發現了什麼、如果要放進 harness 要怎麼做,以及它和 AI 的相容性,並附上實際數字。

mutation testing 與 Stryker

先整理一下 mutation testing 是什麼,以及 Stryker 在做什麼。

Stryker 在做的事

就算測試全部通過,也不代表「測試能看出壞掉的程式碼」。coverage 也是一樣,只是在看有沒有跑到那些行而已。

mutation testing 直接回答這個問題。故意把實作稍微弄壞(mutation = mutant),看測試會不會失敗。如果失敗,就代表「這種壞法測得出來」;如果通過,就代表「測不出來」。也可以說,這是在做「測試的測試」。

Stryker 是會自動把這種「故意弄壞」套用到實作各處的工具。比方說,有這樣的程式:

if (user.role === "admin") {
  // 只有管理者才能做的處理
}

Stryker 會從這一行生成像下面這樣的變異。

替換規則(mutator) 替換後 想驗證什麼
EqualityOperator if (user.role !== "admin") 條件反過來時能不能發現
ConditionalExpression if (true) 不是管理者也能通過時能不能發現
ConditionalExpression if (false) 就算是管理者也不能通過時能不能發現
BlockStatement 把 { } 裡的內容清空 裡面的處理整個消失時能不能發現

除此之外,還有像是把 && 改成 ||、從陣列中刪掉元素之類的規則。產生變異後,接著會跑以下流程:

  1. 預先執行(dry run)
    先用原始程式把所有測試跑過一次,確認都能通過。同時記錄每個測試跑到了哪些行。
  2. 逐一對變異執行測試
    每次只啟用一個變異,然後只跑那些會碰到該行的測試。
  3. 判定
    測試失敗就是「殺掉了(killed)」,通過就是「活下來(survived)」。成功殺掉的變異比例就是 mutation score。

如果測試只是在驗證「管理者可以通過」,那麼 if (true) 這種變異就會活下來。活下來的變異(survived mutant)會以檔案、行號、以及你怎麼改的形式列出來。

其他語言方面
這次用的是 JavaScript / TypeScript 用的 StrykerJS(Stryker 也有 C# 版、Scala 版)。其他語言也有同類工具,可以用「mutation testing + 語言名稱」去找。

到了 2026 年,為什麼 mutation testing 會受到關注

mutation testing 本身是老方法了,但隨著 AI 會大量撰寫程式碼和測試,它又重新受到關注。Thoughtworks 的 Technology Radar 在 2026 年 11 月把它列為 Adopt(建議導入)等級。原因是 AI 協助寫程式後,程式碼和測試都暴增,而那種沒有真的驗證內容的淺層測試,也能輕易把 coverage 數字灌高。mutation testing 可以作為 guardrail,幫忙踩煞車。

coverage 的數字靠 AI 很容易就能拉高。也正因如此,需要的是不看 coverage,而是看「壞掉時能不能發現」的工具。

題材:依公司篩選資料的 Prisma client

接下來介紹這次對 Stryker 套用的系統。若你有以下基礎,會比較容易進入情境:

  • TypeScript 與 Prisma 的基本用法(findMany、include、巢狀寫入)
  • 寫過 Jest 測試
  • 用過 Claude Code 之類的 AI coding agent

先說背景,還有做出來的系統。

背景:漏寫 tenantId,測試抓不出來

在多租戶應用程式中,每個 API 都必須確認「是不是自家資料」。常見做法是在每個 route 的 where 裡手動加上公司的條件。

// 在每個 route 手動寫公司條件
const task = await prisma.task.findFirst({
  where: { id: taskId, project: { tenantId } },
});

如果應用程式還小,這種方式大概是最簡單也最好理解的。不過當 model 增加到 131 個、route 也增加到大約 190 個時,光靠人工注意就很難一直維持正確。

這種做法下,就算忘了寫公司的條件,型別檢查和測試也都可能照樣通過。如果實作者漏掉篩選就直接上線,只要知道 ID,就可能透過 API 直接碰到其他公司的資料。

特別容易漏掉的是沒有直接帶 tenantId 欄位的 model(例如 task 的子資料、圖片的子資料等)。例如 task 的 comment,就必須每次都寫 task → project → tenantId 這種要追兩層父層的條件。

// 漏寫的例子:只用 id 取得,所以別家公司的 comment 也可能被取出
const comment = await prisma.taskComment.findUnique({
  where: { id: commentId },
});

如果 model 本身有 tenantId 欄位,where 裡沒寫 tenantId 一眼就看得出來。但必須追父層的條件,就算漏了,看起來還是很像正常程式碼。也就是說,只注意有欄位的 model,還是補不起洞。

只靠人工注意,規模越大越容易漏寫。所以我決定做一個就算忘了寫也會自動按公司篩選 的機制。當然,也可以用 PostgreSQL 的 RLS(row-level security)從資料庫端篩選,但這次做的是應用程式端篩選。

做出的東西:定義表,以及自動補條件的 client

先看整體架構。先知道完成品,後面談測試時會比較不容易迷路。

定義表(各 model 的「自家資料」條件)
   │
   ▼
依公司篩選的 client(每次查詢都看定義表,自動補上條件)
   │
   ├── 實際 DB 測試(確認 A 公司可取、B 公司不可取,涵蓋所有 model)
   │
   └── Stryker(確認實際 DB 測試,是否真的能看出壞掉的程式碼)

這個機制的核心,是一份針對每個 model 寫下「什麼是自家資料」的定義表。把 key 的型別做成 Prisma 生成的 Prisma.ModelName,再寫成 Record<Prisma.ModelName, ...>,這樣一來,如果 schema 裡新增了 model 卻忘了補那一行,就會出現型別錯誤。

// 三種選項:篩選(回傳條件的函式)/ 不篩選 / 禁止使用
type Rule = ((tenantId: number) => object) | typeof UNSCOPED | typeof FORBIDDEN;

// key 使用 Prisma.ModelName。若在 schema.prisma 新增 model 卻漏寫某一行,會出現型別錯誤
const TENANT_SCOPE: Record<Prisma.ModelName, Rule> = {
  Project:     (tenantId) => ({ tenantId }),
  TaskComment: (tenantId) => ({ task: { project: { tenantId } } }),
  Prefecture:  UNSCOPED, // 所有公司共用的 master
  // ...
};

UNSCOPED 表示對所有公司公開,所以只用在共用 master 之類的資料上。

沒有直接帶 tenantId 欄位的 model,也能用追父層的條件來寫(task → project → tenantId)。實際上 131 個 model 都有對應的行,而且每一個都一定要在「篩選」「不篩選(像共用 master)」「禁止使用」三者中選一個。

client 是用 Prisma 的 $extends 做的。它會在中途攔下所有查詢,參考定義表,把條件加到 where 後再執行。只有列在清單中的操作(像 findMany、update、delete 等)才會加條件,而且會保留呼叫端原本寫好的 AND 條件。這樣 route 端就只要寫 tenantPrisma.task.findUnique({ where: { id } }),公司條件會集中在單一個定義表檔案中。

但真正的難題從這裡才開始。光是把條件加到 where 還不夠,因為 include 追出去的資料、data 裡的父 ID、巢狀的 connect、set、disconnect 等等,任何一條都可能把資料送到別家公司。client 內部越來越複雜,人類已經無法靠閱讀判斷它到底對不對。

撰寫實際 DB 測試

如果正確性沒辦法靠人腦追蹤,那就只能靠測試確認。首先,我寫了不是 mock、而是直接打實際資料庫的測試。因為 mock 測試只能確認「有沒有用預期的參數呼叫」,無法確認條件真的有被篩掉。

測試形式很單純:建立 A 公司和 B 公司的資料,然後從 A 公司篩選的 client 確認以下兩件事:

  • A 公司的資料可以取到
  • B 公司的資料取不到

測試資料會讀取 schema.prisma 的必填欄位,並按父層順序自動建立。這樣一來,model 增加時也不用額外補測試。最後實際 DB 測試大約有 380 個,在本機跑只要 1 秒多。

那麼,這些測試真的夠強嗎

實際 DB 測試全部通過了。那麼,它們真的能看出壞掉的程式碼嗎?

例如,原本應該是篩選 A 公司(tenantId: 1),卻連 B 公司(tenantId: 2)的資料也回傳,想像這種壞法。只驗證「自己公司的資料有取到」這種弱測試,遇到這個變異還是會通過。

type Row = { id: number; tenantId: number };
const rows: Row[] = [
  { id: 1, tenantId: 1 },
  { id: 2, tenantId: 2 },
];

// 真實實作
const findOwn = (tenantId: number) => rows.filter((r) => r.tenantId === tenantId);
// 變異:Stryker 產生的「故意弄壞的程式碼」範例(把條件固定成 true)
const findOwnMutant = (_tenantId: number) => rows.filter(() => true);

// 弱測試:只確認自家資料有取到
const weakTest = (find: typeof findOwn) => find(1).some((r) => r.id === 1);
// 強測試:連別家公司資料取不到也一起驗證
const strongTest = (find: typeof findOwn) =>
  find(1).some((r) => r.id === 1) && !find(1).some((r) => r.id === 2);

console.log("弱測試  真實:", weakTest(findOwn), " 變異:", weakTest(findOwnMutant));
console.log("強測試  真實:", strongTest(findOwn), " 變異:", strongTest(findOwnMutant));

執行後會得到像這樣的結果:

弱測試  真實: true  變異: true
強測試  真實: true  變異: false

弱測試在真實實作和變異實作下都回傳 true,所以根本察覺不到問題。如果不只確認「拿得到」,還要確認「拿不到」,就不能算是真正的篩選測試。 這點很重要。

這次的實際 DB 測試有做到「B 公司的資料取不到」。即便如此,光靠人讀程式,也沒辦法判斷定義表和 client 的各種壞法是否都能抓出來。所以我用 Stryker 對定義表與 client 進行變異,確認實際 DB 測試是否會失敗。

實際用過後發現的事

這裡整理實際跑 Stryker 後得到的心得,也就是在使用前就想先知道的事。

要流的測試,先從「縮小範圍」來想

一開始專案裡沒有 Stryker,所以我加上了共通設定(stryker.config.mjs)。但直接用預設設定行不通。Stryker 的 Jest runner 會把所有有 import 到被變異檔案的測試都跑一遍,連間接引用的也算。這次的定義表透過 helper 被大約 170 個 route import,而那些 route 的測試也全都被捲進來了。

Stryker 在變異前會先做 dry run,如果這一步就有任何一個測試失敗,就不會往下走。當你把幾百個無關測試也一起帶進來,只要有一個完全不相干的測試失敗,整體就停住。每個變異還要跑更多測試,所以時間也會拉長。因此這次我只讓它跑實際 DB 測試。把「要變異的檔案」和「驗證它的測試」綁成一組來想,就不容易迷路。

花了多少時間

這是縮小測試範圍後實際跑出來的數字(client 的部分,並列了第一次結果和修正測試後的結果)。每個變異平均會跑 47 個測試。

目標變異數 時間 mutation score
只有定義表 359 個 2 分 6 秒 100%
定義表+client 407 個 3 分 30 秒 89.74% → 修正測試後達到 100%
包含重寫處理在內的 4 個檔案 512 個 約 5 分鐘 99.61%(剩下 2 個是後述 Prisma 行為導致無法殺掉的變異)

定義表一開始就有 100%,是因為所有 model 都是機械式地跑「可取 / 不可取」的測試。相反地,手寫的 client 測試就有漏洞。

Stryker 找到了什麼

這裡介紹在正式發佈前驗證時,從存活下來的變異中看出了什麼。這些問題都不是人光看測試就很容易察覺的。

「測試有通過,但其實沒驗證到」的測試

以下是活下來的變異,以及原因。

活下來的變異 原因 修正方式
刪掉既有的 AND 條件 A 公司的資料只有 1 筆,就算條件被刪掉結果也一樣 再放一筆符合條件與不符合條件的 A 公司資料
從加入條件的操作清單中移除 'delete' B 公司有子資料,就算沒被篩選直接刪除,也會因外鍵限制失敗,所以測試只是「有錯誤」就過了 用沒有子資料的資料來測試,並確認錯誤種類(P2025: not found)以及 B 公司的資料仍然存在
移除在外鍵為 undefined 時的略過邏輯 測試沒有涵蓋這個情況 補上這個情況的測試

第二個特別危險。測試有確認「嘗試刪除 B 公司的資料時會出錯」,而且也確實通過了。可問題是,那個錯誤 不是因為有按公司篩選,而只是外鍵限制讓它失敗。如果這樣的測試不改,未來有人就算把 'delete' 從加入條件的操作清單裡拿掉,測試還是會是綠的。這代表上線後可能真的能刪到別家公司資料。能在正式發佈前發現這件事,多虧了 Stryker。

不需要的程式碼

活下來的變異不只指出測試弱,也會指出程式本身多餘的部分。

在 code review 時,有人說「透過 Proxy 呼叫會讓 this 跑掉,所以需要 bind」,AI 就照著加了 bind。但在 Stryker 裡,把 bind 拿掉的變異全部都活下來了。仔細查後發現,在這種用法下,就算沒有 bind 行為也一樣。只是因為那個建議聽起來很合理,所以就直接採用了。後來我把 bind 拿掉了。

如果刪掉某段程式碼,測試卻沒有失敗,那就表示少了它也不影響行為。Stryker 也能用來找出那些看起來很合理、其實根本不需要的程式碼,這是我得到的另一個發現。

Prisma 的怪異行為

把 disconnect: true 改成 false 的變異,無論怎麼寫測試都會活下來。上面時間表裡的「剩下 2 個」就是這個。調查後發現,這是 Prisma 已知的 bug:就算 disconnect: false,它還是會把關係解除,而且 issue 也已經存在。

如果要放進 harness

這裡談的是,如果要把它放進 AI 開發的 harness(讓 AI 能自動工作的基礎設施)要怎麼做。因為目前 harness 環境還沒完整建立,所以這裡寫的是依據這次經驗推估的整合方式。

執行頻率,以及想改進的地方

實際 DB 測試大約 1 秒多就結束,但 Stryker 需要幾分鐘到幾十分鐘。當系統成長、變異數達到 1,326 個時,甚至花了 24 分鐘。每次都跑不切實際,所以如果要整合,預計會這樣分工:

  • 實際 DB 測試:在 Claude Code 的 Hook(工作結束時)每次都跑。失敗就讓 AI 自己修。不過如果多個 AI 同時在跑,Hook 階段就會對共享 DB 並行操作。要先準備好下面會提到的測試專用 DB
  • Stryker:當成個人技能使用,當你改動定義表或 client 時,在 PR 前跑一次。通常只會在新增 model 時改動,所以這樣就夠了

如果一開始就塞到整個團隊的 pre-push 或 CI 裡,所有人都要承受同樣的等待時間。我打算先確認自己這邊能順利跑,再逐步擴大。

反覆執行後,也看到了在放進 harness 前應該先修正的地方:

  • 能指定要跑哪些測試:避免把關聯很薄的測試也全部捲進來
  • 把清理流程做成即使中途停止,下次也能回收的形式:如果只是「記住測試建立的資料然後刪掉」,當某次因為逾時等原因中斷時,就什麼都不會刪,測試用的公司或圖片會一直殘留。因此希望把測試資料的前綴加上執行 ID 和開始時間,並把清理分成兩個階段
    • 結束時:只刪除這次執行建立的資料,不碰同時在跑的其他執行
    • 開始時:刪除那些從開始後已經過了一段時間的其他執行資料。這個時間要比最長執行時間長(這次是 24 分鐘),例如設成 1 小時。正在執行中的 run 不會被誤刪,只會回收卡住沒結束的那批資料
  • 不要在共享 DB 上並行跑:如果多個工作目錄或多個 AI 同時執行實際 DB 測試,非相關測試可能會失敗。現在是用並行數 1 來跑,未來希望準備測試專用 DB

如果想用機器保證,就把判斷集中到一處

這次最大的收穫是這個。

Stryker 能保證的,只有集中在一處的部分,也就是定義表和 client。散落在各個 route 的檢查(例如在 create 前確認父資料是不是自家公司)都是 route 的測試在 mock Prisma。就算把 where 裡的公司條件拿掉,mock 也會回傳一樣的值,所以測試不會失敗。這部分既不會被實際 DB 測試碰到,也不在 Stryker 的範圍內。

保證範圍 做法
定義表 / client 型別(漏寫行)+實際 DB 測試(內容錯誤)+ Stryker(測試太弱)機械式保證
散落在各 route 的檢查 依賴每個 route 的測試與 code review

也就是說,如果想用機器保證,就不要把判斷分散到各處,而是集中在一個地方。如此一來,只要把那一處用實際 DB 測試與 Stryker 固定住就好。不只 Stryker,我想這個概念也適用於整個交給 AI 的開發流程。

和 AI 的相容性

這裡整理我實際把 Stryker 用在 AI 開發時的感受。結論先說:相容性非常好。

存活下來的變異,可以直接變成丟給 AI 的工作

Stryker 的輸出,是「哪個檔案、哪一行、怎麼改後,哪些測試還是通過了」的清單。例如會長這樣:

[Survived] ConditionalExpression
src/lib/tenant-prisma/rewrite-data.ts:42:9
-   if (foreignKey === undefined) continue;
+   if (false) continue;
Tests ran:
    update: 自家父資料的 ID 被重新指派
    ...

這對 AI 來說是非常明確的任務。比起說「把測試變強」,「幫我補一個能殺掉這個變異的測試」更清楚。存活下來的原因也多半很典型(例如資料只有 1 筆、沒有檢查錯誤種類等等),AI 也能順利處理。

如果要交給 AI,最好把它分類成下面三種,判斷才不會飄來飄去:

  1. 補測試
    沒有能看出變異的測試,就直接寫。
  2. 可刪除的程式碼候選
    如果變異後行為一樣,那這段程式可能根本不需要。前面提到的 bind 就是這一類。
  3. 等價變異
    在語意上和原始程式一樣的變異。既然殺不掉就接受它,並留下原因註解。

也能成為阻止 AI 太過「合理」的裁判

AI 有時會很順從地接受 code review 的意見或自己的推測,而且看起來都很合理。前面提到 reviewer 建議加上 bind,我和 AI 一開始也都覺得合理。Stryker 則能用數據驗證這種「看起來合理」的東西。當人和 AI 都被騙過時,有個機器能冷靜地說「這段程式拿掉也一樣」,真的很有價值。

需要注意的事

不過反過來,也要注意:

  • 不要為了殺掉等價變異,硬寫奇怪的測試。等價變異本來就不該被殺掉。若完全交給 AI,有時會寫出過度依賴實作細節、很脆弱的測試,硬把它弄失敗
  • score 100% 不代表規格正確。Stryker 測的是「目前的測試能不能看出目前程式的壞法」,並不能找出原本就沒被涵蓋的寫法漏洞。這次我做法上的調整,是不再逐一補漏,而是改成「只讓經過確認的寫法通過,其餘全部報錯」的 allowlist 方式

Stryker 是測試強度的工具,不是找規格漏洞的工具。把角色分開看,就不容易混淆。

總結

正因為進入了 AI 會幫忙寫測試的時代,所以比起「測試有通過」,更重要的是測量 「壞掉時能不能發現」。回頭看這次經驗,我認為重點有三個:

  • 篩選類測試,不只要在實際 DB 上確認「拿得到」,也要確認「拿不到」
  • 存活下來的變異分成三類處理:補測試、刪程式碼、或當成等價變異接受。這對交給 AI 的任務也很適合
  • 想用機器保證的判斷,就集中到單一位置,再用實際 DB 測試與 Stryker 固化它

Stryker 可以直接對現有測試做嘗試。你可以先挑一個最不能出錯的檔案,只指定那一個做 --mutate,看看會有多少變異活下來。

AI 可以很快把測試變成綠燈;至於後面那步「壞掉時能不能發現」,可以交給 Stryker 來確認。

參考

株式会社シンシア

株式会社シンシア正在招募沒有實務經驗的工程師與學生工程實習生,並一起工作。
※ 關於シンシア的工作樣貌,請見這裡

我們每年大約會收到 100 位沒有實務經驗者的應徵,並進行技術面試。
如果這篇文章對你有一點幫助,也歡迎看看 wantedly 的 story,我們會非常開心!


原文出處:https://qiita.com/Dingu-suke/items/b5318ef2040a9f59224a


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
boluoji
📝26   💬3   ❤️6
593
🥈
站長阿川
📝4   💬2   ❤️2
193
🥉
mike
1
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登