← 所有文章

工程 · 2026.10.03 · 4 min · 更新於 2026.10.26

覆蓋率很高還是漏測?用 CRAP 指標找出最該補測試的函式

程式多半是 agent 寫的,行覆蓋率很高,卻看不出沒測到的地方是不是落在最複雜的函式。導入 CRAP 指標、門檻定在 8 之後,前後端清掉兩百多個超標函式,也修了 72 個讀程式時發現的問題。

閱讀設定
主題
字級
寬度

測試的行覆蓋率很高,還是看不出剩下沒測到的幾行,是不是剛好落在最複雜的函式裡。

我這個 Rust 與 React 專案的程式多半是 agent 寫的,整體行覆蓋率很高。導入 CRAP 指標之後,發現確實有這種情況:後端用寬鬆的門檻就抓到排程器這類有分支沒測到的函式,前端的請求管線完全沒有測試。

最後門檻定在 8,前後端都清到 0 個超標,另外修了 72 個讀程式時發現的問題。

CRAP 在算什麼

CRAP(Change Risk Anti-Patterns)替每個函式算一個分數:

CRAP = 複雜度² × (1 − 覆蓋率)³ + 複雜度

複雜度指的是循環複雜度,算函式裡有幾條獨立的執行路徑,分支越多越高。

分支多又沒測到,分數就高;測滿的函式,分數等於它的複雜度。

我要的是一份指出「哪個函式該補測試或該拆」的清單,加上 PR 階段的檢查。後端用現成的 cargo-crap 加 cargo-llvm-cov,前端隔天跟上。

門檻 8,只查改到的檔案

門檻先用工具預設的 30,整個後端 2,670 個函式只抓到 3 個:排程器、Discord 維運通知、duration 解析。

三個都是真的有分支沒測到,只是 30 對這個 codebase 太鬆。

這三個函式和前端沒測過的請求管線,都是在行覆蓋率很高時沒被注意到的。CRAP 把它們排到清單最上面,先補哪裡就有了依據。

定 6 的話,三個 if 加一個 match 的函式測滿都過不了,最後定 8。測滿的函式分數等於複雜度,所以 8 同時也是複雜度的上限。

數線圖:CRAP 分數的門檻。工具預設 30 太鬆,後端 2,670 個函式只抓到排程器、Discord 維運通知、duration 解析 3 個;6 太嚴,三個 if 加一個 match 測滿也過不了;最後採用 8,因為測滿的函式分數等於複雜度,8 也是複雜度上限。

Rust 的 ? 運算子遇到錯誤會直接往上回傳。cargo-crap 預設每個 ? 加 1 分,但錯誤往上傳的那條路徑,行覆蓋率量不到。

算進去的話,一長串 DB 呼叫的函式測滿了分數還是高,結帳函式的複雜度會從 7 變成 21。所以我設成 ? 不算分支。

PR 規則是改到的檔案裡不能有超標函式,沒改到的不看。

這樣不需要 baseline(既有違規的基準清單),既有的超標函式也不會擋住不相干的 PR。

全 repo 任一函式超標就失敗的做法沒採用,因為 main 上只要還有一個,所有 PR 都會紅。

後端的檢查另開一個 job,沒動後端的 PR 不觸發。它沒設成 GitHub 的 required check,因為那樣每個 PR 都要回報、多算一分鐘額度。

改由 merge 用的 script 擋,細節在用 merge script 與部署預檢守住 main。

前端用 TypeScript AST 計分

前端在這之前只有 8 個 Playwright 測試,沒有單元測試。

我定的規則是 ?. 和 ?? 不算分支。現成的 crap4ts 和 ESLint 的 complexity 規則都沒有設定可以關,所以計分腳本自己用 TypeScript 的 AST(抽象語法樹,編譯器解析原始碼後的樹狀結構)寫。

覆蓋率要連分支出口一起算:if 和三元的兩邊、&& 和 || 的每個運算元都走過,才算測滿。

前端的分支多半寫在同一個敘述裡,像 JSX 的 {cond && …}。只算行的話,元件渲染一次就 100%,分數沒有意義。

覆蓋率工具用 Vitest 的 istanbul,因為 v8 實測會把提早 return 之後沒執行到的行,算成有執行。

清理兩百多個超標函式

門檻改成 8 的當下,後端有 120 個函式超標,前端有 88 個。

過渡用一份待清清單:清單內的檔案暫時沿用舊門檻,其他檔案立刻適用 8,清完一個移除一個。中途停在哪一步,已經清好的都不會退回去。

流程圖:門檻改成 8 的當下後端 120 個、前端 88 個函式超標。待清清單上的檔案暫時沿用舊門檻,其他檔案立刻適用 8,清完一個從清單移除一個,最後前後端都是 0 個超標。清法是沒人用的刪掉、沒測到的補測試、真的有分支邏輯的原地拆成有名字的函式。

原則是沒人用的刪掉、沒測到的補測試,真的有分支邏輯的才拆,而且是原地搬進有名字的函式,不改行為。

前端頁面拆之前,先對原本的頁面把測試寫到全過。拆完比對每一個 DOM 狀態,以及 API、導頁的呼叫順序,共 966 個情境。

這層對照真的抓到拆錯的地方:把一段 await 抽成新的 async 函式之後,API 呼叫和導頁的先後順序互換了。

補測試時讀到的問題,另外開 issue 修掉,例如:

  • 新登入通知把 iPhone、iPad 標成 macOS,因為判斷作業系統時先比到 Mac OS X 字串
  • 註冊查重用沒轉小寫的 email,大小寫不同的重複註冊會回 500
  • 暫停 Bot 時讓使用者選的原因只存在畫面上,根本沒送出去

代價:測試數與 CI 時間

導入前後的測試數和前端 CI 時間:

項目 導入前 導入後
後端測試 2,655 個 2,767 個
前端測試 0 個 2,604 個
前端 CI 檢查 不到 1 分鐘 1 分 47 秒

每個前端 PR 因此多算一分鐘額度。

#Rust#React#測試覆蓋率#CRAP 指標

留言

載入留言中…

下一篇

工程

抓 Rust 與 TypeScript 的循環依賴:自己寫模組依賴掃描器,分層規則放進 CI

後端 18 個 package 之間有好幾組循環依賴,只靠人記。我寫了一支不用編譯的掃描器,產出能點進程式碼的架構圖、把分層規則放進 CI,同一天清掉 23 條既有違規,只有 2 條需要注入。