<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Quality-Gate on Tarragon</title><link>https://tarrragon.github.io/blog/tags/quality-gate/</link><description>Recent content in Quality-Gate on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 24 Aug 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/quality-gate/index.xml" rel="self" type="application/rss+xml"/><item><title>品質閘門的更替：覆蓋率、突變分數與各自的射程</title><link>https://tarrragon.github.io/blog/testing/06-agent-authored-code/coverage-to-mutation-gate/</link><pubDate>Mon, 24 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/testing/06-agent-authored-code/coverage-to-mutation-gate/</guid><description>&lt;p>品質閘門要量的是測試的偵測能力：程式在某個位置改掉行為時，這套測試會不會變紅。行覆蓋率量的是執行足跡，它與偵測能力之間隔著一個假設——執行到了通常也就斷言了。&lt;/p>
&lt;p>這個假設從來沒有完全成立。站內的 &lt;a href="https://tarrragon.github.io/blog/testing/05-test-design-judgment/assertion-quality/" data-link-title="Assertion 品質三問" data-link-desc="斷言的是行為嗎？能區分正確和錯誤嗎？會 flaky 嗎？— 三個問題判斷 assertion 是否有效">Assertion 品質三問&lt;/a>把 &lt;code>isNotNull&lt;/code>、&lt;code>isA&lt;/code> 列為人手寫測試裡&lt;strong>常見&lt;/strong>的無效斷言，&lt;a href="https://tarrragon.github.io/blog/testing/cases/ansi-parser-test-data-blindspot/" data-link-title="T.C3 ANSI parser 測試資料不覆蓋真實 shell output" data-link-desc="ANSI parser 只處理基本 SGR 色彩碼、unit test 用手寫乾淨字串驗證 — 真實 zsh prompt 送出 OSC 標題設定、CSI private mode 游標隱藏、括號貼上模式等數十種控制序列，全部殘留為亂碼">T.C3&lt;/a> 就是一個實例。變的是規模：套件小到可以逐條抽查時，這類斷言會在 review 裡被指出來；斷言可以整批產生之後抽查追不上，塌掉的是這個假設的可稽查性——它本身的正確性一直都只是近似。於是需要一個不靠抽查、而且用空斷言達不到的指標——&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/mutation-testing/" data-link-title="Mutation Testing（突變測試）" data-link-desc="行覆蓋率很高卻仍漏掉 bug、或要挑一個比覆蓋率難灌水的測試品質指標時，用來判斷這套測試實際擋得住什麼">突變測試&lt;/a>是目前唯一一種空斷言達不到的成熟指標——這個性質來自它的構造，不隨工具生態變動。&lt;/p>
&lt;h2 id="覆蓋率失效的形狀">覆蓋率失效的形狀&lt;/h2>
&lt;p>覆蓋率不是變得不準，是執行足跡與偵測能力之間的相關性斷了。一個測試呼叫函式、檢查它沒有拋出例外，覆蓋率把那幾行算進去；同一個測試對回傳值不做任何檢查，偵測能力是零。這種測試被稱為永遠綠的測試，它在覆蓋率報表上與一個嚴謹的測試無法區分。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/testing/cases/acceptance-passes-eight-different-programs/" data-link-title="T.C10 同一組驗收通過八個不同的程式 — 關卡放行的等價類有多大" data-link-desc="同一份需求重寫多次、產出的程式互不相同卻全部通過同一組驗收時，用來看清一組關卡實際固定住了哪些行為，又放過了哪些維度">T.C10&lt;/a> 的數據顯示了這個斷裂的程度：事後補測試、逐函式測試、以及 TDD 三法則（沒有失敗的測試就不寫 production code、只寫剛好會失敗的測試、只寫剛好讓它通過的實作）加上複雜度上限，這三種做法產出的程式與套件組成完全不同，行覆蓋率全部停在 97% 到 99%。被覆蓋率區分出來的只有兩端——零測試又不開複雜度上限那一版停在 14% 與 24%（前者是分支形式的覆蓋、後者是行覆蓋；同一組紀律開啟複雜度上限的那一列被迫生出 40 個範例、覆蓋率跟著跳上來），數字全部來自載入而非斷言；三法則不開複雜度上限的那一版停在 85.81% 行覆蓋，作者標為 outlier，成因是主控台迴圈只被輕度驅動。也就是說它分得出「有沒有測試」與「有沒有一整塊沒被驅動」，分不出「測試好不好」。分得出有無、分不出好壞。&lt;/p>
&lt;p>這給覆蓋率一個誠實的定位：它是下限指標。低覆蓋率確實代表有問題，高覆蓋率不代表沒問題，而把它當成通過條件會讓「達成它」變成最便宜路徑的目標。&lt;/p>
&lt;h2 id="突變分數量的是什麼">突變分數量的是什麼&lt;/h2>
&lt;p>突變測試對程式注入單點的行為改變——把 &lt;code>&amp;gt;&lt;/code> 換成 &lt;code>&amp;gt;=&lt;/code>、把回傳值換成常數、刪掉一次呼叫——再看測試會不會因此變紅。變紅稱為殺死，全綠稱為存活。存活的突變指出一件具體的事：程式在那個位置改掉行為，這套測試不會發現。&lt;/p>
&lt;p>突變分數（殺死數除以有效突變數）用&lt;strong>字面上的空斷言&lt;/strong>達不到：要殺死突變，斷言必須真的檢查到那個行為。這是它取代覆蓋率當閘門的理由——不是它更精確，是覆蓋率最便宜的那條達成路徑對它無效。&lt;/p>
&lt;p>但「低思考量」的路徑沒有被完全堵死，下一段的四條就是它們，其中兩條的思考量與空斷言同級。所以突變分數擋掉的是一種特定的作弊，不是所有作弊。&lt;/p>
&lt;p>它並沒有因此免疫於&lt;a href="https://tarrragon.github.io/blog/report/mechanical-constraints-buy-the-measured-number/" data-link-title="機械約束買到被量測的那個數字，代價落在沒被量測的維度" data-link-desc="用函式長度、圈複雜度、覆蓋率門檻這類可自動檢查的品質約束時，用來判斷達成它換到了什麼、又付出了什麼">機械約束的通病&lt;/a>。這件事在分數只是本機參考值時不會發生，一旦它變成 CI 的通過門檻或團隊報表上的一個數字，讓它上升就有了立即收益——而有立即收益的最便宜動作一定會發生。便宜的達成路徑至少有四條。前三條改在設定檔裡——縮小掃描範圍（只掃已經寫好測試的模組）、放寬等價突變的排除清單、削弱算子集（拿掉最會存活的那幾種突變）；它們不留痕跡於測試碼，所以設定檔要進版控，變更要跟分數變化一起看。&lt;/p>
&lt;p>第四條在測試碼裡，而且它是這裡最需要提防的一條：&lt;strong>錄製式斷言&lt;/strong>。把整個回傳值序列化後跟一份存下來的快照比對，思考量跟空斷言同級（不必想清楚哪個欄位重要），卻能一次殺死大量突變。它的判準是現狀而不是需求，也就是把整套測試變成&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/characterization-test/" data-link-title="Characterization Test" data-link-desc="斷言「行為不變」而非「行為正確」的測試形態；與正確性測試的語意分界，決定紅燈能不能歸因">行為快照&lt;/a>——分數很高而「這個行為對不對」一個字都沒回答。防它的方式不是禁用快照，是問&lt;strong>這份快照的預期值是誰決定的&lt;/strong>：由人審過的規格產生的合法，由實作跑一次產生的不合法。&lt;/p>
&lt;p>還有一個不算作弊、但會讓分數失真的來源：&lt;strong>例外導向的殺死&lt;/strong>。造成崩潰的突變連零斷言的 smoke test 都會紅，所以一套幾乎不做檢查的測試在多數專案也拿得到兩三成分數。看絕對分數之前先看這個基線。&lt;/p>
&lt;p>導入的成本結構跟其他測試層不同：每個突變都要重跑一次相關測試，總時間是突變數乘上測試時間。壓下來靠三件事：&lt;/p>
&lt;p>&lt;strong>只跑這次改動的檔案。&lt;/strong> 完整跑一次的代價通常只在夜間排程或發版前接受得了，日常回饋要用差異模式。&lt;/p>
&lt;p>&lt;strong>用覆蓋率資料跳過沒被執行到的突變。&lt;/strong> 沒有任何測試執行到的程式碼，突變一定存活，跑它只是浪費——這是覆蓋率在新配置下仍然有用的地方：它變成突變測試的輸入而不是閘門本身。&lt;/p>
&lt;p>&lt;strong>平行執行並設定逾時。&lt;/strong> 有些突變會造成無窮迴圈，逾時要設得夠短，否則單一突變就吃掉整輪時間。&lt;/p>
&lt;p>覆蓋率與偵測能力脫鉤這件事另有站內既有內容佐證（本章開頭引的 Assertion 品質三問與 T.C3），不單靠那份實驗；實驗本身的射程見&lt;a href="https://tarrragon.github.io/blog/testing/06-agent-authored-code/" data-link-title="模組六：Agent 產出程式碼的驗證" data-link-desc="程式碼由 agent 寫、人不再逐行讀時，用來決定驗證該落在哪些位置，以及每個位置的射程到哪裡">模組入口&lt;/a>。&lt;/p>
&lt;h2 id="存活的突變分兩類">存活的突變分兩類&lt;/h2>
&lt;p>處理方式相反，判錯了會傷到測試套件。&lt;/p>
&lt;p>&lt;strong>真的漏洞。&lt;/strong> 那個位置的行為改變確實沒有被任何斷言檢查到，補一條斷言。這是多數情形。&lt;/p>
&lt;p>&lt;strong>等價突變。&lt;/strong> 改寫之後行為完全相同。典型形態有兩種：一是等價的邊界寫法（&lt;code>i &amp;lt; n&lt;/code> 換成 &lt;code>i &amp;lt;= n - 1&lt;/code>），二是對結果沒有影響的短路順序（兩個都是純函式的條件互換前後）。這是工具的限制，要標記排除。為等價突變硬補斷言的代價是那條斷言必然綁在實作的寫法上，反過來傷害重構安全性，與&lt;a href="https://tarrragon.github.io/blog/record/behavior-first-tdd-methodology/" data-link-title="行為優先的 TDD：測試耦合行為、不耦合結構" data-link-desc="重構時測試大量壞掉、或在「該不該 mock 這個協作者」上需要一個可執行的立場時，本站對兩派 TDD 分歧的選擇與推導">行為優先的 TDD&lt;/a> 要守的性質直接衝突。&lt;/p>
&lt;p>判斷方式是問這個突變如果留在正式版本，有沒有任何一種輸入會讓使用者觀察到不同的結果。答案是沒有，它就是等價突變。&lt;/p>
&lt;p>這個問題形式上可執行、實務上判不出來（等價性一般而言不可判定），所以要配一套不靠判準的處置紀律：單一突變的判定給一個時間盒（十五分鐘是個起始值，依領域邏輯的密度自行調整——密集的規則引擎值得久一點，膠水層更短），時間盒到了預設歸類為&lt;strong>真漏洞&lt;/strong>而非等價突變（誤判成漏洞的代價是多寫一條斷言，誤判成等價的代價是永久失去一個偵測點）、排除清單的每一條要寫下「想不出區分輸入」的理由並具名，且由第二個人覆核。單人維護的專案沒有第二個人，這一項退化成定期回看自己的排除清單——此時清單的成長速度是唯一還在運作的訊號——它比程式碼成長得快時，判定已經放水了。&lt;/p>
&lt;h2 id="突變分數守不住什麼">突變分數守不住什麼&lt;/h2>
&lt;p>這是導入時最需要先講清楚的一件事：&lt;strong>突變是對照著程式現在的樣子產生的&lt;/strong>，所以它守得住「斷言不夠密」，守不住「這段程式一開始就實作錯了需求」。&lt;/p>
&lt;p>把危險判定順序寫反的那份程式跑突變測試，長出來的斷言會把那個順序釘得更牢（機制推論，非實驗觀察）。那份實驗實際記載的是：突變長出來的第二套測試補的全是運算子層的翻轉，作者的結論是它「沒有發現一個不同的遊戲」。&lt;/p>
&lt;p>這個限制在測試與實作&lt;a href="https://tarrragon.github.io/blog/testing/06-agent-authored-code/test-provenance-independence/" data-link-title="判準的推導來源：測試憑什麼不是實作的回音" data-link-desc="測試與實作由同一次生成或同一輪對話產出時，用來判斷這組測試剩下多少驗證力，以及要動哪個變數才能拿回來">出處不獨立&lt;/a>時特別要緊，因為那正是規格層錯誤最容易產生的情境。突變分數 100% 的套件仍然可以整套建立在一個錯誤的需求理解上。&lt;/p>
&lt;h2 id="閘門要成對設計">閘門要成對設計&lt;/h2>
&lt;p>每個機械閘門都有一個它量不到的維度，而達成該閘門最便宜的路徑通常就走在那個維度上。配對的檢查點要一起設，否則閘門會被最便宜的方式滿足。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>閘門&lt;/th>
 &lt;th>它量得到&lt;/th>
 &lt;th>它量不到&lt;/th>
 &lt;th>配對要補的那件事（只有第一列可自動化，其餘三列是人的判準設計）&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>行覆蓋率&lt;/td>
 &lt;td>執行足跡&lt;/td>
 &lt;td>斷言強度&lt;/td>
 &lt;td>突變分數&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>突變分數&lt;/td>
 &lt;td>對現有程式的偵測能力&lt;/td>
 &lt;td>需求是否被正確實作&lt;/td>
 &lt;td>攤開條件的維度表找沒有被碰過的交叉（&lt;a href="https://tarrragon.github.io/blog/testing/06-agent-authored-code/acceptance-equivalence-class/" data-link-title="驗收條件的等價類：關卡實際固定住了什麼" data-link-desc="一組驗收條件通過就準備交付、卻說不出它放過了哪些行為時，用來攤出條件的維度、找沒被碰過的交叉並決定補哪一條">驗收條件的等價類&lt;/a>），並確認條件的出處與實作獨立&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>複雜度上限&lt;/td>
 &lt;td>單一函式的分支數&lt;/td>
 &lt;td>拆出來的名字還指不指涉一個完整的領域概念&lt;/td>
 &lt;td>逐個新名字問它對應到哪個業務動作，對不到的是切碎&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>測試數量&lt;/td>
 &lt;td>套件規模&lt;/td>
 &lt;td>涵蓋了幾個維度&lt;/td>
 &lt;td>攤開維度表數空格（&lt;a href="https://tarrragon.github.io/blog/testing/06-agent-authored-code/acceptance-equivalence-class/" data-link-title="驗收條件的等價類：關卡實際固定住了什麼" data-link-desc="一組驗收條件通過就準備交付、卻說不出它放過了哪些行為時，用來攤出條件的維度、找沒被碰過的交叉並決定補哪一條">驗收條件的等價類&lt;/a>）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/cyclomatic-complexity/" data-link-title="Cyclomatic Complexity（圈複雜度）" data-link-desc="看到函式長度或複雜度上限這類數字門檻時，用來判斷它量的是什麼、以及達成它的兩條路徑為什麼在指標上分不出來">複雜度上限&lt;/a>那一列有實測的代價可以參考：同一份實驗在程式寫完後強制把圈複雜度壓到 3 以下，四個版本的函式數大致翻倍、覆蓋率上升到 97% 以上，而原作者給的可讀性評分全部落在最低的兩級，設計評分一次都沒有上升。他的描述是這個約束「不做簡化，只是繁殖名字」。這條的完整推導在 &lt;a href="https://tarrragon.github.io/blog/report/mechanical-constraints-buy-the-measured-number/" data-link-title="機械約束買到被量測的那個數字，代價落在沒被量測的維度" data-link-desc="用函式長度、圈複雜度、覆蓋率門檻這類可自動檢查的品質約束時，用來判斷達成它換到了什麼、又付出了什麼">#278&lt;/a>。&lt;/p>
&lt;h2 id="兩個前置條件">兩個前置條件&lt;/h2>
&lt;p>這一章的作法有兩個硬前提，任一個不成立時整套跑不起來，而它們不會在文件裡自己現身。&lt;/p>
&lt;p>&lt;strong>測試套件要是確定性的。&lt;/strong> 突變測試對 flaky test 極度敏感：一個隨機紅燈會被記成「殺死突變」，分數因此變成噪音、而且是往上飄的噪音。導入之前要先把已知的不穩定測試&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/quarantine/" data-link-title="Quarantine（隔離）" data-link-desc="把已知 flaky 的測試從主要執行路徑移開、保留修復壓力的治理機制；與 skip 的語意差異在於 quarantine 有負責人和回收期限">隔離&lt;/a>出去，並確認隔離清單不在掃描範圍內。&lt;/p>
&lt;p>&lt;strong>該語言要有一個跑得動的突變工具。&lt;/strong> 工具的成熟度分語言差距很大（寫作當下 Java、JavaScript 與 Python 各有生產可用的實作，而包含 Dart 在內的不少語言還沒有），而這個生態逐年變動——導入前查一次該語言的現行實作，不要拿本文的舉例當現況。工具不存在時的替代品是&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/property-based-testing/" data-link-title="Property-Based Testing（性質式測試）" data-link-desc="逐例斷言列不完、或預期輸出難以逐例算出時，用來把驗證對象從個別例子換成對所有輸入都該成立的性質">性質式測試&lt;/a>加上人工的斷言強度抽查，不是降級回覆蓋率。&lt;/p>
&lt;h2 id="導入順序">導入順序&lt;/h2>
&lt;p>先有可信的判準，再談分數。順序顛倒的話，得到的是一套把錯誤需求釘得很牢的高分測試。&lt;/p></description><content:encoded><![CDATA[<p>品質閘門要量的是測試的偵測能力：程式在某個位置改掉行為時，這套測試會不會變紅。行覆蓋率量的是執行足跡，它與偵測能力之間隔著一個假設——執行到了通常也就斷言了。</p>
<p>這個假設從來沒有完全成立。站內的 <a href="/blog/testing/05-test-design-judgment/assertion-quality/" data-link-title="Assertion 品質三問" data-link-desc="斷言的是行為嗎？能區分正確和錯誤嗎？會 flaky 嗎？— 三個問題判斷 assertion 是否有效">Assertion 品質三問</a>把 <code>isNotNull</code>、<code>isA</code> 列為人手寫測試裡<strong>常見</strong>的無效斷言，<a href="/blog/testing/cases/ansi-parser-test-data-blindspot/" data-link-title="T.C3 ANSI parser 測試資料不覆蓋真實 shell output" data-link-desc="ANSI parser 只處理基本 SGR 色彩碼、unit test 用手寫乾淨字串驗證 — 真實 zsh prompt 送出 OSC 標題設定、CSI private mode 游標隱藏、括號貼上模式等數十種控制序列，全部殘留為亂碼">T.C3</a> 就是一個實例。變的是規模：套件小到可以逐條抽查時，這類斷言會在 review 裡被指出來；斷言可以整批產生之後抽查追不上，塌掉的是這個假設的可稽查性——它本身的正確性一直都只是近似。於是需要一個不靠抽查、而且用空斷言達不到的指標——<a href="/blog/testing/knowledge-cards/mutation-testing/" data-link-title="Mutation Testing（突變測試）" data-link-desc="行覆蓋率很高卻仍漏掉 bug、或要挑一個比覆蓋率難灌水的測試品質指標時，用來判斷這套測試實際擋得住什麼">突變測試</a>是目前唯一一種空斷言達不到的成熟指標——這個性質來自它的構造，不隨工具生態變動。</p>
<h2 id="覆蓋率失效的形狀">覆蓋率失效的形狀</h2>
<p>覆蓋率不是變得不準，是執行足跡與偵測能力之間的相關性斷了。一個測試呼叫函式、檢查它沒有拋出例外，覆蓋率把那幾行算進去；同一個測試對回傳值不做任何檢查，偵測能力是零。這種測試被稱為永遠綠的測試，它在覆蓋率報表上與一個嚴謹的測試無法區分。</p>
<p><a href="/blog/testing/cases/acceptance-passes-eight-different-programs/" data-link-title="T.C10 同一組驗收通過八個不同的程式 — 關卡放行的等價類有多大" data-link-desc="同一份需求重寫多次、產出的程式互不相同卻全部通過同一組驗收時，用來看清一組關卡實際固定住了哪些行為，又放過了哪些維度">T.C10</a> 的數據顯示了這個斷裂的程度：事後補測試、逐函式測試、以及 TDD 三法則（沒有失敗的測試就不寫 production code、只寫剛好會失敗的測試、只寫剛好讓它通過的實作）加上複雜度上限，這三種做法產出的程式與套件組成完全不同，行覆蓋率全部停在 97% 到 99%。被覆蓋率區分出來的只有兩端——零測試又不開複雜度上限那一版停在 14% 與 24%（前者是分支形式的覆蓋、後者是行覆蓋；同一組紀律開啟複雜度上限的那一列被迫生出 40 個範例、覆蓋率跟著跳上來），數字全部來自載入而非斷言；三法則不開複雜度上限的那一版停在 85.81% 行覆蓋，作者標為 outlier，成因是主控台迴圈只被輕度驅動。也就是說它分得出「有沒有測試」與「有沒有一整塊沒被驅動」，分不出「測試好不好」。分得出有無、分不出好壞。</p>
<p>這給覆蓋率一個誠實的定位：它是下限指標。低覆蓋率確實代表有問題，高覆蓋率不代表沒問題，而把它當成通過條件會讓「達成它」變成最便宜路徑的目標。</p>
<h2 id="突變分數量的是什麼">突變分數量的是什麼</h2>
<p>突變測試對程式注入單點的行為改變——把 <code>&gt;</code> 換成 <code>&gt;=</code>、把回傳值換成常數、刪掉一次呼叫——再看測試會不會因此變紅。變紅稱為殺死，全綠稱為存活。存活的突變指出一件具體的事：程式在那個位置改掉行為，這套測試不會發現。</p>
<p>突變分數（殺死數除以有效突變數）用<strong>字面上的空斷言</strong>達不到：要殺死突變，斷言必須真的檢查到那個行為。這是它取代覆蓋率當閘門的理由——不是它更精確，是覆蓋率最便宜的那條達成路徑對它無效。</p>
<p>但「低思考量」的路徑沒有被完全堵死，下一段的四條就是它們，其中兩條的思考量與空斷言同級。所以突變分數擋掉的是一種特定的作弊，不是所有作弊。</p>
<p>它並沒有因此免疫於<a href="/blog/report/mechanical-constraints-buy-the-measured-number/" data-link-title="機械約束買到被量測的那個數字，代價落在沒被量測的維度" data-link-desc="用函式長度、圈複雜度、覆蓋率門檻這類可自動檢查的品質約束時，用來判斷達成它換到了什麼、又付出了什麼">機械約束的通病</a>。這件事在分數只是本機參考值時不會發生，一旦它變成 CI 的通過門檻或團隊報表上的一個數字，讓它上升就有了立即收益——而有立即收益的最便宜動作一定會發生。便宜的達成路徑至少有四條。前三條改在設定檔裡——縮小掃描範圍（只掃已經寫好測試的模組）、放寬等價突變的排除清單、削弱算子集（拿掉最會存活的那幾種突變）；它們不留痕跡於測試碼，所以設定檔要進版控，變更要跟分數變化一起看。</p>
<p>第四條在測試碼裡，而且它是這裡最需要提防的一條：<strong>錄製式斷言</strong>。把整個回傳值序列化後跟一份存下來的快照比對，思考量跟空斷言同級（不必想清楚哪個欄位重要），卻能一次殺死大量突變。它的判準是現狀而不是需求，也就是把整套測試變成<a href="/blog/testing/knowledge-cards/characterization-test/" data-link-title="Characterization Test" data-link-desc="斷言「行為不變」而非「行為正確」的測試形態；與正確性測試的語意分界，決定紅燈能不能歸因">行為快照</a>——分數很高而「這個行為對不對」一個字都沒回答。防它的方式不是禁用快照，是問<strong>這份快照的預期值是誰決定的</strong>：由人審過的規格產生的合法，由實作跑一次產生的不合法。</p>
<p>還有一個不算作弊、但會讓分數失真的來源：<strong>例外導向的殺死</strong>。造成崩潰的突變連零斷言的 smoke test 都會紅，所以一套幾乎不做檢查的測試在多數專案也拿得到兩三成分數。看絕對分數之前先看這個基線。</p>
<p>導入的成本結構跟其他測試層不同：每個突變都要重跑一次相關測試，總時間是突變數乘上測試時間。壓下來靠三件事：</p>
<p><strong>只跑這次改動的檔案。</strong> 完整跑一次的代價通常只在夜間排程或發版前接受得了，日常回饋要用差異模式。</p>
<p><strong>用覆蓋率資料跳過沒被執行到的突變。</strong> 沒有任何測試執行到的程式碼，突變一定存活，跑它只是浪費——這是覆蓋率在新配置下仍然有用的地方：它變成突變測試的輸入而不是閘門本身。</p>
<p><strong>平行執行並設定逾時。</strong> 有些突變會造成無窮迴圈，逾時要設得夠短，否則單一突變就吃掉整輪時間。</p>
<p>覆蓋率與偵測能力脫鉤這件事另有站內既有內容佐證（本章開頭引的 Assertion 品質三問與 T.C3），不單靠那份實驗；實驗本身的射程見<a href="/blog/testing/06-agent-authored-code/" data-link-title="模組六：Agent 產出程式碼的驗證" data-link-desc="程式碼由 agent 寫、人不再逐行讀時，用來決定驗證該落在哪些位置，以及每個位置的射程到哪裡">模組入口</a>。</p>
<h2 id="存活的突變分兩類">存活的突變分兩類</h2>
<p>處理方式相反，判錯了會傷到測試套件。</p>
<p><strong>真的漏洞。</strong> 那個位置的行為改變確實沒有被任何斷言檢查到，補一條斷言。這是多數情形。</p>
<p><strong>等價突變。</strong> 改寫之後行為完全相同。典型形態有兩種：一是等價的邊界寫法（<code>i &lt; n</code> 換成 <code>i &lt;= n - 1</code>），二是對結果沒有影響的短路順序（兩個都是純函式的條件互換前後）。這是工具的限制，要標記排除。為等價突變硬補斷言的代價是那條斷言必然綁在實作的寫法上，反過來傷害重構安全性，與<a href="/blog/record/behavior-first-tdd-methodology/" data-link-title="行為優先的 TDD：測試耦合行為、不耦合結構" data-link-desc="重構時測試大量壞掉、或在「該不該 mock 這個協作者」上需要一個可執行的立場時，本站對兩派 TDD 分歧的選擇與推導">行為優先的 TDD</a> 要守的性質直接衝突。</p>
<p>判斷方式是問這個突變如果留在正式版本，有沒有任何一種輸入會讓使用者觀察到不同的結果。答案是沒有，它就是等價突變。</p>
<p>這個問題形式上可執行、實務上判不出來（等價性一般而言不可判定），所以要配一套不靠判準的處置紀律：單一突變的判定給一個時間盒（十五分鐘是個起始值，依領域邏輯的密度自行調整——密集的規則引擎值得久一點，膠水層更短），時間盒到了預設歸類為<strong>真漏洞</strong>而非等價突變（誤判成漏洞的代價是多寫一條斷言，誤判成等價的代價是永久失去一個偵測點）、排除清單的每一條要寫下「想不出區分輸入」的理由並具名，且由第二個人覆核。單人維護的專案沒有第二個人，這一項退化成定期回看自己的排除清單——此時清單的成長速度是唯一還在運作的訊號——它比程式碼成長得快時，判定已經放水了。</p>
<h2 id="突變分數守不住什麼">突變分數守不住什麼</h2>
<p>這是導入時最需要先講清楚的一件事：<strong>突變是對照著程式現在的樣子產生的</strong>，所以它守得住「斷言不夠密」，守不住「這段程式一開始就實作錯了需求」。</p>
<p>把危險判定順序寫反的那份程式跑突變測試，長出來的斷言會把那個順序釘得更牢（機制推論，非實驗觀察）。那份實驗實際記載的是：突變長出來的第二套測試補的全是運算子層的翻轉，作者的結論是它「沒有發現一個不同的遊戲」。</p>
<p>這個限制在測試與實作<a href="/blog/testing/06-agent-authored-code/test-provenance-independence/" data-link-title="判準的推導來源：測試憑什麼不是實作的回音" data-link-desc="測試與實作由同一次生成或同一輪對話產出時，用來判斷這組測試剩下多少驗證力，以及要動哪個變數才能拿回來">出處不獨立</a>時特別要緊，因為那正是規格層錯誤最容易產生的情境。突變分數 100% 的套件仍然可以整套建立在一個錯誤的需求理解上。</p>
<h2 id="閘門要成對設計">閘門要成對設計</h2>
<p>每個機械閘門都有一個它量不到的維度，而達成該閘門最便宜的路徑通常就走在那個維度上。配對的檢查點要一起設，否則閘門會被最便宜的方式滿足。</p>
<table>
  <thead>
      <tr>
          <th>閘門</th>
          <th>它量得到</th>
          <th>它量不到</th>
          <th>配對要補的那件事（只有第一列可自動化，其餘三列是人的判準設計）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>行覆蓋率</td>
          <td>執行足跡</td>
          <td>斷言強度</td>
          <td>突變分數</td>
      </tr>
      <tr>
          <td>突變分數</td>
          <td>對現有程式的偵測能力</td>
          <td>需求是否被正確實作</td>
          <td>攤開條件的維度表找沒有被碰過的交叉（<a href="/blog/testing/06-agent-authored-code/acceptance-equivalence-class/" data-link-title="驗收條件的等價類：關卡實際固定住了什麼" data-link-desc="一組驗收條件通過就準備交付、卻說不出它放過了哪些行為時，用來攤出條件的維度、找沒被碰過的交叉並決定補哪一條">驗收條件的等價類</a>），並確認條件的出處與實作獨立</td>
      </tr>
      <tr>
          <td>複雜度上限</td>
          <td>單一函式的分支數</td>
          <td>拆出來的名字還指不指涉一個完整的領域概念</td>
          <td>逐個新名字問它對應到哪個業務動作，對不到的是切碎</td>
      </tr>
      <tr>
          <td>測試數量</td>
          <td>套件規模</td>
          <td>涵蓋了幾個維度</td>
          <td>攤開維度表數空格（<a href="/blog/testing/06-agent-authored-code/acceptance-equivalence-class/" data-link-title="驗收條件的等價類：關卡實際固定住了什麼" data-link-desc="一組驗收條件通過就準備交付、卻說不出它放過了哪些行為時，用來攤出條件的維度、找沒被碰過的交叉並決定補哪一條">驗收條件的等價類</a>）</td>
      </tr>
  </tbody>
</table>
<p><a href="/blog/testing/knowledge-cards/cyclomatic-complexity/" data-link-title="Cyclomatic Complexity（圈複雜度）" data-link-desc="看到函式長度或複雜度上限這類數字門檻時，用來判斷它量的是什麼、以及達成它的兩條路徑為什麼在指標上分不出來">複雜度上限</a>那一列有實測的代價可以參考：同一份實驗在程式寫完後強制把圈複雜度壓到 3 以下，四個版本的函式數大致翻倍、覆蓋率上升到 97% 以上，而原作者給的可讀性評分全部落在最低的兩級，設計評分一次都沒有上升。他的描述是這個約束「不做簡化，只是繁殖名字」。這條的完整推導在 <a href="/blog/report/mechanical-constraints-buy-the-measured-number/" data-link-title="機械約束買到被量測的那個數字，代價落在沒被量測的維度" data-link-desc="用函式長度、圈複雜度、覆蓋率門檻這類可自動檢查的品質約束時，用來判斷達成它換到了什麼、又付出了什麼">#278</a>。</p>
<h2 id="兩個前置條件">兩個前置條件</h2>
<p>這一章的作法有兩個硬前提，任一個不成立時整套跑不起來，而它們不會在文件裡自己現身。</p>
<p><strong>測試套件要是確定性的。</strong> 突變測試對 flaky test 極度敏感：一個隨機紅燈會被記成「殺死突變」，分數因此變成噪音、而且是往上飄的噪音。導入之前要先把已知的不穩定測試<a href="/blog/testing/knowledge-cards/quarantine/" data-link-title="Quarantine（隔離）" data-link-desc="把已知 flaky 的測試從主要執行路徑移開、保留修復壓力的治理機制；與 skip 的語意差異在於 quarantine 有負責人和回收期限">隔離</a>出去，並確認隔離清單不在掃描範圍內。</p>
<p><strong>該語言要有一個跑得動的突變工具。</strong> 工具的成熟度分語言差距很大（寫作當下 Java、JavaScript 與 Python 各有生產可用的實作，而包含 Dart 在內的不少語言還沒有），而這個生態逐年變動——導入前查一次該語言的現行實作，不要拿本文的舉例當現況。工具不存在時的替代品是<a href="/blog/testing/knowledge-cards/property-based-testing/" data-link-title="Property-Based Testing（性質式測試）" data-link-desc="逐例斷言列不完、或預期輸出難以逐例算出時，用來把驗證對象從個別例子換成對所有輸入都該成立的性質">性質式測試</a>加上人工的斷言強度抽查，不是降級回覆蓋率。</p>
<h2 id="導入順序">導入順序</h2>
<p>先有可信的判準，再談分數。順序顛倒的話，得到的是一套把錯誤需求釘得很牢的高分測試。</p>
<ol>
<li>驗收條件出處獨立、且做過維度交叉盤點</li>
<li>覆蓋率降級成輸入而非閘門（餵給突變測試用來跳過未執行的位置）</li>
<li>突變測試先在核心領域邏輯上跑，不要一開始就全庫</li>
<li>門檻從現況起跳、只要求不倒退，不要一開始就訂絕對值</li>
<li>等價突變的排除清單納入版控，並定期回顧——排除清單長得太快是判斷太寬鬆的訊號</li>
</ol>
<p>這五步自己也是機械約束——<a href="/blog/report/mechanical-constraints-buy-the-measured-number/" data-link-title="機械約束買到被量測的那個數字，代價落在沒被量測的維度" data-link-desc="用函式長度、圈複雜度、覆蓋率門檻這類可自動檢查的品質約束時，用來判斷達成它換到了什麼、又付出了什麼">#278</a> 要求每個機械約束配一個它量不到的維度，而模組入口的「判定問題的答案要留下痕跡」那一節給的是本模組共用的配對形式。這裡只補一件它特有的：門檻採「只要求不倒退」時，最省力的達成是讓新程式落在掃描範圍的 glob 之外，所以<strong>納入率要跟分數並列著看</strong>，兩個數字缺一個就讀不出真相。</p>
<p>這五步預設閘門由團隊自己決定。<strong>門檻是外部義務時——寫進合約的覆蓋率下限、監理或內稽要求的指標——作法是疊加而不是替換</strong>：外部門檻照舊守著，突變分數當內部指標另外跑。有一件事值得寫進驗收流程或合約附件：覆蓋率達標可以用空斷言達成，所以它不構成驗收通過的充分條件；而要讓這句話有效力，它得寫在有約束力的地方，那已經超出這一章能處理的層。同樣的分辨也適用於逐行閱讀——code review 在受監理環境常是變更管理控制項，存在理由是留下具名的責任證據與四眼原則，跟偵測效率無關，那種 review 不能因為「效率低」而移除。</p>
<p>第三點的範圍選擇有實際差別：領域邏輯的突變幾乎都有意義，而 I/O 膠水層、序列化樣板、設定讀取的突變多半是等價的或無關緊要的，先跑那些會讓排除清單暴增而收益很低。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>覆蓋率維持在 90% 以上而故障落在已覆蓋的程式碼裡</td>
          <td>斷言只檢查到有沒有跑完，用突變分數量一次偵測能力</td>
      </tr>
      <tr>
          <td>測試數量大增而信心沒有跟著上升</td>
          <td>量的是規模不是偵測能力，換指標</td>
      </tr>
      <tr>
          <td>突變分數很高而需求層的錯誤還是漏出去</td>
          <td>突變對規格層的錯誤不產生訊號，要補的是出處獨立的驗收條件不是更多突變</td>
      </tr>
      <tr>
          <td>等價突變排除清單快速變長</td>
          <td>判斷太寬鬆，逐條問「留在正式版有沒有輸入看得出差別」</td>
      </tr>
      <tr>
          <td>突變測試跑一次要好幾小時</td>
          <td>改差異模式、用覆蓋率跳過未執行位置、縮短逾時</td>
      </tr>
      <tr>
          <td>新增一條機械閘門而沒指定它量不到什麼</td>
          <td>補上配對檢查點，否則閘門會被最便宜的達成路徑滿足</td>
      </tr>
      <tr>
          <td>「所有指標都綠了」被當成品質改善的結論</td>
          <td>指標描述的是外觀量，要宣稱品質改善需要獨立於該指標的說法</td>
      </tr>
  </tbody>
</table>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>判準本身怎麼取得獨立性 → <a href="/blog/testing/06-agent-authored-code/test-provenance-independence/" data-link-title="判準的推導來源：測試憑什麼不是實作的回音" data-link-desc="測試與實作由同一次生成或同一輪對話產出時，用來判斷這組測試剩下多少驗證力，以及要動哪個變數才能拿回來">判準的推導來源</a></li>
<li>驗收條件的射程怎麼量 → <a href="/blog/testing/06-agent-authored-code/acceptance-equivalence-class/" data-link-title="驗收條件的等價類：關卡實際固定住了什麼" data-link-desc="一組驗收條件通過就準備交付、卻說不出它放過了哪些行為時，用來攤出條件的維度、找沒被碰過的交叉並決定補哪一條">驗收條件的等價類</a></li>
<li>預期值連寫都寫不出來時怎麼辦 → <a href="/blog/testing/06-agent-authored-code/oracle-beyond-examples/" data-link-title="判準寫不下來的時候：性質、變形關係與留給人的部分" data-link-desc="逐例預期值算不出來或跟不上產出速度時，用來決定判準退到哪一層、以及哪些驗證工作交不出去">判準寫不下來的時候</a></li>
<li>術語卡 → <a href="/blog/testing/knowledge-cards/mutation-testing/" data-link-title="Mutation Testing（突變測試）" data-link-desc="行覆蓋率很高卻仍漏掉 bug、或要挑一個比覆蓋率難灌水的測試品質指標時，用來判斷這套測試實際擋得住什麼">Mutation Testing</a></li>
<li>機械約束的代價 → <a href="/blog/report/mechanical-constraints-buy-the-measured-number/" data-link-title="機械約束買到被量測的那個數字，代價落在沒被量測的維度" data-link-desc="用函式長度、圈複雜度、覆蓋率門檻這類可自動檢查的品質約束時，用來判斷達成它換到了什麼、又付出了什麼">#278 機械約束買到被量測的那個數字</a></li>
<li>斷言本身怎麼寫才有鑑別力 → <a href="/blog/testing/05-test-design-judgment/assertion-quality/" data-link-title="Assertion 品質三問" data-link-desc="斷言的是行為嗎？能區分正確和錯誤嗎？會 flaky 嗎？— 三個問題判斷 assertion 是否有效">Assertion 品質三問</a></li>
</ul>
]]></content:encoded></item><item><title>Cyclomatic Complexity（圈複雜度）</title><link>https://tarrragon.github.io/blog/testing/knowledge-cards/cyclomatic-complexity/</link><pubDate>Mon, 24 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/testing/knowledge-cards/cyclomatic-complexity/</guid><description>&lt;p>圈複雜度數的是一段程式碼裡線性獨立的執行路徑有幾條，實作上等於分支點的數量加一——每個 &lt;code>if&lt;/code>、每個迴圈條件、每個 &lt;code>case&lt;/code>、每個布林運算子的短路點各加一。它的原始用途是估算「要完全覆蓋這段程式需要幾個測試案例」，所以它跟&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/test-oracle/" data-link-title="Test Oracle（測試判準來源）" data-link-desc="一個測試憑什麼判定通過或失敗說不清楚、或被測對象沒有可逐例算出的正確答案時，用來定位判準的來源，以及每一種來源的射程">測試判準&lt;/a>是同一個問題的兩端：複雜度給出案例數的下界，判準決定每個案例斷言什麼。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>它量的是&lt;strong>單一函式的分支數&lt;/strong>，不量概念的完整性，也不量函式之間的耦合——跟&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/mutation-testing/" data-link-title="Mutation Testing（突變測試）" data-link-desc="行覆蓋率很高卻仍漏掉 bug、或要挑一個比覆蓋率難灌水的測試品質指標時，用來判斷這套測試實際擋得住什麼">突變分數&lt;/a>的分工很清楚：這裡數的是路徑有幾條，那裡數的是那些路徑上的斷言擋不擋得住行為改變。這個射程決定了它當品質閘門時的行為：把一個十分支的函式切成四個各三分支的函式，總分支數不變而每個函式都合規——指標下降而閱讀時要重建的脈絡變多。&lt;a href="https://tarrragon.github.io/blog/report/mechanical-constraints-buy-the-measured-number/" data-link-title="機械約束買到被量測的那個數字，代價落在沒被量測的維度" data-link-desc="用函式長度、圈複雜度、覆蓋率門檻這類可自動檢查的品質約束時，用來判斷達成它換到了什麼、又付出了什麼">機械約束買到被量測的那個數字&lt;/a>那張卡用的正是這個度量當實例。&lt;/p>
&lt;p>CRAP 度量把它跟覆蓋率合起來：&lt;code>CRAP(m) = C²(1 − cov)³ + C&lt;/code>，其中 &lt;code>C&lt;/code> 是圈複雜度、&lt;code>cov&lt;/code> 是該函式的覆蓋率。滿覆蓋率時 CRAP 等於圈複雜度本身，所以「CRAP 低於 4」等價於「每個函式的複雜度不超過 3」。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>一個 &lt;code>if&lt;/code> 加一個 &lt;code>for&lt;/code> 加一個三元運算子的函式，複雜度是 4，完全覆蓋它至少要四個案例。同一段邏輯改用查表取代分支，複雜度會掉到 1 而行為不變——這是它與「這段程式難不難懂」脫鉤的地方：查表版可能更難懂，指標卻好看得多。&lt;/p>
&lt;p>門檻設在 10 上下是工具的常見預設，出處是原始論文的建議值而不是實證；設在 3 是特定實驗的參數，不是通用建議。看到一個複雜度門檻時要先問這個數字從哪來。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>拿它當閘門的人要同時指定它量不到的那個維度該由誰檢查。可機械執行的是&lt;strong>觸發&lt;/strong>——每個新產生的函式名都被問一次；判定那個名字對不對得回一個領域概念，仍然是人的工作，可以要求它在需求文件或領域詞彙表裡找得到對應。沒有配對這一步時，最便宜的達成方式（把違規處切碎）與真正的分解在指標上完全一樣。&lt;/p></description><content:encoded><![CDATA[<p>圈複雜度數的是一段程式碼裡線性獨立的執行路徑有幾條，實作上等於分支點的數量加一——每個 <code>if</code>、每個迴圈條件、每個 <code>case</code>、每個布林運算子的短路點各加一。它的原始用途是估算「要完全覆蓋這段程式需要幾個測試案例」，所以它跟<a href="/blog/testing/knowledge-cards/test-oracle/" data-link-title="Test Oracle（測試判準來源）" data-link-desc="一個測試憑什麼判定通過或失敗說不清楚、或被測對象沒有可逐例算出的正確答案時，用來定位判準的來源，以及每一種來源的射程">測試判準</a>是同一個問題的兩端：複雜度給出案例數的下界，判準決定每個案例斷言什麼。</p>
<h2 id="概念位置">概念位置</h2>
<p>它量的是<strong>單一函式的分支數</strong>，不量概念的完整性，也不量函式之間的耦合——跟<a href="/blog/testing/knowledge-cards/mutation-testing/" data-link-title="Mutation Testing（突變測試）" data-link-desc="行覆蓋率很高卻仍漏掉 bug、或要挑一個比覆蓋率難灌水的測試品質指標時，用來判斷這套測試實際擋得住什麼">突變分數</a>的分工很清楚：這裡數的是路徑有幾條，那裡數的是那些路徑上的斷言擋不擋得住行為改變。這個射程決定了它當品質閘門時的行為：把一個十分支的函式切成四個各三分支的函式，總分支數不變而每個函式都合規——指標下降而閱讀時要重建的脈絡變多。<a href="/blog/report/mechanical-constraints-buy-the-measured-number/" data-link-title="機械約束買到被量測的那個數字，代價落在沒被量測的維度" data-link-desc="用函式長度、圈複雜度、覆蓋率門檻這類可自動檢查的品質約束時，用來判斷達成它換到了什麼、又付出了什麼">機械約束買到被量測的那個數字</a>那張卡用的正是這個度量當實例。</p>
<p>CRAP 度量把它跟覆蓋率合起來：<code>CRAP(m) = C²(1 − cov)³ + C</code>，其中 <code>C</code> 是圈複雜度、<code>cov</code> 是該函式的覆蓋率。滿覆蓋率時 CRAP 等於圈複雜度本身，所以「CRAP 低於 4」等價於「每個函式的複雜度不超過 3」。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>一個 <code>if</code> 加一個 <code>for</code> 加一個三元運算子的函式，複雜度是 4，完全覆蓋它至少要四個案例。同一段邏輯改用查表取代分支，複雜度會掉到 1 而行為不變——這是它與「這段程式難不難懂」脫鉤的地方：查表版可能更難懂，指標卻好看得多。</p>
<p>門檻設在 10 上下是工具的常見預設，出處是原始論文的建議值而不是實證；設在 3 是特定實驗的參數，不是通用建議。看到一個複雜度門檻時要先問這個數字從哪來。</p>
<h2 id="設計責任">設計責任</h2>
<p>拿它當閘門的人要同時指定它量不到的那個維度該由誰檢查。可機械執行的是<strong>觸發</strong>——每個新產生的函式名都被問一次；判定那個名字對不對得回一個領域概念，仍然是人的工作，可以要求它在需求文件或領域詞彙表裡找得到對應。沒有配對這一步時，最便宜的達成方式（把違規處切碎）與真正的分解在指標上完全一樣。</p>
]]></content:encoded></item></channel></rss>