<?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>Mutation-Testing on Tarragon</title><link>https://tarrragon.github.io/blog/tags/mutation-testing/</link><description>Recent content in Mutation-Testing 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/mutation-testing/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>模組六：Agent 產出程式碼的驗證</title><link>https://tarrragon.github.io/blog/testing/06-agent-authored-code/</link><pubDate>Mon, 24 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/testing/06-agent-authored-code/</guid><description>&lt;p>回答「這段程式碼不是我寫的，我憑什麼相信它是對的」。&lt;/p>
&lt;h2 id="這個模組跟前五個模組的分工">這個模組跟前五個模組的分工&lt;/h2>
&lt;p>模組一到五處理的是驗證發生在哪一層、斷言怎麼寫、盲區在哪裡。那些判準本身與作者身分無關，但它們被使用的方式建立在兩個假設上：寫測試的人懂需求，而且有人會讀完程式碼。程式碼由 agent 產出時這兩個假設各失效一半——判準可能與實作出自同一次推導，而讀完整份產出的成本高到多數人不會做。&lt;/p>
&lt;p>失效的不是分層，是&lt;strong>判準的來源與可信度&lt;/strong>。三層測試分層在這裡照舊成立——協議差異仍然要用真實服務驗、畫面狀態仍然要覆蓋；改變的是每一層的預期值從哪裡來，以及那個來源有沒有被實作污染。本模組處理的就是這一層。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>人寫程式時成立的假設&lt;/th>
 &lt;th>Agent 產出時的實情&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;a href="https://tarrragon.github.io/blog/testing/06-agent-authored-code/test-provenance-independence/" 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;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;a href="https://tarrragon.github.io/blog/testing/06-agent-authored-code/coverage-to-mutation-gate/" 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;a href="https://tarrragon.github.io/blog/testing/06-agent-authored-code/test-provenance-independence/" 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;a href="https://tarrragon.github.io/blog/testing/06-agent-authored-code/oracle-beyond-examples/" data-link-title="判準寫不下來的時候：性質、變形關係與留給人的部分" data-link-desc="逐例預期值算不出來或跟不上產出速度時，用來決定判準退到哪一層、以及哪些驗證工作交不出去">判準寫不下來的時候&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="這個模組的證據基礎">這個模組的證據基礎&lt;/h2>
&lt;p>四章共用同一份實證錨：Robert C. Martin 的公開對照實驗 &lt;a href="https://github.com/unclebob/negative-test-experiment">negative-test-experiment&lt;/a>，同一份需求從空目錄寫八次、四種測試紀律乘上一個複雜度度量的開關。案例側的完整記錄在 &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>。&lt;/p>
&lt;p>它只有一個產品（一個主控台文字遊戲）、一種語言、每一格執行一次，設計與可讀性評分由原作者本人以主觀量表給出。這決定了它撐得起什麼：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &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;/tr>
 &lt;tr>
 &lt;td>行覆蓋率在紀律差異極大的做法之間鑑別力很低&lt;/td>
 &lt;td>覆蓋率門檻在多大的套件規模下才失效&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>突變測試長出的斷言集中在運算子層&lt;/td>
 &lt;td>突變分數在正式產品上的投資報酬&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>施加複雜度上限會增加函式數並降低可讀性&lt;/td>
 &lt;td>那個代價的量級（八列各自獨立重寫，差值含重寫變異）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&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;strong>判成不適用、判成暫緩時這一列必填&lt;/strong>——遵循會留下測試碼，豁免什麼都不留，所以舉證責任反過來壓在豁免那一側。&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="用函式長度、圈複雜度、覆蓋率門檻這類可自動檢查的品質約束時，用來判斷達成它換到了什麼、又付出了什麼">#278&lt;/a> 套用在自己身上的結果：那張卡說機械約束會被最便宜的路徑滿足，而本模組開出的判定問題就是這種約束——最便宜的滿足方式是心裡想一下然後判它不適用。&lt;/p>
&lt;h2 id="資源很少時先做哪一件">資源很少時先做哪一件&lt;/h2>
&lt;p>四章的作法成本差很多。這一節是&lt;strong>授權的降級版本&lt;/strong>，所以進出都要留下痕跡：用它之前寫下哪一項前置不成立（沒有 CI、團隊人數、該語言沒有工具），用它之後訂一個回看的時點或訊號。少了這兩件，「資源很少」會變成不必再檢查的長期狀態。&lt;/p>
&lt;p>前置成立時照這個順序取用：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>判準與實作分開產出&lt;/strong>——開兩個會話、寫測試那一次不餵程式碼。工具層的成本接近零，真正的成本落在需求描述必須自足；擋掉的是最大宗的那一類失效。&lt;/li>
&lt;li>&lt;strong>維度表先攤前四個維度&lt;/strong>（狀態、事件、順序、邊界），這四個靠產品知識就推得出來。併發與失敗需要系統經驗，但&lt;strong>不要整個跳過&lt;/strong>——需求已經明寫的（例如「付款失敗要回滾」）照補，其餘降級成「這個交叉發生時使用者該觀察到什麼」的陳述，實際的競態驗證留給整合層。&lt;/li>
&lt;li>&lt;strong>性質式測試挑兩類&lt;/strong>：往返與冪等。這兩類最好認、也最常在小系統裡成立。&lt;/li>
&lt;li>&lt;strong>突變測試整章暫緩&lt;/strong>。它的兩個硬前置（確定性套件、可用的語言工具）加上一個成本條件（CI 時間預算）通常一個都不成立，強行導入會卡在第零步。暫緩要記進模組的 backlog 並寫明是哪一個前置擋住——回補的訊號跟著那一項走（隔離清單清空、CI 時間預算鬆動、該語言出現生產可用的實作），沒有訊號的暫緩會變成永久豁免。&lt;/li>
&lt;/ol>
&lt;p>反過來說，人手充足而&lt;strong>決定權不在自己手上&lt;/strong>時，順序完全不同——先看&lt;a href="https://tarrragon.github.io/blog/testing/06-agent-authored-code/test-provenance-independence/" data-link-title="判準的推導來源：測試憑什麼不是實作的回音" data-link-desc="測試與實作由同一次生成或同一輪對話產出時，用來判斷這組測試剩下多少驗證力，以及要動哪個變數才能拿回來">判準的推導來源&lt;/a>那一章的三層主體表，確認要動的是哪一層。&lt;/p>
&lt;h2 id="本模組回應的案例">本模組回應的案例&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>案例&lt;/th>
 &lt;th>盲區與補位&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&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>&lt;/td>
 &lt;td>同一組驗收放行八個不同的程式，判定順序住在沒有被任何條件碰到的維度交叉裡&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5&lt;/a>&lt;/td>
 &lt;td>測試自己餵資料造成的同型盲區——同源問題在 stub 層的形態&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/llm/04-applications/benchmarking-and-evaluation/" data-link-title="4.14 Benchmarking 與評估方法論" data-link-desc="判讀 model card benchmark 數字、做自己工作流的 in-house benchmark、量測本地推論速度的完整方法論">LLM 模組四 Benchmarking 與評估&lt;/a>：被測對象本身是模型、輸出不確定時的評估方法住在那裡；本模組處理的是 agent &lt;strong>產出的程式碼&lt;/strong>，兩者的判準形態不同&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/llm/04-applications/human-ai-collaboration/" data-link-title="4.5 人機協作拓樸：何時人介入、怎麼介入" data-link-desc="Centaur vs Cyborg 工作模式、jagged frontier、HITL 三種觸發時機（pre-act / mid-stream / post-hoc）、確認流程的設計避免橡皮圖章化">LLM 4.5 人機協作拓樸&lt;/a>：那一章決定「人在什麼時機介入、怎麼介入」，本模組的「人審的位置要跟著移動」是同一個決策在驗證側的落點——自動判準的能力決定介入頻率，而判準能力正是本模組四章處理的對象&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend 模組六 可靠性驗證&lt;/a>：Release gate 與 fuzz campaign 是伺服器側的閘門設計，本模組的「品質閘門要成對設計」給的是挑選閘門的判準，兩者互補不重疊&lt;/li>
&lt;li>→ &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;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/report/passing-a-gate-does-not-pin-the-program/" data-link-title="通過關卡不等於通過的是同一個程式" data-link-desc="把驗證交給一組自動化關卡、並據此不再逐行讀產出時，用來判斷這組關卡實際固定住了什麼、放過了哪些維度">#277 通過關卡不等於通過的是同一個程式&lt;/a> 與 &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;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「這段程式碼不是我寫的，我憑什麼相信它是對的」。</p>
<h2 id="這個模組跟前五個模組的分工">這個模組跟前五個模組的分工</h2>
<p>模組一到五處理的是驗證發生在哪一層、斷言怎麼寫、盲區在哪裡。那些判準本身與作者身分無關，但它們被使用的方式建立在兩個假設上：寫測試的人懂需求，而且有人會讀完程式碼。程式碼由 agent 產出時這兩個假設各失效一半——判準可能與實作出自同一次推導，而讀完整份產出的成本高到多數人不會做。</p>
<p>失效的不是分層，是<strong>判準的來源與可信度</strong>。三層測試分層在這裡照舊成立——協議差異仍然要用真實服務驗、畫面狀態仍然要覆蓋；改變的是每一層的預期值從哪裡來，以及那個來源有沒有被實作污染。本模組處理的就是這一層。</p>
<table>
  <thead>
      <tr>
          <th>人寫程式時成立的假設</th>
          <th>Agent 產出時的實情</th>
          <th>本模組的處理</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>測試由懂需求的人寫</td>
          <td>判準可能從實作推導，編碼的是實作的理解</td>
          <td><a href="/blog/testing/06-agent-authored-code/test-provenance-independence/" data-link-title="判準的推導來源：測試憑什麼不是實作的回音" data-link-desc="測試與實作由同一次生成或同一輪對話產出時，用來判斷這組測試剩下多少驗證力，以及要動哪個變數才能拿回來">判準的推導來源</a></td>
      </tr>
      <tr>
          <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><a href="/blog/testing/06-agent-authored-code/coverage-to-mutation-gate/" data-link-title="品質閘門的更替：覆蓋率、突變分數與各自的射程" data-link-desc="測試數量大幅增加而信心沒有跟著上升、或要為生成的測試挑一個不會被灌水的門檻時，用來決定量什麼，以及那個量的盲區在哪裡">品質閘門的更替</a></td>
      </tr>
      <tr>
          <td>有人會讀完程式碼</td>
          <td>逐行讀的成本高到多數人不做，而閱讀原本承擔的那件事沒有自動移交</td>
          <td><a href="/blog/testing/06-agent-authored-code/test-provenance-independence/" data-link-title="判準的推導來源：測試憑什麼不是實作的回音" data-link-desc="測試與實作由同一次生成或同一輪對話產出時，用來判斷這組測試剩下多少驗證力，以及要動哪個變數才能拿回來">判準的推導來源</a>（人審的位置要跟著移動）</td>
      </tr>
      <tr>
          <td>預期值人算得出來</td>
          <td>產出規模與速度讓逐例斷言跟不上</td>
          <td><a href="/blog/testing/06-agent-authored-code/oracle-beyond-examples/" data-link-title="判準寫不下來的時候：性質、變形關係與留給人的部分" data-link-desc="逐例預期值算不出來或跟不上產出速度時，用來決定判準退到哪一層、以及哪些驗證工作交不出去">判準寫不下來的時候</a></td>
      </tr>
  </tbody>
</table>
<h2 id="這個模組的證據基礎">這個模組的證據基礎</h2>
<p>四章共用同一份實證錨：Robert C. Martin 的公開對照實驗 <a href="https://github.com/unclebob/negative-test-experiment">negative-test-experiment</a>，同一份需求從空目錄寫八次、四種測試紀律乘上一個複雜度度量的開關。案例側的完整記錄在 <a href="/blog/testing/cases/acceptance-passes-eight-different-programs/" data-link-title="T.C10 同一組驗收通過八個不同的程式 — 關卡放行的等價類有多大" data-link-desc="同一份需求重寫多次、產出的程式互不相同卻全部通過同一組驗收時，用來看清一組關卡實際固定住了哪些行為，又放過了哪些維度">T.C10</a>。</p>
<p>它只有一個產品（一個主控台文字遊戲）、一種語言、每一格執行一次，設計與可讀性評分由原作者本人以主觀量表給出。這決定了它撐得起什麼：</p>
<table>
  <thead>
      <tr>
          <th>這份實驗支持的</th>
          <th>它撐不起的</th>
      </tr>
  </thead>
  <tbody>
      <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>
<p>四章的判準本身出自機制推導，實驗提供的是一次外顯的形態。逐項數字要當成示例讀，不要當成基準值。</p>
<p>右欄是<strong>量的未知，不是機制的未知</strong>——「這份實驗撐不起覆蓋率門檻在多大規模才失效」不等於「覆蓋率門檻在我的規模還有效」。拿右欄當有利推定時，要說得出憑什麼。</p>
<h2 id="判定問題的答案要留下痕跡">判定問題的答案要留下痕跡</h2>
<p>本模組的判準大多以問句給出——這個交叉消費者觀察得到嗎、這條性質擋不擋得住某個具體的錯誤寫法、這個名字對得回哪個領域概念、這個功能有沒有「兩件事同時發生時該怎麼辦」。<strong>問句本身不產生產物，而認真問過與完全沒問，結論都可以是「不必補」，後者省力得多。</strong></p>
<p>可執行的形式是把答案寫成一列：問了什麼、答案是什麼、依據是哪一條可指認的東西（需求文件的哪一段、可觀察面清單的哪一項、想得到的那個具體錯誤寫法）。<strong>判成不適用、判成暫緩時這一列必填</strong>——遵循會留下測試碼，豁免什麼都不留，所以舉證責任反過來壓在豁免那一側。</p>
<p>這是本模組把 <a href="/blog/report/mechanical-constraints-buy-the-measured-number/" data-link-title="機械約束買到被量測的那個數字，代價落在沒被量測的維度" data-link-desc="用函式長度、圈複雜度、覆蓋率門檻這類可自動檢查的品質約束時，用來判斷達成它換到了什麼、又付出了什麼">#278</a> 套用在自己身上的結果：那張卡說機械約束會被最便宜的路徑滿足，而本模組開出的判定問題就是這種約束——最便宜的滿足方式是心裡想一下然後判它不適用。</p>
<h2 id="資源很少時先做哪一件">資源很少時先做哪一件</h2>
<p>四章的作法成本差很多。這一節是<strong>授權的降級版本</strong>，所以進出都要留下痕跡：用它之前寫下哪一項前置不成立（沒有 CI、團隊人數、該語言沒有工具），用它之後訂一個回看的時點或訊號。少了這兩件，「資源很少」會變成不必再檢查的長期狀態。</p>
<p>前置成立時照這個順序取用：</p>
<ol>
<li><strong>判準與實作分開產出</strong>——開兩個會話、寫測試那一次不餵程式碼。工具層的成本接近零，真正的成本落在需求描述必須自足；擋掉的是最大宗的那一類失效。</li>
<li><strong>維度表先攤前四個維度</strong>（狀態、事件、順序、邊界），這四個靠產品知識就推得出來。併發與失敗需要系統經驗，但<strong>不要整個跳過</strong>——需求已經明寫的（例如「付款失敗要回滾」）照補，其餘降級成「這個交叉發生時使用者該觀察到什麼」的陳述，實際的競態驗證留給整合層。</li>
<li><strong>性質式測試挑兩類</strong>：往返與冪等。這兩類最好認、也最常在小系統裡成立。</li>
<li><strong>突變測試整章暫緩</strong>。它的兩個硬前置（確定性套件、可用的語言工具）加上一個成本條件（CI 時間預算）通常一個都不成立，強行導入會卡在第零步。暫緩要記進模組的 backlog 並寫明是哪一個前置擋住——回補的訊號跟著那一項走（隔離清單清空、CI 時間預算鬆動、該語言出現生產可用的實作），沒有訊號的暫緩會變成永久豁免。</li>
</ol>
<p>反過來說，人手充足而<strong>決定權不在自己手上</strong>時，順序完全不同——先看<a href="/blog/testing/06-agent-authored-code/test-provenance-independence/" data-link-title="判準的推導來源：測試憑什麼不是實作的回音" data-link-desc="測試與實作由同一次生成或同一輪對話產出時，用來判斷這組測試剩下多少驗證力，以及要動哪個變數才能拿回來">判準的推導來源</a>那一章的三層主體表，確認要動的是哪一層。</p>
<h2 id="本模組回應的案例">本模組回應的案例</h2>
<table>
  <thead>
      <tr>
          <th>案例</th>
          <th>盲區與補位</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/testing/cases/acceptance-passes-eight-different-programs/" data-link-title="T.C10 同一組驗收通過八個不同的程式 — 關卡放行的等價類有多大" data-link-desc="同一份需求重寫多次、產出的程式互不相同卻全部通過同一組驗收時，用來看清一組關卡實際固定住了哪些行為，又放過了哪些維度">T.C10</a></td>
          <td>同一組驗收放行八個不同的程式，判定順序住在沒有被任何條件碰到的維度交叉裡</td>
      </tr>
      <tr>
          <td><a href="/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5</a></td>
          <td>測試自己餵資料造成的同型盲區——同源問題在 stub 層的形態</td>
      </tr>
  </tbody>
</table>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/llm/04-applications/benchmarking-and-evaluation/" data-link-title="4.14 Benchmarking 與評估方法論" data-link-desc="判讀 model card benchmark 數字、做自己工作流的 in-house benchmark、量測本地推論速度的完整方法論">LLM 模組四 Benchmarking 與評估</a>：被測對象本身是模型、輸出不確定時的評估方法住在那裡；本模組處理的是 agent <strong>產出的程式碼</strong>，兩者的判準形態不同</li>
<li>→ <a href="/blog/llm/04-applications/human-ai-collaboration/" data-link-title="4.5 人機協作拓樸：何時人介入、怎麼介入" data-link-desc="Centaur vs Cyborg 工作模式、jagged frontier、HITL 三種觸發時機（pre-act / mid-stream / post-hoc）、確認流程的設計避免橡皮圖章化">LLM 4.5 人機協作拓樸</a>：那一章決定「人在什麼時機介入、怎麼介入」，本模組的「人審的位置要跟著移動」是同一個決策在驗證側的落點——自動判準的能力決定介入頻率，而判準能力正是本模組四章處理的對象</li>
<li>→ <a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend 模組六 可靠性驗證</a>：Release gate 與 fuzz campaign 是伺服器側的閘門設計，本模組的「品質閘門要成對設計」給的是挑選閘門的判準，兩者互補不重疊</li>
<li>→ <a href="/blog/record/behavior-first-tdd-methodology/" data-link-title="行為優先的 TDD：測試耦合行為、不耦合結構" data-link-desc="重構時測試大量壞掉、或在「該不該 mock 這個協作者」上需要一個可執行的立場時，本站對兩派 TDD 分歧的選擇與推導">行為優先的 TDD</a>：站內在古典派與倫敦學派分歧上的立場，本模組承接它留下的下一個問題——行為由誰決定</li>
<li>→ <a href="/blog/report/passing-a-gate-does-not-pin-the-program/" data-link-title="通過關卡不等於通過的是同一個程式" data-link-desc="把驗證交給一組自動化關卡、並據此不再逐行讀產出時，用來判斷這組關卡實際固定住了什麼、放過了哪些維度">#277 通過關卡不等於通過的是同一個程式</a> 與 <a href="/blog/report/mechanical-constraints-buy-the-measured-number/" data-link-title="機械約束買到被量測的那個數字，代價落在沒被量測的維度" data-link-desc="用函式長度、圈複雜度、覆蓋率門檻這類可自動檢查的品質約束時，用來判斷達成它換到了什麼、又付出了什麼">#278 機械約束買到被量測的那個數字</a>：本模組兩條主線抽出的可重用原則</li>
</ul>
]]></content:encoded></item><item><title>T.C10 同一組驗收通過八個不同的程式 — 關卡放行的等價類有多大</title><link>https://tarrragon.github.io/blog/testing/cases/acceptance-passes-eight-different-programs/</link><pubDate>Mon, 24 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/testing/cases/acceptance-passes-eight-different-programs/</guid><description>&lt;p>&lt;strong>一組關卡通過，證明的是產出落在該關卡切出的等價類裡，不是它就是需求要的那一個。&lt;/strong> 這則案例的價值在於等價類的大小被實際量了出來：同一份需求、同一組驗收，八個互不相同的程式全部放行，其中包含真正的行為分岔。&lt;/p>
&lt;p>本則是&lt;strong>外部案例&lt;/strong>，來源為 Robert C. Martin 的公開實驗 repo &lt;a href="https://github.com/unclebob/negative-test-experiment">negative-test-experiment&lt;/a>。實驗的 policy、plan、八份逐行 notebook 與結論都在 repo 內可回溯；本頁的數字取自 2026 年 8 月的 &lt;code>experiment-conclusion.md&lt;/code>（當時的 HEAD 為 &lt;code>faf9df0&lt;/code>，2026-08-17）。&lt;strong>該 repo 仍在演進&lt;/strong>——作者若補跑新一輪網格或改寫結論，本頁的列號式指認（列 1、列 4、列 5 這類）就會失聯而讀者無從察覺；repo 出現觸及 &lt;code>experiment-conclusion.md&lt;/code> 的新 commit 時，要回頭核對站內所有引用該實驗數字的位置——&lt;code>rg &amp;quot;negative-test-experiment|331 到 603|97% 到 99%|85.81&amp;quot; content/&lt;/code> 是目前掃得到它們的方式。本頁是這些數字的來源，其他位置引用時應指回本頁。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>實驗設計是一個 4 × 2 的網格。產品固定為 Greg Yob 的 Hunt the Wumpus，合規判準固定為一份 25 案例的主控台驗收腳本。變因一是測試紀律四選一：三法則 TDD、test-last（先寫完程式再補測試）、bundling（寫一個函式就測一個函式）、完全不寫測試；變因二是 CRAP 度量是否強制壓到低於 4。八行各自從空目錄寫起，禁止複製其他行的程式碼；每行寫完後封存該行的測試套件，再從空的用突變測試長出第二套。執行者是 AI agent，policy 檔通篇是防止抄近路的條款。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>指標&lt;/th>
 &lt;th>值&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>驗收結果&lt;/td>
 &lt;td>八行全部 25/0 通過，包含零單元測試的那一行&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>程式相異度&lt;/td>
 &lt;td>八棵樹無任何兩棵 checksum 相同；行數 331 到 603、函式數 33 到 118&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>行覆蓋率&lt;/td>
 &lt;td>test-last、bundling、三法則+CRAP 三者都落在 97% 到 99%；三法則不開 CRAP 是 outlier、停在 85.81%&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>零測試那一行&lt;/td>
 &lt;td>覆蓋率 14%（form）/ 24%（line），數字全部來自載入而非斷言，驗收仍然全過&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>最密的測試套件&lt;/td>
 &lt;td>206 個範例、277 個斷言，而驗收結果與零測試那一行完全一致&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>突變測試的產出&lt;/td>
 &lt;td>補上運算子層的翻轉（&lt;code>if&lt;/code>/&lt;code>if-not&lt;/code>、&lt;code>=&lt;/code>/&lt;code>not=&lt;/code>、&lt;code>1&lt;/code>/&lt;code>0&lt;/code>），未發現不同的遊戲&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>被放行的差異裡有三類不只是實作自由度：&lt;/p>
&lt;p>&lt;strong>危險判定順序。&lt;/strong> 原作的規則是先檢查 wumpus、再 pit、再 bats。八行裡有四行照做（列 3、6、7、8）；另外三行（列 1、4、5）的 notebook 逐行記載都是 bats、pit、wumpus；列 2 的 notebook 未記順序。實際存在的是&lt;strong>兩種&lt;/strong>順序，而驗收案例從來沒有把兩個危險放進同一個房間，所以兩種都合法通過。&lt;/p>
&lt;p>&lt;strong>終止行為。&lt;/strong> 一行的主迴圈永不結束，一行的外層迴圈永不離開，一行以拋出並吞掉例外的方式結束。驗收腳本沒有斷言離開的方式。&lt;/p>
&lt;p>&lt;strong>亂數來源。&lt;/strong> 三種不同的產生器（平台內建、兩種不同係數的線性同餘），意味著 &lt;code>--seed&lt;/code> 參數在八行之間不是可攜的契約——同一個種子在不同行產出不同的地圖。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>等價類的大小由條件之間的交叉密度決定，不由條件數量決定。&lt;/strong> 二十五條驗收案例讀起來像把功能講完了，實際上它們只約束了「每個危險單獨出現時的反應」。判定順序住在「兩個危險同時出現」這個交叉裡，而沒有任何一條條件走進那裡。這與&lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/three-layer-definition/" data-link-title="三層定義與職責表" data-link-desc="Unit Test / Protocol Integration Test / Screen State Test 各層職責、驗證目標與盲區的完整論述">三層測試分層&lt;/a>談的盲區不同——那是層與層之間的縫，這是同一層內部維度沒有相乘。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>同一維度上加斷言，縮小等價類的幅度隨密度遞減；對沒有任何條件碰過的維度，增量是零。&lt;/strong> 八行的單元測試規模從零到 206 個範例，而驗收結果完全一致——判定順序住在「兩個危險同時出現」這個交叉裡，25 條驗收條件一條都沒有走進去。密度成長與涵蓋面成長是兩件事，而套件規模只反映前者。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>行覆蓋率在這個網格裡鑑別力很低。&lt;/strong> test-last、bundling、三法則+CRAP 三者都停在 97% 到 99%，而它們的程式與套件組成完全不同。被覆蓋率區分出來的只有兩端：零測試那一行的 14% / 24%，以及三法則不開 CRAP 的 85.81%（作者標為 outlier，成因是主控台迴圈只被輕度驅動）。也就是說覆蓋率分得出「有沒有測試」與「有沒有一整塊沒被驅動」，分不出「測試好不好」。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>突變測試守得住斷言密度，守不住規格錯誤。&lt;/strong> 實驗記載的是突變長出來的第二套測試全部瞄準運算子層的翻轉、作者的結論是它「沒有發現一個不同的遊戲」。由此可以推出而實驗未直接測量的一件事：突變是對照著程式&lt;strong>現在的樣子&lt;/strong>產生的，所以它不會對判定順序產生任何訊號——把 bats 排在前面的那份程式跑突變測試，長出來的斷言會把那個順序釘得更牢。這個限制在測試與實作&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/test-provenance/" data-link-title="Test Provenance（測試出處）" data-link-desc="測試與被測實作由同一來源產出時（同一次生成、同一輪對話、同一個人同時寫），用來判斷這組測試還剩下多少驗證力">出處不獨立&lt;/a>時特別要緊，因為那正是規格層錯誤最容易產生的情境。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>自由度與未規定的需求要分開。&lt;/strong> 等價類大本身不是缺陷——命名、模組切法、內部資料結構本來就該留給實作。八行之間的檔案切法差異屬於這一類；判定順序與終止行為不屬於，它們是需求的一部分，只是沒有被寫進條件裡。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;p>從這則案例抽出的可重用修法在 &lt;a href="https://tarrragon.github.io/blog/report/passing-a-gate-does-not-pin-the-program/" data-link-title="通過關卡不等於通過的是同一個程式" data-link-desc="把驗證交給一組自動化關卡、並據此不再逐行讀產出時，用來判斷這組關卡實際固定住了什麼、放過了哪些維度">#277&lt;/a>（設計驗收條件時問哪些不同的程式會通過、交叉優先於單維度密度、把通過的產出當成樣本、指名誰接手了閱讀原本承擔的工作、區分自由度與未規定的需求），這裡不重述。&lt;/p>
&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;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>這則案例抽出的可重用原則 → &lt;a href="https://tarrragon.github.io/blog/report/passing-a-gate-does-not-pin-the-program/" data-link-title="通過關卡不等於通過的是同一個程式" data-link-desc="把驗證交給一組自動化關卡、並據此不再逐行讀產出時，用來判斷這組關卡實際固定住了什麼、放過了哪些維度">#277 通過關卡不等於通過的是同一個程式&lt;/a>&lt;/li>
&lt;li>同一份實驗的另一軸（機械約束的代價）→ &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;/li>
&lt;li>測試與實作同源時 oracle 為什麼退化 → &lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/test-provenance/" data-link-title="Test Provenance（測試出處）" data-link-desc="測試與被測實作由同一來源產出時（同一次生成、同一輪對話、同一個人同時寫），用來判斷這組測試還剩下多少驗證力">Test Provenance&lt;/a>&lt;/li>
&lt;li>判準來源本身有哪幾種、各自抓得到什麼 → &lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/test-oracle/" data-link-title="Test Oracle（測試判準來源）" data-link-desc="一個測試憑什麼判定通過或失敗說不清楚、或被測對象沒有可逐例算出的正確答案時，用來定位判準的來源，以及每一種來源的射程">Test Oracle&lt;/a>&lt;/li>
&lt;li>由測試自己餵資料造成同型盲區的自有案例 → &lt;a href="https://tarrragon.github.io/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5 凍結參照失效被 stub 遮蔽&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p><strong>一組關卡通過，證明的是產出落在該關卡切出的等價類裡，不是它就是需求要的那一個。</strong> 這則案例的價值在於等價類的大小被實際量了出來：同一份需求、同一組驗收，八個互不相同的程式全部放行，其中包含真正的行為分岔。</p>
<p>本則是<strong>外部案例</strong>，來源為 Robert C. Martin 的公開實驗 repo <a href="https://github.com/unclebob/negative-test-experiment">negative-test-experiment</a>。實驗的 policy、plan、八份逐行 notebook 與結論都在 repo 內可回溯；本頁的數字取自 2026 年 8 月的 <code>experiment-conclusion.md</code>（當時的 HEAD 為 <code>faf9df0</code>，2026-08-17）。<strong>該 repo 仍在演進</strong>——作者若補跑新一輪網格或改寫結論，本頁的列號式指認（列 1、列 4、列 5 這類）就會失聯而讀者無從察覺；repo 出現觸及 <code>experiment-conclusion.md</code> 的新 commit 時，要回頭核對站內所有引用該實驗數字的位置——<code>rg &quot;negative-test-experiment|331 到 603|97% 到 99%|85.81&quot; content/</code> 是目前掃得到它們的方式。本頁是這些數字的來源，其他位置引用時應指回本頁。</p>
<h2 id="觀察">觀察</h2>
<p>實驗設計是一個 4 × 2 的網格。產品固定為 Greg Yob 的 Hunt the Wumpus，合規判準固定為一份 25 案例的主控台驗收腳本。變因一是測試紀律四選一：三法則 TDD、test-last（先寫完程式再補測試）、bundling（寫一個函式就測一個函式）、完全不寫測試；變因二是 CRAP 度量是否強制壓到低於 4。八行各自從空目錄寫起，禁止複製其他行的程式碼；每行寫完後封存該行的測試套件，再從空的用突變測試長出第二套。執行者是 AI agent，policy 檔通篇是防止抄近路的條款。</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>驗收結果</td>
          <td>八行全部 25/0 通過，包含零單元測試的那一行</td>
      </tr>
      <tr>
          <td>程式相異度</td>
          <td>八棵樹無任何兩棵 checksum 相同；行數 331 到 603、函式數 33 到 118</td>
      </tr>
      <tr>
          <td>行覆蓋率</td>
          <td>test-last、bundling、三法則+CRAP 三者都落在 97% 到 99%；三法則不開 CRAP 是 outlier、停在 85.81%</td>
      </tr>
      <tr>
          <td>零測試那一行</td>
          <td>覆蓋率 14%（form）/ 24%（line），數字全部來自載入而非斷言，驗收仍然全過</td>
      </tr>
      <tr>
          <td>最密的測試套件</td>
          <td>206 個範例、277 個斷言，而驗收結果與零測試那一行完全一致</td>
      </tr>
      <tr>
          <td>突變測試的產出</td>
          <td>補上運算子層的翻轉（<code>if</code>/<code>if-not</code>、<code>=</code>/<code>not=</code>、<code>1</code>/<code>0</code>），未發現不同的遊戲</td>
      </tr>
  </tbody>
</table>
<p>被放行的差異裡有三類不只是實作自由度：</p>
<p><strong>危險判定順序。</strong> 原作的規則是先檢查 wumpus、再 pit、再 bats。八行裡有四行照做（列 3、6、7、8）；另外三行（列 1、4、5）的 notebook 逐行記載都是 bats、pit、wumpus；列 2 的 notebook 未記順序。實際存在的是<strong>兩種</strong>順序，而驗收案例從來沒有把兩個危險放進同一個房間，所以兩種都合法通過。</p>
<p><strong>終止行為。</strong> 一行的主迴圈永不結束，一行的外層迴圈永不離開，一行以拋出並吞掉例外的方式結束。驗收腳本沒有斷言離開的方式。</p>
<p><strong>亂數來源。</strong> 三種不同的產生器（平台內建、兩種不同係數的線性同餘），意味著 <code>--seed</code> 參數在八行之間不是可攜的契約——同一個種子在不同行產出不同的地圖。</p>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>等價類的大小由條件之間的交叉密度決定，不由條件數量決定。</strong> 二十五條驗收案例讀起來像把功能講完了，實際上它們只約束了「每個危險單獨出現時的反應」。判定順序住在「兩個危險同時出現」這個交叉裡，而沒有任何一條條件走進那裡。這與<a href="/blog/testing/01-test-strategy-layers/three-layer-definition/" data-link-title="三層定義與職責表" data-link-desc="Unit Test / Protocol Integration Test / Screen State Test 各層職責、驗證目標與盲區的完整論述">三層測試分層</a>談的盲區不同——那是層與層之間的縫，這是同一層內部維度沒有相乘。</p>
</li>
<li>
<p><strong>同一維度上加斷言，縮小等價類的幅度隨密度遞減；對沒有任何條件碰過的維度，增量是零。</strong> 八行的單元測試規模從零到 206 個範例，而驗收結果完全一致——判定順序住在「兩個危險同時出現」這個交叉裡，25 條驗收條件一條都沒有走進去。密度成長與涵蓋面成長是兩件事，而套件規模只反映前者。</p>
</li>
<li>
<p><strong>行覆蓋率在這個網格裡鑑別力很低。</strong> test-last、bundling、三法則+CRAP 三者都停在 97% 到 99%，而它們的程式與套件組成完全不同。被覆蓋率區分出來的只有兩端：零測試那一行的 14% / 24%，以及三法則不開 CRAP 的 85.81%（作者標為 outlier，成因是主控台迴圈只被輕度驅動）。也就是說覆蓋率分得出「有沒有測試」與「有沒有一整塊沒被驅動」，分不出「測試好不好」。</p>
</li>
<li>
<p><strong>突變測試守得住斷言密度，守不住規格錯誤。</strong> 實驗記載的是突變長出來的第二套測試全部瞄準運算子層的翻轉、作者的結論是它「沒有發現一個不同的遊戲」。由此可以推出而實驗未直接測量的一件事：突變是對照著程式<strong>現在的樣子</strong>產生的，所以它不會對判定順序產生任何訊號——把 bats 排在前面的那份程式跑突變測試，長出來的斷言會把那個順序釘得更牢。這個限制在測試與實作<a href="/blog/testing/knowledge-cards/test-provenance/" data-link-title="Test Provenance（測試出處）" data-link-desc="測試與被測實作由同一來源產出時（同一次生成、同一輪對話、同一個人同時寫），用來判斷這組測試還剩下多少驗證力">出處不獨立</a>時特別要緊，因為那正是規格層錯誤最容易產生的情境。</p>
</li>
<li>
<p><strong>自由度與未規定的需求要分開。</strong> 等價類大本身不是缺陷——命名、模組切法、內部資料結構本來就該留給實作。八行之間的檔案切法差異屬於這一類；判定順序與終止行為不屬於，它們是需求的一部分，只是沒有被寫進條件裡。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<p>從這則案例抽出的可重用修法在 <a href="/blog/report/passing-a-gate-does-not-pin-the-program/" data-link-title="通過關卡不等於通過的是同一個程式" data-link-desc="把驗證交給一組自動化關卡、並據此不再逐行讀產出時，用來判斷這組關卡實際固定住了什麼、放過了哪些維度">#277</a>（設計驗收條件時問哪些不同的程式會通過、交叉優先於單維度密度、把通過的產出當成樣本、指名誰接手了閱讀原本承擔的工作、區分自由度與未規定的需求），這裡不重述。</p>
<p>案例層特有的只有一條：<strong>把品質指標配對到它量不到的維度</strong>。覆蓋率門檻配<a href="/blog/testing/knowledge-cards/mutation-testing/" data-link-title="Mutation Testing（突變測試）" data-link-desc="行覆蓋率很高卻仍漏掉 bug、或要挑一個比覆蓋率難灌水的測試品質指標時，用來判斷這套測試實際擋得住什麼">突變測試</a>，突變分數再配一次規格層的人工檢查——本則的八個版本正好示範了為什麼單一指標全綠不構成放寬下一層檢查的理由。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>這則案例抽出的可重用原則 → <a href="/blog/report/passing-a-gate-does-not-pin-the-program/" data-link-title="通過關卡不等於通過的是同一個程式" data-link-desc="把驗證交給一組自動化關卡、並據此不再逐行讀產出時，用來判斷這組關卡實際固定住了什麼、放過了哪些維度">#277 通過關卡不等於通過的是同一個程式</a></li>
<li>同一份實驗的另一軸（機械約束的代價）→ <a href="/blog/report/mechanical-constraints-buy-the-measured-number/" data-link-title="機械約束買到被量測的那個數字，代價落在沒被量測的維度" data-link-desc="用函式長度、圈複雜度、覆蓋率門檻這類可自動檢查的品質約束時，用來判斷達成它換到了什麼、又付出了什麼">#278 機械約束買到被量測的那個數字</a></li>
<li>測試與實作同源時 oracle 為什麼退化 → <a href="/blog/testing/knowledge-cards/test-provenance/" data-link-title="Test Provenance（測試出處）" data-link-desc="測試與被測實作由同一來源產出時（同一次生成、同一輪對話、同一個人同時寫），用來判斷這組測試還剩下多少驗證力">Test Provenance</a></li>
<li>判準來源本身有哪幾種、各自抓得到什麼 → <a href="/blog/testing/knowledge-cards/test-oracle/" data-link-title="Test Oracle（測試判準來源）" data-link-desc="一個測試憑什麼判定通過或失敗說不清楚、或被測對象沒有可逐例算出的正確答案時，用來定位判準的來源，以及每一種來源的射程">Test Oracle</a></li>
<li>由測試自己餵資料造成同型盲區的自有案例 → <a href="/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5 凍結參照失效被 stub 遮蔽</a></li>
</ul>
]]></content:encoded></item><item><title>Mutation Testing（突變測試）</title><link>https://tarrragon.github.io/blog/testing/knowledge-cards/mutation-testing/</link><pubDate>Mon, 24 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/testing/knowledge-cards/mutation-testing/</guid><description>&lt;p>Mutation testing 對被測程式注入單點的行為改變（把 &lt;code>&amp;gt;&lt;/code> 換成 &lt;code>&amp;gt;=&lt;/code>、把回傳值換成常數、刪掉一次呼叫），再看測試套件會不會因此變紅。變紅稱為殺死該突變，維持全綠稱為存活；存活的突變指出一件具體的事——程式在那個位置改掉行為，這套測試不會發現。它量的是測試的&lt;strong>偵測能力&lt;/strong>，而行覆蓋率量的是&lt;strong>執行足跡&lt;/strong>，兩者的差距正是斷言可以完全不寫卻仍有 100% 覆蓋率的那段空間。它不提供判準，它檢查既有 &lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/test-oracle/" data-link-title="Test Oracle（測試判準來源）" data-link-desc="一個測試憑什麼判定通過或失敗說不清楚、或被測對象沒有可逐例算出的正確答案時，用來定位判準的來源，以及每一種來源的射程">oracle&lt;/a> 的密度。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>突變分數（被殺死的突變數除以有效突變數）是覆蓋率的替代指標——字面上的空斷言灌不高它，而錄製式斷言這類低思考量的路徑仍然可以，射程在&lt;a href="https://tarrragon.github.io/blog/testing/06-agent-authored-code/coverage-to-mutation-gate/" data-link-title="品質閘門的更替：覆蓋率、突變分數與各自的射程" data-link-desc="測試數量大幅增加而信心沒有跟著上升、或要為生成的測試挑一個不會被灌水的門檻時，用來決定量什麼，以及那個量的盲區在哪裡">品質閘門的更替&lt;/a>。它跟 &lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/test-oracle/" data-link-title="Test Oracle（測試判準來源）" data-link-desc="一個測試憑什麼判定通過或失敗說不清楚、或被測對象沒有可逐例算出的正確答案時，用來定位判準的來源，以及每一種來源的射程">test oracle&lt;/a> 的分工是：oracle 決定判準從哪來，突變分數量既有判準的密度。覆蓋率之所以長期被當成閘門，前提是「執行到了通常也就斷言了」——這個前提在斷言由人一行一行寫的年代大致成立，在斷言可以整批產生之後不再成立。用突變分數當閘門的理由是它無法用空斷言達成：要殺死突變，斷言必須真的檢查到那個行為。&lt;/p>
&lt;p>它的成本結構跟其他測試層不同：每一個突變都要重跑一次相關測試，所以執行時間與突變數乘上測試時間成正比。壓低成本的三種手法，以及分數自己的至少四條便宜達成路徑（兩組互不對應），在&lt;a href="https://tarrragon.github.io/blog/testing/06-agent-authored-code/coverage-to-mutation-gate/" data-link-title="品質閘門的更替：覆蓋率、突變分數與各自的射程" data-link-desc="測試數量大幅增加而信心沒有跟著上升、或要為生成的測試挑一個不會被灌水的門檻時，用來決定量什麼，以及那個量的盲區在哪裡">品質閘門的更替&lt;/a>那一章。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>覆蓋率維持在 90% 以上，而漏出去的 bug 落在有被覆蓋的程式碼裡——這組數字同時出現時，斷言多半只檢查到「有沒有跑完」，而突變分數會立刻把落差顯示出來。&lt;/p>
&lt;p>存活的突變分成兩類，處理方式相反：真的漏洞要補斷言，等價突變（改寫之後行為完全相同）是工具的限制，要標記排除。為等價突變硬補的斷言會綁在實作寫法上，反過來傷害重構安全性。兩者判不開時的處置紀律（時間盒、預設歸類、排除清單覆核）在&lt;a href="https://tarrragon.github.io/blog/testing/06-agent-authored-code/coverage-to-mutation-gate/" data-link-title="品質閘門的更替：覆蓋率、突變分數與各自的射程" data-link-desc="測試數量大幅增加而信心沒有跟著上升、或要為生成的測試挑一個不會被灌水的門檻時，用來決定量什麼，以及那個量的盲區在哪裡">品質閘門的更替&lt;/a>。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>引入的順序是先有可信的 oracle、再談突變分數。突變是對照著程式&lt;strong>現在的樣子&lt;/strong>產生的，因此它守得住「斷言不夠密」，守不住「這段程式一開始就實作錯了需求」——把危險判定順序寫反的程式碼跑突變測試，長出來的斷言會把那個順序釘得更牢（機制推論，非實驗觀察）。這個限制在測試與實作&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/test-provenance/" data-link-title="Test Provenance（測試出處）" data-link-desc="測試與被測實作由同一來源產出時（同一次生成、同一輪對話、同一個人同時寫），用來判斷這組測試還剩下多少驗證力">出處不獨立&lt;/a>時特別要緊，因為那正是規格層錯誤最容易發生的情境。&lt;/p>
&lt;p>它也不會提出新的問題。突變生出來的斷言全部瞄準運算子與回傳值層級的翻轉，屬於 &lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/testing-vs-checking/" data-link-title="Testing vs Checking（探究與檢查）" data-link-desc="斷言可以被整批產生之後，用來區分哪些驗證工作交得出去給機器、哪些需要人的判斷才成立">checking 的補完&lt;/a>；產品有哪些情境值得驗證，仍然要由人設計。&lt;/p></description><content:encoded><![CDATA[<p>Mutation testing 對被測程式注入單點的行為改變（把 <code>&gt;</code> 換成 <code>&gt;=</code>、把回傳值換成常數、刪掉一次呼叫），再看測試套件會不會因此變紅。變紅稱為殺死該突變，維持全綠稱為存活；存活的突變指出一件具體的事——程式在那個位置改掉行為，這套測試不會發現。它量的是測試的<strong>偵測能力</strong>，而行覆蓋率量的是<strong>執行足跡</strong>，兩者的差距正是斷言可以完全不寫卻仍有 100% 覆蓋率的那段空間。它不提供判準，它檢查既有 <a href="/blog/testing/knowledge-cards/test-oracle/" data-link-title="Test Oracle（測試判準來源）" data-link-desc="一個測試憑什麼判定通過或失敗說不清楚、或被測對象沒有可逐例算出的正確答案時，用來定位判準的來源，以及每一種來源的射程">oracle</a> 的密度。</p>
<h2 id="概念位置">概念位置</h2>
<p>突變分數（被殺死的突變數除以有效突變數）是覆蓋率的替代指標——字面上的空斷言灌不高它，而錄製式斷言這類低思考量的路徑仍然可以，射程在<a href="/blog/testing/06-agent-authored-code/coverage-to-mutation-gate/" data-link-title="品質閘門的更替：覆蓋率、突變分數與各自的射程" data-link-desc="測試數量大幅增加而信心沒有跟著上升、或要為生成的測試挑一個不會被灌水的門檻時，用來決定量什麼，以及那個量的盲區在哪裡">品質閘門的更替</a>。它跟 <a href="/blog/testing/knowledge-cards/test-oracle/" data-link-title="Test Oracle（測試判準來源）" data-link-desc="一個測試憑什麼判定通過或失敗說不清楚、或被測對象沒有可逐例算出的正確答案時，用來定位判準的來源，以及每一種來源的射程">test oracle</a> 的分工是：oracle 決定判準從哪來，突變分數量既有判準的密度。覆蓋率之所以長期被當成閘門，前提是「執行到了通常也就斷言了」——這個前提在斷言由人一行一行寫的年代大致成立，在斷言可以整批產生之後不再成立。用突變分數當閘門的理由是它無法用空斷言達成：要殺死突變，斷言必須真的檢查到那個行為。</p>
<p>它的成本結構跟其他測試層不同：每一個突變都要重跑一次相關測試，所以執行時間與突變數乘上測試時間成正比。壓低成本的三種手法，以及分數自己的至少四條便宜達成路徑（兩組互不對應），在<a href="/blog/testing/06-agent-authored-code/coverage-to-mutation-gate/" data-link-title="品質閘門的更替：覆蓋率、突變分數與各自的射程" data-link-desc="測試數量大幅增加而信心沒有跟著上升、或要為生成的測試挑一個不會被灌水的門檻時，用來決定量什麼，以及那個量的盲區在哪裡">品質閘門的更替</a>那一章。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>覆蓋率維持在 90% 以上，而漏出去的 bug 落在有被覆蓋的程式碼裡——這組數字同時出現時，斷言多半只檢查到「有沒有跑完」，而突變分數會立刻把落差顯示出來。</p>
<p>存活的突變分成兩類，處理方式相反：真的漏洞要補斷言，等價突變（改寫之後行為完全相同）是工具的限制，要標記排除。為等價突變硬補的斷言會綁在實作寫法上，反過來傷害重構安全性。兩者判不開時的處置紀律（時間盒、預設歸類、排除清單覆核）在<a href="/blog/testing/06-agent-authored-code/coverage-to-mutation-gate/" data-link-title="品質閘門的更替：覆蓋率、突變分數與各自的射程" data-link-desc="測試數量大幅增加而信心沒有跟著上升、或要為生成的測試挑一個不會被灌水的門檻時，用來決定量什麼，以及那個量的盲區在哪裡">品質閘門的更替</a>。</p>
<h2 id="設計責任">設計責任</h2>
<p>引入的順序是先有可信的 oracle、再談突變分數。突變是對照著程式<strong>現在的樣子</strong>產生的，因此它守得住「斷言不夠密」，守不住「這段程式一開始就實作錯了需求」——把危險判定順序寫反的程式碼跑突變測試，長出來的斷言會把那個順序釘得更牢（機制推論，非實驗觀察）。這個限制在測試與實作<a href="/blog/testing/knowledge-cards/test-provenance/" data-link-title="Test Provenance（測試出處）" data-link-desc="測試與被測實作由同一來源產出時（同一次生成、同一輪對話、同一個人同時寫），用來判斷這組測試還剩下多少驗證力">出處不獨立</a>時特別要緊，因為那正是規格層錯誤最容易發生的情境。</p>
<p>它也不會提出新的問題。突變生出來的斷言全部瞄準運算子與回傳值層級的翻轉，屬於 <a href="/blog/testing/knowledge-cards/testing-vs-checking/" data-link-title="Testing vs Checking（探究與檢查）" data-link-desc="斷言可以被整批產生之後，用來區分哪些驗證工作交得出去給機器、哪些需要人的判斷才成立">checking 的補完</a>；產品有哪些情境值得驗證，仍然要由人設計。</p>
]]></content:encoded></item></channel></rss>