品質閘門的更替:覆蓋率、突變分數與各自的射程
品質閘門要量的是測試的偵測能力:程式在某個位置改掉行為時,這套測試會不會變紅。行覆蓋率量的是執行足跡,它與偵測能力之間隔著一個假設——執行到了通常也就斷言了。
這個假設從來沒有完全成立。站內的 Assertion 品質三問把 isNotNull、isA 列為人手寫測試裡常見的無效斷言,T.C3 就是一個實例。變的是規模:套件小到可以逐條抽查時,這類斷言會在 review 裡被指出來;斷言可以整批產生之後抽查追不上,塌掉的是這個假設的可稽查性——它本身的正確性一直都只是近似。於是需要一個不靠抽查、而且用空斷言達不到的指標——突變測試是目前唯一一種空斷言達不到的成熟指標——這個性質來自它的構造,不隨工具生態變動。
覆蓋率失效的形狀
覆蓋率不是變得不準,是執行足跡與偵測能力之間的相關性斷了。一個測試呼叫函式、檢查它沒有拋出例外,覆蓋率把那幾行算進去;同一個測試對回傳值不做任何檢查,偵測能力是零。這種測試被稱為永遠綠的測試,它在覆蓋率報表上與一個嚴謹的測試無法區分。
T.C10 的數據顯示了這個斷裂的程度:事後補測試、逐函式測試、以及 TDD 三法則(沒有失敗的測試就不寫 production code、只寫剛好會失敗的測試、只寫剛好讓它通過的實作)加上複雜度上限,這三種做法產出的程式與套件組成完全不同,行覆蓋率全部停在 97% 到 99%。被覆蓋率區分出來的只有兩端——零測試又不開複雜度上限那一版停在 14% 與 24%(前者是分支形式的覆蓋、後者是行覆蓋;同一組紀律開啟複雜度上限的那一列被迫生出 40 個範例、覆蓋率跟著跳上來),數字全部來自載入而非斷言;三法則不開複雜度上限的那一版停在 85.81% 行覆蓋,作者標為 outlier,成因是主控台迴圈只被輕度驅動。也就是說它分得出「有沒有測試」與「有沒有一整塊沒被驅動」,分不出「測試好不好」。分得出有無、分不出好壞。
這給覆蓋率一個誠實的定位:它是下限指標。低覆蓋率確實代表有問題,高覆蓋率不代表沒問題,而把它當成通過條件會讓「達成它」變成最便宜路徑的目標。
突變分數量的是什麼
突變測試對程式注入單點的行為改變——把 > 換成 >=、把回傳值換成常數、刪掉一次呼叫——再看測試會不會因此變紅。變紅稱為殺死,全綠稱為存活。存活的突變指出一件具體的事:程式在那個位置改掉行為,這套測試不會發現。
突變分數(殺死數除以有效突變數)用字面上的空斷言達不到:要殺死突變,斷言必須真的檢查到那個行為。這是它取代覆蓋率當閘門的理由——不是它更精確,是覆蓋率最便宜的那條達成路徑對它無效。
但「低思考量」的路徑沒有被完全堵死,下一段的四條就是它們,其中兩條的思考量與空斷言同級。所以突變分數擋掉的是一種特定的作弊,不是所有作弊。
它並沒有因此免疫於機械約束的通病。這件事在分數只是本機參考值時不會發生,一旦它變成 CI 的通過門檻或團隊報表上的一個數字,讓它上升就有了立即收益——而有立即收益的最便宜動作一定會發生。便宜的達成路徑至少有四條。前三條改在設定檔裡——縮小掃描範圍(只掃已經寫好測試的模組)、放寬等價突變的排除清單、削弱算子集(拿掉最會存活的那幾種突變);它們不留痕跡於測試碼,所以設定檔要進版控,變更要跟分數變化一起看。
第四條在測試碼裡,而且它是這裡最需要提防的一條:錄製式斷言。把整個回傳值序列化後跟一份存下來的快照比對,思考量跟空斷言同級(不必想清楚哪個欄位重要),卻能一次殺死大量突變。它的判準是現狀而不是需求,也就是把整套測試變成行為快照——分數很高而「這個行為對不對」一個字都沒回答。防它的方式不是禁用快照,是問這份快照的預期值是誰決定的:由人審過的規格產生的合法,由實作跑一次產生的不合法。
還有一個不算作弊、但會讓分數失真的來源:例外導向的殺死。造成崩潰的突變連零斷言的 smoke test 都會紅,所以一套幾乎不做檢查的測試在多數專案也拿得到兩三成分數。看絕對分數之前先看這個基線。
導入的成本結構跟其他測試層不同:每個突變都要重跑一次相關測試,總時間是突變數乘上測試時間。壓下來靠三件事:
只跑這次改動的檔案。 完整跑一次的代價通常只在夜間排程或發版前接受得了,日常回饋要用差異模式。
用覆蓋率資料跳過沒被執行到的突變。 沒有任何測試執行到的程式碼,突變一定存活,跑它只是浪費——這是覆蓋率在新配置下仍然有用的地方:它變成突變測試的輸入而不是閘門本身。
平行執行並設定逾時。 有些突變會造成無窮迴圈,逾時要設得夠短,否則單一突變就吃掉整輪時間。
覆蓋率與偵測能力脫鉤這件事另有站內既有內容佐證(本章開頭引的 Assertion 品質三問與 T.C3),不單靠那份實驗;實驗本身的射程見模組入口。
存活的突變分兩類
處理方式相反,判錯了會傷到測試套件。
真的漏洞。 那個位置的行為改變確實沒有被任何斷言檢查到,補一條斷言。這是多數情形。
等價突變。 改寫之後行為完全相同。典型形態有兩種:一是等價的邊界寫法(i < n 換成 i <= n - 1),二是對結果沒有影響的短路順序(兩個都是純函式的條件互換前後)。這是工具的限制,要標記排除。為等價突變硬補斷言的代價是那條斷言必然綁在實作的寫法上,反過來傷害重構安全性,與行為優先的 TDD 要守的性質直接衝突。
判斷方式是問這個突變如果留在正式版本,有沒有任何一種輸入會讓使用者觀察到不同的結果。答案是沒有,它就是等價突變。
這個問題形式上可執行、實務上判不出來(等價性一般而言不可判定),所以要配一套不靠判準的處置紀律:單一突變的判定給一個時間盒(十五分鐘是個起始值,依領域邏輯的密度自行調整——密集的規則引擎值得久一點,膠水層更短),時間盒到了預設歸類為真漏洞而非等價突變(誤判成漏洞的代價是多寫一條斷言,誤判成等價的代價是永久失去一個偵測點)、排除清單的每一條要寫下「想不出區分輸入」的理由並具名,且由第二個人覆核。單人維護的專案沒有第二個人,這一項退化成定期回看自己的排除清單——此時清單的成長速度是唯一還在運作的訊號——它比程式碼成長得快時,判定已經放水了。
突變分數守不住什麼
這是導入時最需要先講清楚的一件事:突變是對照著程式現在的樣子產生的,所以它守得住「斷言不夠密」,守不住「這段程式一開始就實作錯了需求」。
把危險判定順序寫反的那份程式跑突變測試,長出來的斷言會把那個順序釘得更牢(機制推論,非實驗觀察)。那份實驗實際記載的是:突變長出來的第二套測試補的全是運算子層的翻轉,作者的結論是它「沒有發現一個不同的遊戲」。
這個限制在測試與實作出處不獨立時特別要緊,因為那正是規格層錯誤最容易產生的情境。突變分數 100% 的套件仍然可以整套建立在一個錯誤的需求理解上。
閘門要成對設計
每個機械閘門都有一個它量不到的維度,而達成該閘門最便宜的路徑通常就走在那個維度上。配對的檢查點要一起設,否則閘門會被最便宜的方式滿足。
| 閘門 | 它量得到 | 它量不到 | 配對要補的那件事(只有第一列可自動化,其餘三列是人的判準設計) |
|---|---|---|---|
| 行覆蓋率 | 執行足跡 | 斷言強度 | 突變分數 |
| 突變分數 | 對現有程式的偵測能力 | 需求是否被正確實作 | 攤開條件的維度表找沒有被碰過的交叉(驗收條件的等價類),並確認條件的出處與實作獨立 |
| 複雜度上限 | 單一函式的分支數 | 拆出來的名字還指不指涉一個完整的領域概念 | 逐個新名字問它對應到哪個業務動作,對不到的是切碎 |
| 測試數量 | 套件規模 | 涵蓋了幾個維度 | 攤開維度表數空格(驗收條件的等價類) |
複雜度上限那一列有實測的代價可以參考:同一份實驗在程式寫完後強制把圈複雜度壓到 3 以下,四個版本的函式數大致翻倍、覆蓋率上升到 97% 以上,而原作者給的可讀性評分全部落在最低的兩級,設計評分一次都沒有上升。他的描述是這個約束「不做簡化,只是繁殖名字」。這條的完整推導在 #278。
兩個前置條件
這一章的作法有兩個硬前提,任一個不成立時整套跑不起來,而它們不會在文件裡自己現身。
測試套件要是確定性的。 突變測試對 flaky test 極度敏感:一個隨機紅燈會被記成「殺死突變」,分數因此變成噪音、而且是往上飄的噪音。導入之前要先把已知的不穩定測試隔離出去,並確認隔離清單不在掃描範圍內。
該語言要有一個跑得動的突變工具。 工具的成熟度分語言差距很大(寫作當下 Java、JavaScript 與 Python 各有生產可用的實作,而包含 Dart 在內的不少語言還沒有),而這個生態逐年變動——導入前查一次該語言的現行實作,不要拿本文的舉例當現況。工具不存在時的替代品是性質式測試加上人工的斷言強度抽查,不是降級回覆蓋率。
導入順序
先有可信的判準,再談分數。順序顛倒的話,得到的是一套把錯誤需求釘得很牢的高分測試。
- 驗收條件出處獨立、且做過維度交叉盤點
- 覆蓋率降級成輸入而非閘門(餵給突變測試用來跳過未執行的位置)
- 突變測試先在核心領域邏輯上跑,不要一開始就全庫
- 門檻從現況起跳、只要求不倒退,不要一開始就訂絕對值
- 等價突變的排除清單納入版控,並定期回顧——排除清單長得太快是判斷太寬鬆的訊號
這五步自己也是機械約束——#278 要求每個機械約束配一個它量不到的維度,而模組入口的「判定問題的答案要留下痕跡」那一節給的是本模組共用的配對形式。這裡只補一件它特有的:門檻採「只要求不倒退」時,最省力的達成是讓新程式落在掃描範圍的 glob 之外,所以納入率要跟分數並列著看,兩個數字缺一個就讀不出真相。
這五步預設閘門由團隊自己決定。門檻是外部義務時——寫進合約的覆蓋率下限、監理或內稽要求的指標——作法是疊加而不是替換:外部門檻照舊守著,突變分數當內部指標另外跑。有一件事值得寫進驗收流程或合約附件:覆蓋率達標可以用空斷言達成,所以它不構成驗收通過的充分條件;而要讓這句話有效力,它得寫在有約束力的地方,那已經超出這一章能處理的層。同樣的分辨也適用於逐行閱讀——code review 在受監理環境常是變更管理控制項,存在理由是留下具名的責任證據與四眼原則,跟偵測效率無關,那種 review 不能因為「效率低」而移除。
第三點的範圍選擇有實際差別:領域邏輯的突變幾乎都有意義,而 I/O 膠水層、序列化樣板、設定讀取的突變多半是等價的或無關緊要的,先跑那些會讓排除清單暴增而收益很低。
判讀訊號
| 訊號 | 該做的事 |
|---|---|
| 覆蓋率維持在 90% 以上而故障落在已覆蓋的程式碼裡 | 斷言只檢查到有沒有跑完,用突變分數量一次偵測能力 |
| 測試數量大增而信心沒有跟著上升 | 量的是規模不是偵測能力,換指標 |
| 突變分數很高而需求層的錯誤還是漏出去 | 突變對規格層的錯誤不產生訊號,要補的是出處獨立的驗收條件不是更多突變 |
| 等價突變排除清單快速變長 | 判斷太寬鬆,逐條問「留在正式版有沒有輸入看得出差別」 |
| 突變測試跑一次要好幾小時 | 改差異模式、用覆蓋率跳過未執行位置、縮短逾時 |
| 新增一條機械閘門而沒指定它量不到什麼 | 補上配對檢查點,否則閘門會被最便宜的達成路徑滿足 |
| 「所有指標都綠了」被當成品質改善的結論 | 指標描述的是外觀量,要宣稱品質改善需要獨立於該指標的說法 |
下一步路由
- 判準本身怎麼取得獨立性 → 判準的推導來源
- 驗收條件的射程怎麼量 → 驗收條件的等價類
- 預期值連寫都寫不出來時怎麼辦 → 判準寫不下來的時候
- 術語卡 → Mutation Testing
- 機械約束的代價 → #278 機械約束買到被量測的那個數字
- 斷言本身怎麼寫才有鑑別力 → Assertion 品質三問
#testing #mutation-testing #coverage #quality-gate #ai-generated-code