T.C10 同一組驗收通過八個不同的程式 — 關卡放行的等價類有多大
一組關卡通過,證明的是產出落在該關卡切出的等價類裡,不是它就是需求要的那一個。 這則案例的價值在於等價類的大小被實際量了出來:同一份需求、同一組驗收,八個互不相同的程式全部放行,其中包含真正的行為分岔。
來源與可回溯性
本則是外部案例,來源為 Robert C. Martin 的公開實驗 repo negative-test-experiment。實驗的規則檔、計畫、八格各自的開發筆記與整份結論都在 repo 內可回溯。本頁的數字取自 2026 年 8 月的 experiment-conclusion.md,當時的 HEAD 是 faf9df0(2026-08-17)。
該 repo 仍在演進。 作者若補跑新一輪網格或改寫結論,本頁引用的數字就會與來源脫節,而讀者無從察覺。repo 出現修改到 experiment-conclusion.md 的新 commit 時,要把站內引用該實驗數字的位置全部找出來重新核對一次;rg "negative-test-experiment|331 到 603|97% 到 99%|85.81" content/ 是目前掃得到它們的方式。本頁是這些數字的來源,其他位置引用時指回本頁。
實驗怎麼設計的
產品固定為 Greg Yob 的 Hunt the Wumpus,一個主控台文字遊戲。合規判斷標準固定為一份 25 案例的主控台驗收腳本——八個版本用的是同一份,沒有任何一格可以修改它。
變因有兩個,交叉成一個 4 × 2 的網格,八格各寫出一份完整的程式:
| 不設 CRAP 上限 | CRAP 壓到低於 4 | |
|---|---|---|
| 三法則 TDD | 一格 | 一格 |
| test-last | 一格 | 一格 |
| bundling | 一格 | 一格 |
| 完全不寫測試 | 一格 | 一格 |
第一個變因是測試紀律,四選一:
- 三法則 TDD:Robert C. Martin 的三條規則——沒有失敗的測試就不寫產品碼、只寫到剛好失敗為止的測試、只寫到剛好通過為止的產品碼。它把測試與產品碼的交錯壓到以秒計的循環。
- test-last:先把程式寫完,再回頭補測試。
- bundling:寫完一個函式就替那個函式補測試,粒度介於前兩者之間。
- 完全不寫測試:只寫產品碼,仍然要通過同一份驗收腳本。
第二個變因是要不要對 CRAP 設上限。CRAP(Change Risk Anti-Patterns)是一個把循環複雜度與單元測試覆蓋率合成一個數字的度量:複雜度越高、覆蓋率越低,分數越高。壓到低於 4 等於強制拆函式或補測試,兩條路都會改變程式的形狀。
紀律規則本身是防止抄近路的:每一格從空目錄寫起,禁止複製其他格的程式碼;每格寫完後封存該格的測試套件,再從空的測試套件開始,用突變測試長出第二套。八格的執行者都是 AI agent。
量出來的數字
| 指標 | 值 |
|---|---|
| 驗收結果 | 八格全部 25 通過、0 失敗,包含零單元測試的那一格 |
| 程式相異度 | 八份產出無任何兩份 checksum 相同;程式碼行數介於 331 與 603 之間、函式數介於 33 與 118 之間 |
| 行覆蓋率 | test-last、bundling、三法則+CRAP 三格都落在 97% 到 99%;三法則不開 CRAP 是 outlier,停在 85.81% |
| 零測試那一格 | 覆蓋率 14%(form)/ 24%(line),數字全部來自程式被載入執行、而不是被斷言檢查,驗收仍然全過 |
| 最密的一套測試 | 206 個範例、277 個斷言,而它的驗收結果與零測試那一格完全一致 |
| 突變測試的產出 | 補上運算子層的翻轉(if/if-not、=/not=、1/0),未發現不同的遊戲 |
覆蓋率有兩個計數單位:form 是運算式、line 是程式碼行,兩者是覆蓋率工具的兩種計數單位;同一份程式在兩種單位下的數字不同,所以零測試那一格才會出現 14% 與 24% 兩個值。
三類被放行的行為差異
八份產出的差異裡,有三類超出了實作自由度的範圍——它們是需求的一部分,只是沒有被寫進驗收條件。
危險判定順序。 原作的規則是先檢查 wumpus、再 pit、再 bats。八格裡有四格照做,另外三格的開發筆記記載的順序都是 bats、pit、wumpus,剩下一格的筆記沒有記順序。實際存在的是兩種順序,而驗收案例從來沒有把兩個危險放進同一個房間,所以兩種都合法通過。(repo 的結論表把八格編號 1 到 8:照做的是 3、6、7、8,反序的是 1、4、5,未記錄的是 2。那組編號是該表的欄位、不是本頁的結構,要核對得開 repo。)
終止行為。 一格的主迴圈永不結束,一格的外層迴圈永不離開,一格以拋出並吞掉例外的方式結束。驗收腳本沒有斷言程式該怎麼離開。
亂數來源。 三種不同的產生器(平台內建、兩種不同係數的線性同餘)。這意味著 --seed 參數在八格之間不是可攜的契約——同一個種子在不同格產出不同的地圖。
判讀
等價類的大小由條件之間的交叉密度決定,不由條件數量決定。 二十五條驗收案例讀起來像把功能講完了,實際上它們只約束了「每個危險單獨出現時的反應」。判定順序住在「兩個危險同時出現」這個交叉裡,而沒有任何一條條件走進那裡。驗收這一層內部的維度沒有相乘,這與三層測試分層談的盲區不同——那是層與層之間的縫。
在已經有條件覆蓋的維度上繼續加斷言,每多一條,縮小等價類的幅度就更小;對沒有任何條件碰過的維度,增量是零。 八格的單元測試規模從零到 206 個範例,而驗收結果完全一致。密度成長與涵蓋面成長是兩件事,而套件規模只反映密度成長。
行覆蓋率在這個網格裡鑑別力很低。 test-last、bundling、三法則+CRAP 三格都停在 97% 到 99%,而它們的程式與套件組成完全不同。被覆蓋率區分出來的只有兩端:零測試那一格的 14% / 24%,以及三法則不開 CRAP 的 85.81%(作者標為 outlier,成因是主控台迴圈只被輕度驅動)。覆蓋率分得出「有沒有測試」與「有沒有一整塊沒被驅動」,分不出「測試好不好」。
突變測試守得住斷言密度,守不住規格錯誤。 實驗記載的是突變長出來的第二套測試全部瞄準運算子層的翻轉,作者的結論是它「沒有發現一個不同的遊戲」。由此可以推出而實驗未直接測量的一件事:突變是對照著程式現在的樣子產生的,所以它不會對判定順序產生任何訊號——把 bats 排在判定順序最前面的那份程式跑突變測試,長出來的斷言只會讓那個順序更難被改動。這個限制在測試與實作出處不獨立時特別要緊,因為那正是規格層錯誤最容易產生的情境。
自由度與未規定的需求要分開。 等價類大本身不是缺陷——命名、模組切法、內部資料結構本來就該留給實作。八格之間的檔案切法差異屬於這一類;判定順序與終止行為不屬於。
策略
從這則案例抽出的可重用修法在 #277(設計驗收條件時問哪些不同的程式會通過、交叉優先於單維度密度、把通過的產出當成樣本、指名誰接手了閱讀原本承擔的工作、區分自由度與未規定的需求),這裡不重述。
案例層特有的只有一條:把品質指標配對到它量不到的維度。覆蓋率門檻配突變測試,突變分數再配一次規格層的人工檢查——本則的八個版本正好示範了為什麼單一指標全綠不構成放寬下一層檢查的理由。
下一步路由
- 這則案例抽出的可重用原則 → #277 通過關卡不等於通過的是同一個程式
- 同一份實驗的另一軸(機械約束的代價)→ #278 機械約束買到被量測的那個數字
- 測試與實作同源時 oracle 為什麼退化 → Test Provenance
- Test oracle 本身有哪幾種、各自抓得到什麼 → Test Oracle
- 由測試自己餵資料造成同型盲區的自有案例 → 凍結參照失效被 stub 遮蔽
#testing #case-study #acceptance-test #mutation-testing #ai-generated-code