<?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>Acceptance-Test on Tarragon</title><link>https://tarrragon.github.io/blog/tags/acceptance-test/</link><description>Recent content in Acceptance-Test 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/acceptance-test/index.xml" rel="self" type="application/rss+xml"/><item><title>驗收條件的等價類：關卡實際固定住了什麼</title><link>https://tarrragon.github.io/blog/testing/06-agent-authored-code/acceptance-equivalence-class/</link><pubDate>Mon, 24 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/testing/06-agent-authored-code/acceptance-equivalence-class/</guid><description>&lt;p>一組驗收條件通過，證明的是產出落在該條件切出的等價類裡（這裡的等價類指&lt;strong>能通過同一組條件的所有程式&lt;/strong>所成的集合，跟測試設計裡的等價類劃分不是同一件事——那個切的是輸入域，這個切的是程式）。等價類是所有能通過這組條件的程式所成的集合，它的大小由條件之間的&lt;strong>交叉密度&lt;/strong>決定，不由條件的數量決定。設計驗收條件的工作因此不是「把功能講完」，而是把等價類縮到只剩下真正屬於實作自由度的差異。&lt;/p>
&lt;p>閱讀退出之後，「那兩個同時發生怎麼辦」這句話沒有人會順口問了——它原本由讀程式碼的人隨手補上，現在要由條件的設計自己回答。&lt;/p>
&lt;h2 id="等價類可以被量出來">等價類可以被量出來&lt;/h2>
&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> 把這件事實際量了一次。產品是 Hunt the Wumpus，一個主控台文字冒險遊戲：玩家在洞穴間移動、房間裡可能有怪物、陷阱或蝙蝠。同一份需求從空目錄寫八次，八個程式全部通過同一組 25 案例的驗收，八棵原始碼樹沒有任何兩棵相同，行數從 331 到 603。&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> 三種不同的產生器，意味著同一個 seed 參數在不同版本產出不同結果——&lt;code>--seed&lt;/code> 沒有成為可攜的契約。&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>同一個實驗提供了它的外顯形態：八份程式的單元測試規模從零到 206 個範例，驗收結果卻完全一致——那 25 條驗收條件把每個危險單獨出現時的反應驗了很多遍，兩個危險同時出現一次都沒有，所以判定順序這個維度的增量始終是零。&lt;/p>
&lt;p>它同時解釋了一種常見的挫折感——測試一直加、線上還是出事，而出的事「測試裡明明有類似的案例」。類似不等於交叉：同一個維度上的第十個案例，對另一個維度的貢獻仍然是零。&lt;/p>
&lt;p>本章引用的量測數字全部來自同一份實驗，它撐得起與撐不起什麼寫在&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;table>
 &lt;thead>
 &lt;tr>
 &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>對一個登入中的使用者降權，他手上那張 session 怎麼算&lt;/td>
 &lt;/tr>
 &lt;tr>
 &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;/tr>
 &lt;tr>
 &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;/tr>
 &lt;tr>
 &lt;td>失敗&lt;/td>
 &lt;td>權限服務逾時、或只寫成功一半&lt;/td>
 &lt;td>失敗發生在寫入之後、快取失效通知之前&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>換一個流程，六個維度的內容全部要重填——這張表的欄位是問題，不是答案。前四個維度在任何有狀態的系統裡都推得出來；&lt;strong>併發與失敗兩個維度值得單獨說一句&lt;/strong>：它們在驗收條件層能寫到的程度比其他四個低，因為驗收多半跑在單執行緒的腳本裡。可行的寫法是把它們降級成「這個交叉發生時使用者該觀察到什麼」的陳述（輸的那筆要收到明確拒絕、而不是靜默覆蓋），把實際的競態驗證留給整合層。&lt;/p>
&lt;p>&lt;strong>這張表自己有一個盲區，而且是本章方法論的遞歸：空格只存在於已經被列出的維度上，漏掉的維度連格子都沒有。&lt;/strong> 找空格證明不了維度列得夠，所以維度清單要有第二個來源——最便宜的是既有的事故與客訴紀錄：逐則問它落在哪個維度，落不進表上任何一格的就是漏掉的那一個。沒有事故史時退而求其次，把表拿給另一個熟悉這個領域、但沒有參與寫條件的人補一輪。&lt;/p>
&lt;p>做法是逐條驗收條件標出它涵蓋哪個維度，再看維度兩兩相乘的格子裡哪些是空的——兩兩相乘是第一輪，更高維的交叉怎麼處理見下一節。空格不必全補——判斷依據是那個交叉發生時&lt;strong>消費者觀察得到的行為&lt;/strong>會不會不同，會不同就是需求缺口。答「觀察不到」的人要指出那個差異落在可觀察面清單（API 契約、時序、資源使用、錯誤形態、分佈不變量）之外的哪一側；&lt;strong>指不出來、或說不出從哪個介面觀察，就當成需求缺口補條件&lt;/strong>——誤補的代價是多一條條件，漏補的代價是那個維度永遠不會再被問起。&lt;/p>
&lt;p>消費者不是人的時候這一問要換個問法。下游是程式（函式庫、SDK、資料管線、內部服務）時，可觀察面是 API 契約加上時序、資源使用與錯誤形態——「換一種資料結構」在延遲或記憶體屬於契約的一部分時就不是自由度。下游是統計性流程（模型訓練、報表聚合）時，單筆記錄的差異可能觀察不到而分佈的差異觀察得到，判準要換成&lt;strong>分佈層的不變量&lt;/strong>（筆數、空值率、值域、欄位相關性），而不是逐筆的觀察。這一節的判準與上一段的預設是同一條：指不出可觀察的介面就補條件。處置的形態沿用&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;h3 id="攤到幾維才停">攤到幾維才停&lt;/h3>
&lt;p>兩兩相乘是起點不是終點。真實系統裡最貴的缺陷常常住在三維以上——年約 × 期末 × 付款失敗、登入中 × 降權 × 快取尚未失效，這類組合在二維表上一格都不會出現。而三維的組合數是二維的好幾倍，全補是不可能的。&lt;/p>
&lt;p>停止的判準不是維數，是&lt;strong>代價&lt;/strong>：&lt;/p>
&lt;ol>
&lt;li>把每一個空著的交叉標一個「它發生時消費者損失多少」——金額、資料正確性、無法回復的操作、對外承諾的違反，取那個系統自己的單位。&lt;/li>
&lt;li>由大到小排序，從頂端往下補。&lt;/li>
&lt;li>補到「再補一條的成本超過那個交叉的代價」為止，然後停。停下來的位置要寫下來——那是這組條件的射程宣告，不是遺漏。&lt;/li>
&lt;/ol>
&lt;p>這條判準對維數是中立的：某個三維交叉的代價比所有二維交叉都高時它排在最前面，而多數四維組合會自動落到門檻以下。實務上代價的估算不需要精確，只需要能排序——「這個會讓使用者被多收一期的錢」與「這個會讓錯誤訊息的措辭不一致」之間的差距，不必量化就分得出來。&lt;/p>
&lt;p>代價估不出來的交叉另外處理：那通常代表這個情境的業務後果從來沒有人想過，而那件事本身比補一條條件更值得先解決。&lt;/p>
&lt;p>這個盤點的副產物往往比測試本身有價值：空格代表需求文件對那個情境沒有交代，而沒有交代的地方通常是產品決策從來沒有被做過的地方。權限異動這個例子裡，「降權時那張 session 怎麼算」多半不在任何文件裡，而它決定了使用者會不會在操作到一半突然被踢掉。&lt;/p>
&lt;h2 id="補條件的順序">補條件的順序&lt;/h2>
&lt;p>&lt;strong>先補改變外部行為的交叉。&lt;/strong> 判定順序、狀態轉換的合法性、失敗發生在流程中段時的補償，這些交叉一旦錯了使用者會看見。&lt;/p>
&lt;p>&lt;strong>再補契約性的承諾。&lt;/strong> 這一類是「文件上寫了、而沒有任何一條條件在守」的承諾。付款端點宣稱重試安全，就要有一條條件用同一個冪等鍵送兩次並斷言只扣一次款；分頁 API 宣稱排序穩定，就要有一條條件在兩次查詢之間插入一筆資料並斷言既有頁次的內容不位移。T.C10 裡的 &lt;code>--seed&lt;/code> 是同一類的最小形態——宣稱同樣輸入得到同樣輸出，而八個版本用了三種不同的亂數產生器，沒有一條條件發現。&lt;/p>
&lt;p>&lt;strong>終止與資源釋放要有自己的條件。&lt;/strong> 「跑得完」與「跑對」是兩件事，而多數驗收腳本只斷言後者。長時間執行的流程要斷言它會結束、結束時釋放了什麼。&lt;/p>
&lt;p>&lt;strong>最後才是同一維度上的密度。&lt;/strong> 邊界值列表有它的價值，但它排在交叉之後。&lt;/p>
&lt;h2 id="把通過的產出當成樣本">把通過的產出當成樣本&lt;/h2>
&lt;p>同一組條件會被重跑時（重寫、重構、換一個 agent 實作），要能說出這次的產出跟上次是不是同一類。做法是記下行為指紋而不只是「通過了」——原始碼的 checksum 是最粗的一種，更有用的是針對那幾個關鍵維度各跑一次探測（同房間兩個危險時觸發哪一個、同一個 seed 兩次執行結果是否相同、流程中斷後資料停在哪個狀態）。&lt;/p>
&lt;p>指紋改變而條件仍然通過，就是等價類裡的移動；此時要問移動落在哪個維度、那個維度該不該被規定。這比「重跑一次看有沒有過」多帶回一個資訊：&lt;strong>產出在條件的射程之外變了什麼。&lt;/strong>&lt;/p>
&lt;h2 id="邊界等價類大是正常的">邊界：等價類大是正常的&lt;/h2>
&lt;p>需求本來就只該約束外部行為，內部實作留給實作是設計上的正確選擇。等價類大本身不是缺陷，甚至是好的——條件把每一行程式碼都釘死的話，重構會變成不可能。&lt;/p>
&lt;p>要區分的是集合裡的差異落在哪一側：換一個變數名、把三個函式併成一個是自由度；換一個判定順序、換一種終止方式、換一個亂數來源，是需求沒寫。「換一種資料結構」要看情況——延遲或記憶體用量屬於契約時它就不是自由度。判別的問題是這個差異消費者觀察不觀察得到，而消費者是誰、可觀察面包含哪些維度，要先照前面那一節的方式定下來。&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;/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;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>契約性承諾（seed、冪等、排序穩定）只寫在文件裡&lt;/td>
 &lt;td>補成斷言，否則它不是承諾只是說法&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/testing/06-agent-authored-code/test-provenance-independence/" data-link-title="判準的推導來源：測試憑什麼不是實作的回音" data-link-desc="測試與實作由同一次生成或同一輪對話產出時，用來判斷這組測試剩下多少驗證力，以及要動哪個變數才能拿回來">判準的推導來源&lt;/a>&lt;/li>
&lt;li>條件寫好之後怎麼量測試的偵測能力 → &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;/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;/li>
&lt;li>實際量出等價類的案例 → &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;/li>
&lt;li>條件下沉到協議層之後怎麼寫成契約 → &lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/http-contract-test/" data-link-title="HTTP contract test 設計" data-link-desc="HTTP REST API 的 protocol integration test — request/response 格式、status code 語意、error body 結構的驗證">HTTP contract test 設計&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>一組驗收條件通過，證明的是產出落在該條件切出的等價類裡（這裡的等價類指<strong>能通過同一組條件的所有程式</strong>所成的集合，跟測試設計裡的等價類劃分不是同一件事——那個切的是輸入域，這個切的是程式）。等價類是所有能通過這組條件的程式所成的集合，它的大小由條件之間的<strong>交叉密度</strong>決定，不由條件的數量決定。設計驗收條件的工作因此不是「把功能講完」，而是把等價類縮到只剩下真正屬於實作自由度的差異。</p>
<p>閱讀退出之後，「那兩個同時發生怎麼辦」這句話沒有人會順口問了——它原本由讀程式碼的人隨手補上，現在要由條件的設計自己回答。</p>
<h2 id="等價類可以被量出來">等價類可以被量出來</h2>
<p><a href="/blog/testing/cases/acceptance-passes-eight-different-programs/" data-link-title="T.C10 同一組驗收通過八個不同的程式 — 關卡放行的等價類有多大" data-link-desc="同一份需求重寫多次、產出的程式互不相同卻全部通過同一組驗收時，用來看清一組關卡實際固定住了哪些行為，又放過了哪些維度">T.C10</a> 把這件事實際量了一次。產品是 Hunt the Wumpus，一個主控台文字冒險遊戲：玩家在洞穴間移動、房間裡可能有怪物、陷阱或蝙蝠。同一份需求從空目錄寫八次，八個程式全部通過同一組 25 案例的驗收，八棵原始碼樹沒有任何兩棵相同，行數從 331 到 603。</p>
<p>被放行的差異裡，大部分屬於實作自由度——檔案怎麼切、函式怎麼命名、內部用什麼資料結構，這些本來就該留給實作。但有三類不是：</p>
<p><strong>判定順序。</strong> 原規則是先檢查怪物、再陷阱、再蝙蝠。八個版本裡四個照做、三個反過來先檢查蝙蝠、一個的紀錄沒寫順序——兩種順序都通過，因為沒有任何一條驗收案例把兩個危險放進同一個房間。</p>
<p><strong>終止方式。</strong> 一個版本的主迴圈永不結束，一個外層迴圈永不離開，一個以拋出並吞掉例外的方式收場。驗收沒有斷言離開的方式。</p>
<p><strong>亂數來源。</strong> 三種不同的產生器，意味著同一個 seed 參數在不同版本產出不同結果——<code>--seed</code> 沒有成為可攜的契約。</p>
<p>這三類都是需求的一部分，只是沒有被寫進條件裡。<strong>它們與實作自由度的差別在於：改變它們會改變使用者觀察得到的行為。</strong></p>
<h2 id="密度與涵蓋面是兩件事">密度與涵蓋面是兩件事</h2>
<p><strong>同一維度上多加一條斷言，縮小等價類的幅度隨密度遞減；而對一個沒有任何條件碰過的維度，增量是零。</strong> 這條判準的操作含義是補到第十個邊界值的收益遠低於補第一條「兩件事同時發生」。</p>
<p>同一個實驗提供了它的外顯形態：八份程式的單元測試規模從零到 206 個範例，驗收結果卻完全一致——那 25 條驗收條件把每個危險單獨出現時的反應驗了很多遍，兩個危險同時出現一次都沒有，所以判定順序這個維度的增量始終是零。</p>
<p>它同時解釋了一種常見的挫折感——測試一直加、線上還是出事，而出的事「測試裡明明有類似的案例」。類似不等於交叉：同一個維度上的第十個案例，對另一個維度的貢獻仍然是零。</p>
<p>本章引用的量測數字全部來自同一份實驗，它撐得起與撐不起什麼寫在<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>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>這個流程裡它是什麼</th>
          <th>交叉的形態</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>狀態</td>
          <td>使用者當下是登入中、閒置、還是已停用</td>
          <td>對一個登入中的使用者降權，他手上那張 session 怎麼算</td>
      </tr>
      <tr>
          <td>事件</td>
          <td>收到的是升權、降權、還是整組角色置換</td>
          <td>升權與降權在同一個時間窗內各來一次</td>
      </tr>
      <tr>
          <td>順序</td>
          <td>兩次異動的先後</td>
          <td>先降後升與先升後降的最終權限該不該相同</td>
      </tr>
      <tr>
          <td>併發</td>
          <td>兩位管理者同時改同一個人</td>
          <td>兩筆寫入重疊時誰贏、輸的那筆有沒有回報</td>
      </tr>
      <tr>
          <td>邊界</td>
          <td>角色數為零、或一次帶入上限數量的角色</td>
          <td>降到零角色的同時他正在送出一個需要權限的請求</td>
      </tr>
      <tr>
          <td>失敗</td>
          <td>權限服務逾時、或只寫成功一半</td>
          <td>失敗發生在寫入之後、快取失效通知之前</td>
      </tr>
  </tbody>
</table>
<p>換一個流程，六個維度的內容全部要重填——這張表的欄位是問題，不是答案。前四個維度在任何有狀態的系統裡都推得出來；<strong>併發與失敗兩個維度值得單獨說一句</strong>：它們在驗收條件層能寫到的程度比其他四個低，因為驗收多半跑在單執行緒的腳本裡。可行的寫法是把它們降級成「這個交叉發生時使用者該觀察到什麼」的陳述（輸的那筆要收到明確拒絕、而不是靜默覆蓋），把實際的競態驗證留給整合層。</p>
<p><strong>這張表自己有一個盲區，而且是本章方法論的遞歸：空格只存在於已經被列出的維度上，漏掉的維度連格子都沒有。</strong> 找空格證明不了維度列得夠，所以維度清單要有第二個來源——最便宜的是既有的事故與客訴紀錄：逐則問它落在哪個維度，落不進表上任何一格的就是漏掉的那一個。沒有事故史時退而求其次，把表拿給另一個熟悉這個領域、但沒有參與寫條件的人補一輪。</p>
<p>做法是逐條驗收條件標出它涵蓋哪個維度，再看維度兩兩相乘的格子裡哪些是空的——兩兩相乘是第一輪，更高維的交叉怎麼處理見下一節。空格不必全補——判斷依據是那個交叉發生時<strong>消費者觀察得到的行為</strong>會不會不同，會不同就是需求缺口。答「觀察不到」的人要指出那個差異落在可觀察面清單（API 契約、時序、資源使用、錯誤形態、分佈不變量）之外的哪一側；<strong>指不出來、或說不出從哪個介面觀察，就當成需求缺口補條件</strong>——誤補的代價是多一條條件，漏補的代價是那個維度永遠不會再被問起。</p>
<p>消費者不是人的時候這一問要換個問法。下游是程式（函式庫、SDK、資料管線、內部服務）時，可觀察面是 API 契約加上時序、資源使用與錯誤形態——「換一種資料結構」在延遲或記憶體屬於契約的一部分時就不是自由度。下游是統計性流程（模型訓練、報表聚合）時，單筆記錄的差異可能觀察不到而分佈的差異觀察得到，判準要換成<strong>分佈層的不變量</strong>（筆數、空值率、值域、欄位相關性），而不是逐筆的觀察。這一節的判準與上一段的預設是同一條：指不出可觀察的介面就補條件。處置的形態沿用<a href="/blog/testing/06-agent-authored-code/coverage-to-mutation-gate/" data-link-title="品質閘門的更替：覆蓋率、突變分數與各自的射程" data-link-desc="測試數量大幅增加而信心沒有跟著上升、或要為生成的測試挑一個不會被灌水的門檻時，用來決定量什麼，以及那個量的盲區在哪裡">等價突變的時間盒紀律</a>——給判定一個時限，到了就落在有成本的那一側。</p>
<h3 id="攤到幾維才停">攤到幾維才停</h3>
<p>兩兩相乘是起點不是終點。真實系統裡最貴的缺陷常常住在三維以上——年約 × 期末 × 付款失敗、登入中 × 降權 × 快取尚未失效，這類組合在二維表上一格都不會出現。而三維的組合數是二維的好幾倍，全補是不可能的。</p>
<p>停止的判準不是維數，是<strong>代價</strong>：</p>
<ol>
<li>把每一個空著的交叉標一個「它發生時消費者損失多少」——金額、資料正確性、無法回復的操作、對外承諾的違反，取那個系統自己的單位。</li>
<li>由大到小排序，從頂端往下補。</li>
<li>補到「再補一條的成本超過那個交叉的代價」為止，然後停。停下來的位置要寫下來——那是這組條件的射程宣告，不是遺漏。</li>
</ol>
<p>這條判準對維數是中立的：某個三維交叉的代價比所有二維交叉都高時它排在最前面，而多數四維組合會自動落到門檻以下。實務上代價的估算不需要精確，只需要能排序——「這個會讓使用者被多收一期的錢」與「這個會讓錯誤訊息的措辭不一致」之間的差距，不必量化就分得出來。</p>
<p>代價估不出來的交叉另外處理：那通常代表這個情境的業務後果從來沒有人想過，而那件事本身比補一條條件更值得先解決。</p>
<p>這個盤點的副產物往往比測試本身有價值：空格代表需求文件對那個情境沒有交代，而沒有交代的地方通常是產品決策從來沒有被做過的地方。權限異動這個例子裡，「降權時那張 session 怎麼算」多半不在任何文件裡，而它決定了使用者會不會在操作到一半突然被踢掉。</p>
<h2 id="補條件的順序">補條件的順序</h2>
<p><strong>先補改變外部行為的交叉。</strong> 判定順序、狀態轉換的合法性、失敗發生在流程中段時的補償，這些交叉一旦錯了使用者會看見。</p>
<p><strong>再補契約性的承諾。</strong> 這一類是「文件上寫了、而沒有任何一條條件在守」的承諾。付款端點宣稱重試安全，就要有一條條件用同一個冪等鍵送兩次並斷言只扣一次款；分頁 API 宣稱排序穩定，就要有一條條件在兩次查詢之間插入一筆資料並斷言既有頁次的內容不位移。T.C10 裡的 <code>--seed</code> 是同一類的最小形態——宣稱同樣輸入得到同樣輸出，而八個版本用了三種不同的亂數產生器，沒有一條條件發現。</p>
<p><strong>終止與資源釋放要有自己的條件。</strong> 「跑得完」與「跑對」是兩件事，而多數驗收腳本只斷言後者。長時間執行的流程要斷言它會結束、結束時釋放了什麼。</p>
<p><strong>最後才是同一維度上的密度。</strong> 邊界值列表有它的價值，但它排在交叉之後。</p>
<h2 id="把通過的產出當成樣本">把通過的產出當成樣本</h2>
<p>同一組條件會被重跑時（重寫、重構、換一個 agent 實作），要能說出這次的產出跟上次是不是同一類。做法是記下行為指紋而不只是「通過了」——原始碼的 checksum 是最粗的一種，更有用的是針對那幾個關鍵維度各跑一次探測（同房間兩個危險時觸發哪一個、同一個 seed 兩次執行結果是否相同、流程中斷後資料停在哪個狀態）。</p>
<p>指紋改變而條件仍然通過，就是等價類裡的移動；此時要問移動落在哪個維度、那個維度該不該被規定。這比「重跑一次看有沒有過」多帶回一個資訊：<strong>產出在條件的射程之外變了什麼。</strong></p>
<h2 id="邊界等價類大是正常的">邊界：等價類大是正常的</h2>
<p>需求本來就只該約束外部行為，內部實作留給實作是設計上的正確選擇。等價類大本身不是缺陷，甚至是好的——條件把每一行程式碼都釘死的話，重構會變成不可能。</p>
<p>要區分的是集合裡的差異落在哪一側：換一個變數名、把三個函式併成一個是自由度；換一個判定順序、換一種終止方式、換一個亂數來源，是需求沒寫。「換一種資料結構」要看情況——延遲或記憶體用量屬於契約時它就不是自由度。判別的問題是這個差異消費者觀察不觀察得到，而消費者是誰、可觀察面包含哪些維度，要先照前面那一節的方式定下來。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<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>
      <tr>
          <td>驗收條件讀起來已經把功能講完了</td>
          <td>這個感覺不可靠，逐條標維度比通讀可靠</td>
      </tr>
      <tr>
          <td>維度表出現空格而沒有人知道該填什麼</td>
          <td>那是產品決策從未被做過的地方，先把決策做出來再寫條件</td>
      </tr>
      <tr>
          <td>契約性承諾（seed、冪等、排序穩定）只寫在文件裡</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/coverage-to-mutation-gate/" data-link-title="品質閘門的更替：覆蓋率、突變分數與各自的射程" data-link-desc="測試數量大幅增加而信心沒有跟著上升、或要為生成的測試挑一個不會被灌水的門檻時，用來決定量什麼，以及那個量的盲區在哪裡">品質閘門的更替</a></li>
<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/testing/cases/acceptance-passes-eight-different-programs/" data-link-title="T.C10 同一組驗收通過八個不同的程式 — 關卡放行的等價類有多大" data-link-desc="同一份需求重寫多次、產出的程式互不相同卻全部通過同一組驗收時，用來看清一組關卡實際固定住了哪些行為，又放過了哪些維度">T.C10</a></li>
<li>條件下沉到協議層之後怎麼寫成契約 → <a href="/blog/testing/03-protocol-integration-test/http-contract-test/" data-link-title="HTTP contract test 設計" data-link-desc="HTTP REST API 的 protocol integration test — request/response 格式、status code 語意、error body 結構的驗證">HTTP contract test 設計</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></channel></rss>