問題

六個前端 repo 都設了覆蓋率門檻,CI 也都綠。但這個綠是假的,兩個獨立的原因:

假象一:分母不完整。 v8 provider 在沒有 include 白名單時,只計算被測試 import 到的檔案。沒有人測的檔案連分母都進不去。於是「已測的那幾支覆蓋得不錯」被呈現成「整個專案覆蓋得不錯」。Vitest 文件對 coverage.include 的預設值寫得很直白:沒設定時,就是「測試執行期間被 import 的檔案」。

實測前後:

repo宣稱加上 include 後
vue90.35%70.44%
react74.56%(只算到 6 個檔案,src 下實際有 100 個)5.08%
svelte87.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(頁面)。排掉頁面數字會好看很多,但那正是分母不完整的同一種自欺。

怎麼預防再次發生

  1. 看到覆蓋率數字,先問「分母是幾個檔案」。 報告底部的檔案列表數量對得上 find src -name '*.ts' | wc -l 才可信。差一個數量級就是白名單缺席。
  2. 設定檔的欄位名跟著大版本走。 升級 Vitest/Jest 這類工具時,被移除或改名的設定欄位不一定會報錯,可能只會靜靜失效;從別的工具抄過來的寫法也一樣。改完要驗證它真的會擋——故意把門檻設到高於現況,看它是不是真的紅。
  3. 「守門機制」本身要被驗證過一次。 宣稱有守門 ≠ 守得住。驗證方法是注入一個必然違規的狀態,看它會不會紅。
  4. 跨 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 對照診斷才定位:

reactastro_react
getRandomValues全 0有隨機值
crypto.subtle普通 ObjectSubtleCrypto
密文長度021 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 個是這一項。型別檢查也是「看起來有跑」的守門。

相關