一組驗收條件通過,證明的是產出落在該條件切出的等價類裡(這裡的等價類指能通過同一組條件的所有程式所成的集合,跟測試設計裡的等價類劃分不是同一件事——那個切的是輸入域,這個切的是程式)。等價類是所有能通過這組條件的程式所成的集合,它的大小由條件之間的交叉密度決定,不由條件的數量決定。設計驗收條件的工作因此不是「把功能講完」,而是把等價類縮到只剩下真正屬於實作自由度的差異。

閱讀退出之後,「那兩個同時發生怎麼辦」這句話沒有人會順口問了——它原本由讀程式碼的人隨手補上,現在要由條件的設計自己回答。

等價類可以被量出來

T.C10 把這件事實際量了一次。產品是 Hunt the Wumpus,一個主控台文字冒險遊戲:玩家在洞穴間移動、房間裡可能有怪物、陷阱或蝙蝠。同一份需求從空目錄寫八次,八個程式全部通過同一組 25 案例的驗收,八棵原始碼樹沒有任何兩棵相同,行數從 331 到 603。

被放行的差異裡,大部分屬於實作自由度——檔案怎麼切、函式怎麼命名、內部用什麼資料結構,這些本來就該留給實作。但有三類不是:

判定順序。 原規則是先檢查怪物、再陷阱、再蝙蝠。八個版本裡四個照做、三個反過來先檢查蝙蝠、一個的紀錄沒寫順序——兩種順序都通過,因為沒有任何一條驗收案例把兩個危險放進同一個房間。

終止方式。 一個版本的主迴圈永不結束,一個外層迴圈永不離開,一個以拋出並吞掉例外的方式收場。驗收沒有斷言離開的方式。

亂數來源。 三種不同的產生器,意味著同一個 seed 參數在不同版本產出不同結果——--seed 沒有成為可攜的契約。

這三類都是需求的一部分,只是沒有被寫進條件裡。它們與實作自由度的差別在於:改變它們會改變使用者觀察得到的行為。

密度與涵蓋面是兩件事

同一維度上多加一條斷言,縮小等價類的幅度隨密度遞減;而對一個沒有任何條件碰過的維度,增量是零。 這條判準的操作含義是補到第十個邊界值的收益遠低於補第一條「兩件事同時發生」。

同一個實驗提供了它的外顯形態:八份程式的單元測試規模從零到 206 個範例,驗收結果卻完全一致——那 25 條驗收條件把每個危險單獨出現時的反應驗了很多遍,兩個危險同時出現一次都沒有,所以判定順序這個維度的增量始終是零。

它同時解釋了一種常見的挫折感——測試一直加、線上還是出事,而出的事「測試裡明明有類似的案例」。類似不等於交叉:同一個維度上的第十個案例,對另一個維度的貢獻仍然是零。

本章引用的量測數字全部來自同一份實驗,它撐得起與撐不起什麼寫在模組入口

量射程的操作方式

把條件攤成維度表,看哪些維度組合沒有被任何一條條件同時碰到。維度自己要先推出來,推法是逐一問「這個系統的行為會隨著什麼而不同」——答案裡每一個獨立的自變數就是一個維度。

下面用一個具名流程走一次,讓推導的結果看得見:帳號權限異動(管理者調整某位使用者的角色,而該使用者可能正在使用系統)。

維度這個流程裡它是什麼交叉的形態
狀態使用者當下是登入中、閒置、還是已停用對一個登入中的使用者降權,他手上那張 session 怎麼算
事件收到的是升權、降權、還是整組角色置換升權與降權在同一個時間窗內各來一次
順序兩次異動的先後先降後升與先升後降的最終權限該不該相同
併發兩位管理者同時改同一個人兩筆寫入重疊時誰贏、輸的那筆有沒有回報
邊界角色數為零、或一次帶入上限數量的角色降到零角色的同時他正在送出一個需要權限的請求
失敗權限服務逾時、或只寫成功一半失敗發生在寫入之後、快取失效通知之前

換一個流程,六個維度的內容全部要重填——這張表的欄位是問題,不是答案。前四個維度在任何有狀態的系統裡都推得出來;併發與失敗兩個維度值得單獨說一句:它們在驗收條件層能寫到的程度比其他四個低,因為驗收多半跑在單執行緒的腳本裡。可行的寫法是把它們降級成「這個交叉發生時使用者該觀察到什麼」的陳述(輸的那筆要收到明確拒絕、而不是靜默覆蓋),把實際的競態驗證留給整合層。

這張表自己有一個盲區,而且是本章方法論的遞歸:空格只存在於已經被列出的維度上,漏掉的維度連格子都沒有。 找空格證明不了維度列得夠,所以維度清單要有第二個來源——最便宜的是既有的事故與客訴紀錄:逐則問它落在哪個維度,落不進表上任何一格的就是漏掉的那一個。沒有事故史時退而求其次,把表拿給另一個熟悉這個領域、但沒有參與寫條件的人補一輪。

做法是逐條驗收條件標出它涵蓋哪個維度,再看維度兩兩相乘的格子裡哪些是空的——兩兩相乘是第一輪,更高維的交叉怎麼處理見下一節。空格不必全補——判斷依據是那個交叉發生時消費者觀察得到的行為會不會不同,會不同就是需求缺口。答「觀察不到」的人要指出那個差異落在可觀察面清單(API 契約、時序、資源使用、錯誤形態、分佈不變量)之外的哪一側;指不出來、或說不出從哪個介面觀察,就當成需求缺口補條件——誤補的代價是多一條條件,漏補的代價是那個維度永遠不會再被問起。

消費者不是人的時候這一問要換個問法。下游是程式(函式庫、SDK、資料管線、內部服務)時,可觀察面是 API 契約加上時序、資源使用與錯誤形態——「換一種資料結構」在延遲或記憶體屬於契約的一部分時就不是自由度。下游是統計性流程(模型訓練、報表聚合)時,單筆記錄的差異可能觀察不到而分佈的差異觀察得到,判準要換成分佈層的不變量(筆數、空值率、值域、欄位相關性),而不是逐筆的觀察。這一節的判準與上一段的預設是同一條:指不出可觀察的介面就補條件。處置的形態沿用等價突變的時間盒紀律——給判定一個時限,到了就落在有成本的那一側。

攤到幾維才停

兩兩相乘是起點不是終點。真實系統裡最貴的缺陷常常住在三維以上——年約 × 期末 × 付款失敗、登入中 × 降權 × 快取尚未失效,這類組合在二維表上一格都不會出現。而三維的組合數是二維的好幾倍,全補是不可能的。

停止的判準不是維數,是代價

  1. 把每一個空著的交叉標一個「它發生時消費者損失多少」——金額、資料正確性、無法回復的操作、對外承諾的違反,取那個系統自己的單位。
  2. 由大到小排序,從頂端往下補。
  3. 補到「再補一條的成本超過那個交叉的代價」為止,然後停。停下來的位置要寫下來——那是這組條件的射程宣告,不是遺漏。

這條判準對維數是中立的:某個三維交叉的代價比所有二維交叉都高時它排在最前面,而多數四維組合會自動落到門檻以下。實務上代價的估算不需要精確,只需要能排序——「這個會讓使用者被多收一期的錢」與「這個會讓錯誤訊息的措辭不一致」之間的差距,不必量化就分得出來。

代價估不出來的交叉另外處理:那通常代表這個情境的業務後果從來沒有人想過,而那件事本身比補一條條件更值得先解決。

這個盤點的副產物往往比測試本身有價值:空格代表需求文件對那個情境沒有交代,而沒有交代的地方通常是產品決策從來沒有被做過的地方。權限異動這個例子裡,「降權時那張 session 怎麼算」多半不在任何文件裡,而它決定了使用者會不會在操作到一半突然被踢掉。

補條件的順序

先補改變外部行為的交叉。 判定順序、狀態轉換的合法性、失敗發生在流程中段時的補償,這些交叉一旦錯了使用者會看見。

再補契約性的承諾。 這一類是「文件上寫了、而沒有任何一條條件在守」的承諾。付款端點宣稱重試安全,就要有一條條件用同一個冪等鍵送兩次並斷言只扣一次款;分頁 API 宣稱排序穩定,就要有一條條件在兩次查詢之間插入一筆資料並斷言既有頁次的內容不位移。T.C10 裡的 --seed 是同一類的最小形態——宣稱同樣輸入得到同樣輸出,而八個版本用了三種不同的亂數產生器,沒有一條條件發現。

終止與資源釋放要有自己的條件。 「跑得完」與「跑對」是兩件事,而多數驗收腳本只斷言後者。長時間執行的流程要斷言它會結束、結束時釋放了什麼。

最後才是同一維度上的密度。 邊界值列表有它的價值,但它排在交叉之後。

把通過的產出當成樣本

同一組條件會被重跑時(重寫、重構、換一個 agent 實作),要能說出這次的產出跟上次是不是同一類。做法是記下行為指紋而不只是「通過了」——原始碼的 checksum 是最粗的一種,更有用的是針對那幾個關鍵維度各跑一次探測(同房間兩個危險時觸發哪一個、同一個 seed 兩次執行結果是否相同、流程中斷後資料停在哪個狀態)。

指紋改變而條件仍然通過,就是等價類裡的移動;此時要問移動落在哪個維度、那個維度該不該被規定。這比「重跑一次看有沒有過」多帶回一個資訊:產出在條件的射程之外變了什麼。

邊界:等價類大是正常的

需求本來就只該約束外部行為,內部實作留給實作是設計上的正確選擇。等價類大本身不是缺陷,甚至是好的——條件把每一行程式碼都釘死的話,重構會變成不可能。

要區分的是集合裡的差異落在哪一側:換一個變數名、把三個函式併成一個是自由度;換一個判定順序、換一種終止方式、換一個亂數來源,是需求沒寫。「換一種資料結構」要看情況——延遲或記憶體用量屬於契約時它就不是自由度。判別的問題是這個差異消費者觀察不觀察得到,而消費者是誰、可觀察面包含哪些維度,要先照前面那一節的方式定下來。

判讀訊號

訊號該做的事
「驗收全過,可以交付」先問這組條件切出的等價類有多大,把維度表攤開看空格
補測試時一直在同一個邊界上加數值該維度已經飽和,換方向補交叉
線上出事而「測試裡有類似的案例」類似不等於交叉,去看那個情境是兩個維度相乘的結果
同一份需求重跑產出的程式差很多,而全部通過記行為指紋、指出差異落在哪個維度、判斷那個維度該不該被規定
驗收條件讀起來已經把功能講完了這個感覺不可靠,逐條標維度比通讀可靠
維度表出現空格而沒有人知道該填什麼那是產品決策從未被做過的地方,先把決策做出來再寫條件
契約性承諾(seed、冪等、排序穩定)只寫在文件裡補成斷言,否則它不是承諾只是說法

下一步路由