論述基礎與限制

本卡的論述基於一個 Dart 專案的測試 bug 追因:效能基準測試炸出 InvalidBookIdException,追到 Arrange 段一行「建一個立刻丟棄的物件、只為拿它的一個欄位」的拼裝寫法,而同型寫法幾天前才在另一個檔案修過一次。完整 case 見 copyWith 是逃生口,不是設計、教學層展開見 建構路徑設計。具體限制:

  • 實證是同一專案內的兩次復發。兩次足以確立「會復發」、不足以量化復發率跟拼裝點數量的關係。
  • 逃生口形態單一。case 裡的逃生口是全欄位 copyWith;setter 氾濫、測試裡直接 new 繞 factory、config 物件手工拼裝是同機制的其他形態,本卡的機制推論涵蓋它們、實證沒有。

核心原則

當存在一個「總有辦法把物件拼出來」的萬能出口,上游建構路徑的表達力缺陷永遠不會被迫修好。 需求進不了工廠,就從逃生口出去;建構路徑的缺陷被逃生口吸收,然後以語意錯誤的形式在別處復發。

機制分三步。第一步,工廠表達不了需求:createForTest 只收 id / title / author / isbn,測試想要「預設 tags 再追加三個自訂 tag」,工廠給不了。第二步,逃生口接住需求:copyWith 總是能拼,於是它成為唯一出路。第三步,拼裝現場的最短路徑埋下語意錯誤:在用 copyWith 拼的當下,順手建個臨時物件撈預設值(Book.createForTest(id: 'tmp').bookTags)是阻力最小的寫法——而 'tmp' 不滿足 ID 的 value object 約束,或者臨時物件的其他欄位跟呼叫端指定的值靜默不一致。

關鍵是第二步的因果方向:逃生口不只是讓錯誤寫法變可能,它讓正確的修法變不必要。 沒有逃生口時,工廠表達力不足會立刻擋住需求、逼出「幫工廠加參數」的修法;有逃生口時,每個被擋的需求都繞出去了,工廠的缺陷永遠不會痛、也就永遠不會修。

修法對準上游:讓工廠能表達需求(createForTest 接受 bookTags),拼裝的動機就消失了。修每一個拼裝點——把 'tmp' 改成合法長度、把臨時物件改成取自己的欄位——只消掉當次症狀,下一個測試還是會在同一個表達力缺口前面選擇拼裝。


具體 case

case 1:同型錯誤兩個檔案各一次

測試資料產生器先犯:copyWith(bookTags: [...Book.createForTest(id: bookId).bookTags, ...]) 用全新預設書籍的 tags、丟棄呼叫端指定的 author / isbn。幾天後效能基準測試再犯一次結構相同的寫法、換成炸 ID 長度。兩個作者(或同作者兩個時刻)在同一個表達力缺口前面、各自獨立走出同一條最短路徑——這不是巧合、是結構在選擇行為。

case 2:最小修法會留下語意錯誤

炸例外那行的最小修法是把 'tmp' 改成 'tmp-12345'。例外會消失,但「建臨時物件撈欄位」的語意錯誤原封不動——臨時物件的預設值跟 base 物件的實際值之間的靜默不一致繼續存在。症狀層修法的典型形態:改完之後看起來全好了、缺陷完整保留。


沒這樣做的麻煩

每個新測試都是一次擲骰

表達力缺口不修,每個需要「預設值加自訂欄位」的新測試都會重新面對同一個選擇,而逃生口永遠是阻力最小的選項。復發次數隨測試數量線性成長、沒有上限。

修拼裝點的成本隨呼叫點擴散

拼裝寫法會被複製——測試檔之間互相參考是常態。等到決定要修時,同型寫法已散在多個檔案,每一處都要獨立辨識與修正;而修工廠只要改一個簽名。

錯誤在遠離根因的地方浮現

拼裝點的語意錯誤不在拼裝當下報錯:它以「效能測試炸例外」「測試資料跟預期不符」的形態在下游浮現,診斷路徑要從症狀反推到拼裝寫法、再反推到工廠表達力,比缺陷本身長得多。


跟其他抽象層原則的關係

  • #222 約束要讓違反路徑走不通:同 case 抽出的 sibling、分工不同。#222 講意圖的強制層次(文件層意圖擋不住繞過),本卡講缺陷的轉移機制(逃生口讓上游缺陷不痛、在下游復發)。關逃生口(#222 的修法)同時是本卡機制的斷路器——出口關了、表達力缺口才會痛、才會被修。
  • #42 2 次門檻:第一次是運氣、第二次是訊號:本卡的觸發判準。同族語意錯誤第一次出現可以當個案修掉、第二次出現就該停止修拼裝點、轉頭找它們共同面對的表達力缺口。case 1 的兩次復發正是這個門檻被跨過的時刻。
  • #64 Feature 操作要跟 Source 同層合成:同構的修法方向。#64 說 stream 操作要在 source 同層或更上游合成、不在下游補;本卡說物件建構的表達需求要在工廠層滿足、不在呼叫端拼裝。兩者共享「下游補丁會複製、上游修一次」的成本結構。
  • #86 Capability gap 的三層對策階梯:修工廠表達力是 #86 的 L3(structural rebuild)、修拼裝點是 L1/L2(期望對齊 / 補強)。#86 說不必每次跳 L3,本卡補上「該跳 L3」的訊號:同型缺陷第二次出現(#42)、且 L1/L2 的修法不減少未來復發的機率。
  • #67 寫作便利度跟意圖對齊反相關:第三步「拼裝現場的最短路徑」是 #67 的實例——臨時物件撈預設值比先存 base 變數再取欄位少一行,便利選項跟正確選項分離時、便利贏。本卡的上游修法讓兩者重合:工廠直接收 bookTags 之後、正確寫法就是最短寫法。

判讀徵兆

徵兆該做的行動
建一個立刻丟棄的物件、只為了拿它的一個欄位語意錯誤的標記——找它真正想表達的需求、檢查工廠給不給得了
測試 Arrange 段大量出現「工廠 + copyWith 拼裝」盤點拼裝在補什麼欄位、把高頻欄位收進工廠參數列
同族語意錯誤第二次出現停止修個案、找兩個案共同面對的建構路徑缺口
修測試 bug 時最小修法是「把值改合法」先問這個值為什麼會出現、症狀層修法會保留語意錯誤
工廠參數列長期不變、而它的產物被 copyWith 環繞工廠的表達力已落後需求、繞道流量就是需求清單

最後一項可以反過來用:盤點某工廠產物的 copyWith 呼叫點在覆寫哪些欄位,就是一份「工廠該收而沒收的參數」的實證清單——繞道流量本身是免費的需求調查。


適用範圍與邊界

  • 適用:測試建構路徑(factory / fixture / builder)、任何「官方建構器 + 萬能拼裝出口」並存的結構——config 物件、API request builder、ORM model 的測試工廠。
  • 邊界
    • 一次性特殊組合不值得擴工廠:只出現一次的欄位組合用拼裝是合理的,工廠 API 也有膨脹成本(over-parameterized factory 是反向的問題)。門檻同 #42:第二次出現才收進工廠。
    • 逃生口本身不是錯:value object / DTO 上的 copyWith 是正當工具(見 #222 的判準)。本卡談的是逃生口跟建構路徑並存時的缺陷轉移機制,不主張消滅逃生口。
    • 表達力也可能該止步:工廠若要表達所有組合、它會退化成另一個全欄位建構器。工廠參數列收的是「有語意的需求」(預設 tags 加自訂)、不是「所有欄位的任意值」。