問題
六個前端 repo 都設了覆蓋率門檻,CI 也都綠。但這個綠是假的,兩個獨立的原因:
假象一:分母不完整。 v8 provider 在沒有 include 白名單時,只計算被測試 import 到的檔案。沒有人測的檔案連分母都進不去。於是「已測的那幾支覆蓋得不錯」被呈現成「整個專案覆蓋得不錯」。Vitest 文件對 coverage.include 的預設值寫得很直白:沒設定時,就是「測試執行期間被 import 的檔案」。
實測前後:
| repo | 宣稱 | 加上 include 後 |
|---|---|---|
vue | 90.35% | 70.44% |
react | 74.56%(只算到 6 個檔案,src 下實際有 100 個) | 5.08% |
svelte | 87.45%/74.11%(branch) | 63.47%/46.72% |
react 那個差了 69 個百分點。
假象二:門檻整段被忽略。 svelte 的設定寫成:
thresholds: {
global: { statements: 80, branches: 80, functions: 80, lines: 80 }
}Vitest 的門檻從 1.0 起集中在 coverage.thresholds 底下,四個指標直接放在這一層(Vitest 1 遷移指南、coverage 設定文件);global 這一層從來不是 Vitest 的寫法,那是 Jest 的 coverageThreshold.global。寫錯不會報錯,所以那四個 80 從來沒有生效過,實際覆蓋率 76% 也照樣 exit 0。把格式改對之後,同一份程式碼四項門檻全部變紅。
為什麼完全不報錯,文件沒有直接說。我的推測是:thresholds 底下允許用 glob pattern 當 key 替特定檔案設門檻,global 很可能被當成一個對不到任何檔案的 pattern(未查證)。
兩個假象疊在一起:分母不完整讓數字虛高,門檻失效讓虛高的數字也不用達標。
根因
共通的形狀是**「設定寫了,但沒有接上」**,只是這次發生在設定檔而不是程式碼。
include缺席:不會報錯,v8 就是這樣設計的(沒指定就只看跑到的檔案)thresholds.global:不會報錯,對不上的設定靜靜失效
兩者都沒有任何訊號。CI 綠、指令 exit 0、報告有數字。要發現只能靠「拿數字去對照 src 底下實際有幾個檔案」。
解法
coverage: {
provider: 'v8',
// 沒有 include 時 v8 只計算被測試 import 到的檔案,分母不完整
include: ['src/**/*.{ts,svelte}'],
exclude: ['node_modules/', 'tests/', '**/*.d.ts', '**/*.config.*', '**/index.ts', 'src/lib/types/**'],
// 門檻直接放在 thresholds 底下,沒有 global 這一層(那是 Jest 的寫法)
thresholds: { statements: 63, branches: 46, functions: 69, lines: 61 }
}門檻數字用現況鎖定值而不是目標值:真實覆蓋率離團隊設的目標門檻(例如全域 90、branch 80)很遠,直接設 90 會讓 CI 一直紅,等於沒有守門。鎖現況擋倒退,補一批就往上調一格,並在註解寫明「這不是目標」。
為什麼這樣解
不把門檻直接設成目標值,是因為紅燈如果永遠是紅的,就沒有人會看。棘輪式往上調的好處是每次補測試都有立即回饋,而且任何倒退會馬上紅。
exclude 只排型別檔與設定檔,不排 src/routes(頁面)。排掉頁面數字會好看很多,但那正是分母不完整的同一種自欺。
怎麼預防再次發生
- 看到覆蓋率數字,先問「分母是幾個檔案」。 報告底部的檔案列表數量對得上
find src -name '*.ts' | wc -l才可信。差一個數量級就是白名單缺席。 - 設定檔的欄位名跟著大版本走。 升級 Vitest/Jest 這類工具時,被移除或改名的設定欄位不一定會報錯,可能只會靜靜失效;從別的工具抄過來的寫法也一樣。改完要驗證它真的會擋——故意把門檻設到高於現況,看它是不是真的紅。
- 「守門機制」本身要被驗證過一次。 宣稱有守門 ≠ 守得住。驗證方法是注入一個必然違規的狀態,看它會不會紅。
- 跨 repo 的同型設定要一起查。 這次是掃六家才發現三家都中。單看一家會以為是個案。
第三種:mock 成一個不會真的做事的假物件
同一輪還遇到第三個變體,機制不同但病灶一樣。
react 的測試 setup 裡有一份假的 crypto,註解寫著「Mock crypto.subtle for secure storage tests」:
Object.defineProperty(window, 'crypto', {
value: {
subtle: {
encrypt: () => Promise.resolve(new ArrayBuffer(0)), // ← 密文恆為 0 bytes
decrypt: () => Promise.resolve(new ArrayBuffer(0))
},
getRandomValues: (arr: Uint8Array) => arr // ← 不填隨機值,IV 全 0
}
});結果是 secureStorage 的任何 round-trip 測試都不可能通過——存進去的密文長度恆為 0,讀回來永遠是空字串。我一度以為那是產品碼的嚴重 bug(「存進去的 token 讀回來是空的」),花了三輪追,最後靠跨 repo 對照診斷才定位:
| react | astro_react | |
|---|---|---|
getRandomValues | 全 0 | 有隨機值 |
crypto.subtle | 普通 Object | SubtleCrypto |
| 密文長度 | 0 | 21 bytes |
Node 20 內建真正的 WebCrypto,jsdom 下直接可用,根本不需要 mock。移除後先前標記待查的 round-trip 測試全數通過,整組 11 條全過。
同一輪的第四個實例:astro_react 的測試 setup 把 localStorage 換成四個空的 vi.fn(),存不進值也讀不回來。
共同的判準:一個 mock 如果讓「被測對象的核心行為」永遠回傳固定值,那它保護的不是測試,是讓測試永遠不會失敗。判斷方法——問這個 mock 底下的測試「有沒有可能因為產品碼壞掉而變紅」,答案是否,那個 mock 就是有害的。
同一輪還抓到 svelte 的 tsconfig.json 用自訂 include 蓋掉 SvelteKit 生成的設定,$app/* 全部找不到型別——37 個錯誤裡有 26 個是這一項。型別檢查也是「看起來有跑」的守門。
相關
- 驗收假綠形態目錄:測試綠不等於事實成立的各種方式;這篇的三種都可以歸進去。
- 「兩種情況都寫」的容錯設定會兩頭落空:另一個覆蓋率守門的設定陷阱,症狀同樣是空報告靜默通過或數字變好看。
- 守門有沒有在擋,只能證明、不能推定:把覆蓋率之外的守門也放進來看——六個「設定寫了但沒生效」的實例,和怎麼證明守門真的在擋。