🎯 什麼情境該想到我

當某段程式因為相依了資料庫、外部服務、或建構時就造出具體物件,而無法放進測試用具時。

書把這件事拆成兩章:第 9 章「無法將類放入測試用具中」(連物件都建不出來)和 第 10 章「無法在測試用具中運行方法」(能實例化之後,方法還是跑不了)。這張卡是這兩章的症狀對照表;每個症狀對應的具體手法在第 25 章的型錄裡(見下方連結)。

第 9 章開場就把姿態放低(p.89):

「本章將要討論的是一個困難的問題。如果在測試用具中實例化一個類總是那麼容易的話,本書也就會簡短得多了。遺憾的是情況並非如此。」

⚙️ 怎麼用

起手式:最佳做法就是試一試(p.90)

「要想知道能否在測試用具中實例化一個類,最佳途徑就是試一試。編寫一個測試用例並在裡面建立該類的物件。編譯器會告訴你要令程式碼工作起來還需要哪些東西。」

書把這種測試叫建構測試:「編寫這類測試時我通常並不在裡面放置任何斷言,而只是試著建立物件。最後,當終於能夠在測試用具中建構某類的物件時,我通常會刪掉該測試,或將它重新命名以便能夠用來做一些更為實質性的測試。」(p.90)

A. 無法把類放進測試用具(第 9 章)

書列出四種最常見的問題(p.89):

「(1) 無法輕易建立該類的物件。
(2) 當該類位於測試用具中時,測試用具無法輕易通過編譯建置。
(3) 我們需要用到的建構函式具有副作用。
(4) 建構函式中有一些要緊的工作,我們需要感知到它們。」

接著用七個症狀場景展開:

症狀(節)長相書給的對策
9.1 令人惱火的參數(p.89–95)建構函式要你傳一個很難造的東西(例:一個建構時就會連上伺服器的連線物件;耗時,而且伺服器並不總是處於服務狀態)對該參數型別用 介面提取(285 頁)+做一個輕便的偽類(p.92);或傳 Null(p.93–94);若參數型別中的問題依賴不是硬編碼在其建構函式裡,可用 子類化並重寫方法(314 頁)(p.95)
9.2 隱藏依賴(p.95–98)建構函式自己造了一個難搞的資源(例:郵件服務),依賴藏在建構函式裡參數化建構函式(297 頁)把依賴「外在化」,作者傾向盡可能用它;書列的其他選項是 提取並重寫獲取方法(278 頁)、提取並重寫工廠方法(276 頁)、替換實例變數(317 頁)(p.98)
9.3 建構塊(p.98–100)建構函式裡先造一批物件、再用它們造另一批,你要感知的東西被嵌在這串建立過程中間有能安全提取方法的重構工具就用 提取並重寫工廠方法(276 頁);C++ 不行——建構函式中對虛擬函式的呼叫不會被決議到衍生類(p.99)——改用 替換實例變數(317 頁),再對被換掉的物件用 介面提取實現提取 造偽物件(p.100)
9.4 惱人的全域依賴(p.100–107)到處都在用全域變數/單件,測試前得先把它們設到適當狀態簡單情況:參數化建構函式(297 頁)、參數化方法(301 頁)、提取並重寫呼叫(275 頁)(p.100);單件則先加一個能替換靜態實例的設置方法(p.102),或改加一個「重置以供測試」的方法(p.103);單件在幕後做壞事(如與資料庫通訊)就用 子類化並重寫方法(314 頁)造偽單件(p.104);依賴廣到不行,就對單件做 介面提取(或較省事的 實現提取,281 頁)並把應用中相關引用全改成介面——這整個重構過程即 引入靜態設置方法(292 頁)(p.106)
9.5 可怕的包含依賴(p.107–110)C++ 的標頭檔一層層包進來,測試根本編不起來同一目錄下建測試檔(有預處理時通常比較容易);把原始檔的 include 一個一個加進來,看是否真的需要那些依賴;對難搞的依賴寫「偽定義」,並把偽定義剪到一個單獨標頭檔以便在多個原始檔重複使用(p.108–109)
9.6「洋蔥」參數(p.110–112)要造 A 得先造 B,要造 B 得先造 C……物件裡面包著物件先問「對於那些傳給建構函式的參數,我們真的需要它們嗎?」不需要就傳 Null;只需要一些基本行為就用 介面提取實現提取(p.111)。Java 可以只為子類建一個含有父類方法的介面,不必整條體系都抽;C++ 沒有 Java 那樣的介面,得為純虛函式補一個轉發給父類的定義體(p.111–112)
9.7 化名參數(p.112–114)參數型別在一個繼承體系裡,抽介面會逼你順著體系一路往下建介面別硬抽——「接口的確是解依賴的利器,但如果出現了介面與類幾乎一一對應的情形,設計就變得混亂起來了」(p.113)。先問「為什麼這個依賴是糟糕的」;如果某個類只是有某幾個地方有問題,就用 子類化並重寫方法 只切斷那幾個地方(p.113–114)。若壞依賴和需要的邏輯混在一起,得先做方法提取(p.114)

幾個書中反覆強調的判斷:

  • 偽類長得很醜是正常的(p.93):「〔偽造的連線類〕看起來有點古怪:它的方法要麼是空的要麼就是簡單地返回 null。……這樣一個類似乎違反了所有的良好準則。但你要看到,實際上並非如此。對於一個用來使得測試可行的類,規則是有所不同的。〔它〕中的程式碼並非產品程式碼。」
  • 測試程式碼不需要具有產品程式碼那麼高的品質(p.93,方框):「一般來說,如果將變數設為公有能夠使測試的編寫更為容易的話,我是不會介意這麼做的,儘管這會破壞封裝。不過就算是測試程式碼也應當是乾淨、易於理解和修改的。」
  • 傳 Null 的用法與適用範圍(p.94,方框):「在編寫測試時,如果你發現某個物件需要的某參數難以構造,便可以考慮傳遞一個 null。這樣一來如果這個參數在測試運行的過程中被用到了的話,程式碼就會拋出一個異常,而測試用具則會捕獲這個異常。」但「在 Java 和 C# 中使用起來沒什麼問題,其實只要是那些會對運行期使用空引用拋出異常的語言,這項技術都適用。但這也意味著在 C 和 C++ 中使用這項技術並不是個好主意,除非你知道運行時系統會檢測出空指標的使用。」另外「不到萬不得已千萬別在產品程式碼中傳遞 null」,要避免在程式中傳遞 null 可考慮空物件模式(p.94–95)。
  • 參數化建構函式不會逼所有呼叫方跟著改(p.97):這是人們想不到用它的一個原因,但書說這種想法並不正確——先把建構函式本體提取到一個新方法(提取時運用簽名保持,249 頁,因此沒有測試保護也相當安全),再額外提供一個簽名與原先一模一樣的建構函式,「客戶程式碼便可以維持原樣,無需關心我們作了哪些改動」。
  • 替換實例變數是最後手段(p.100):「除非萬不得已,否則我是不願使用替換實例變數手法的。這一做法很可能帶來一些資源管理方面的問題。」C++ 要先把舊物件處理掉(p.99);而且「別在產品程式碼中使用這一技術」(p.99)。作者仍會在 C++ 用它,因為提取並重寫工廠方法在 C++ 的建構函式中用不了(p.100)。
  • 單件為什麼難搞、以及可以放鬆到什麼程度(p.102–104):「單件模式的核心理念便是使人們無法在應用中建立一個以上單件類的實例。這在產品程式碼中或許是件好事,然而到了測試中可能就成了一場災難:一套測試中的每個測試在某種程度上都應當被看成是一個小型的應用,互相之間應當完全隔離開來。」書列出「真的需要唯一實例」的三個常見原因(現實世界只有一個、建立多個會導致嚴重問題、建立多個會用掉過多資源),然後指出「人們常常會為了建立全域變數而使用單件模式」——若是後者,「其實並沒有什麼理由一定要保持其單件性」,把建構函式改設為受保護的、公用的或包作用域的即可。要補保護就在建置系統加檢查(搜尋所有原始檔,確保那個測試用設置方法沒被非測試程式碼呼叫),或做執行期檢測(p.104)。若改用子類化的做法,建構函式設為受保護即可,不必設成公用(p.105)。
  • 靜態設置方法 vs 重置方法怎麼選(p.103,方框):如果單件類的公用方法允許你在測試中任意設置其狀態,「重置以供測試」的方法就行得通;如果沒有這些公用方法、或它用了一些影響其狀態的外部資源,「引入靜態設置方法就是個較好的選擇了」。
  • 9.5 那一招要留給極端情況(p.110):這種寫偽定義的做法「有幾個非常嚴重的缺點」——測試得單獨建置成一個程式、並沒有在語言層面把依賴解開、重複定義只要測試還在就必須保留。「這一技術適合留給類非常巨大且依賴非常嚴重的時候。」

B. 類建得出來,但方法跑不了(第 10 章)

實例化通常只是第一步,但有時可以跳過它直接進第二步(p.115):待修改的方法沒用到多少實例變數 → 暴露靜態方法(273 頁);方法相當長且難於對付 → 分解出方法對象(261 頁),把程式碼搬到一個較容易實例化的類裡。

書列出的問題(p.115):

「・無法在測試中存取那個方法。比如說,它可能是私有的,或者有其他可存取性限制。
・無法輕易地呼叫那個方法,因為很難建構呼叫它所需的參數。
・那個方法可能會產生糟糕的副作用(如修改資料庫、發射一枚巡航導彈,等等),因而無法在測試用具中運行它。
・我們可能會需要通過該方法所使用的某些物件來進行感知。」

症狀(節)長相書給的對策
10.1 隱藏的方法(p.115–118)要改的方法是私有的(或有其他存取限制)先看能不能透過公用方法測;正面答案是把它設為公用;不方便公開就把它移到一個新類當中去、在新類上設為公用;當下負擔不起分解職責的風險,才退而把存取權限從私有改為受保護,再子類化取得存取權(p.115–117)
10.2「有益的」語言特性(p.118–121)參數的型別來自你控制不了的類庫,沒有公用建構函式,又被 sealed/final 封死,無法建立實例也無法派生不能用 介面提取(285 頁)/實現提取(281 頁),只能用 參數適配(258 頁)(p.119);若那個封閉類有一個非封閉的基底類,可子類化基底類再把物件傳進去,「借助於依靠編譯器(251 頁)技術,我們的修改既安全又容易」(p.119);真的建不出實例,就用剝離並外覆 API(169 頁):提取一個介面 + 寫一個外覆類,測試時換上偽類(p.120)
10.3 無法探知的副作用(p.121–126)方法什麼都不回傳,只是去戳別的物件,沒有合適的地方可以感知這段程式碼做了什麼(典型:GUI 事件處理器裡塞滿業務邏輯)一連串方法提取(325 頁)+ 子類化並重寫方法,見下方五步(p.122–126)

10.3 的處理順序(p.122–126)

  1. 把整個方法體提取出來,就可以完全脫離對事件參數類的依賴(p.122)。
  2. 把要用到的協作物件設成該類的實例變數,再把使用它的程式碼提取成一組方法(p.123)。
  3. 命名時不要用顯示元件的名字:「可以在提取出的程式碼中使用顯示元件,但其方法名卻應當隱藏這一事實。」(p.123)並照命令/查詢分離(最先由 Bertrand Meyer 提出)把提取出的每塊做成命令式方法或查詢式方法——「一個方法要麼是一個命令,要麼是一個查詢,但不能兩者都是。」(p.123–124)
  4. 子類化並重寫這些新方法,測試就能看到原本看不到的副作用(p.125)。
  5. 最後才依「哪些方法只用到哪個成員變數」把它們看成互相獨立的職責、分到不同類去(p.126)。

書對這種粗糙產物的態度(p.126):

「如果目的是為了讓測試能夠安置到位的話,提取出具有糟糕名字或糟糕結構的方法是可以接受的。畢竟,安全才是第一位。在測試到位之後,就可以放心著手讓程式碼變得更清爽了。」

🧪 我實際套用的紀錄

  • 2026-07-14:(待填)

⚠️ 注意

  • 好的設計應當是可測試的(p.116):「好的設計應當是可測試的,不具可測試性的設計是糟糕的。」私有方法測不到,「大多數情況下便意味著我們的類做的事情太多了」。
  • 先求「能測」的最小改動,別在沒安全網時做大改造。 要不要順手把職責分解掉,「取決於當前我們在整個產品發布週期中所處的階段,有無足夠時間以及所有相關的風險」(p.117);現有測試不多時「就不得不小心行事,先做一些其他工作,然後再開始分解」(p.116)。
  • 削弱封裝是有意識付出的代價(p.118):把私有改成受保護,「坦白地說,我並不介意這麼做。對我來說,這樣做令我們能夠編寫測試,是一宗公平交易」,但「當我們在分析程式碼是如何工作的時候,就得把〔它的〕子類也能呼叫〔該方法〕這一事實也考慮進去」。
  • 不要用反射作弊(p.118,方框):「我可不喜歡將存取私有變數的測試程式碼留在專案中。因為這種以『欺騙』手段存取私有變數的方式會蒙蔽團隊的眼睛,使他們看不到程式碼變得有多糟糕。」
  • 子類化並重寫時別把要測的行為改掉了(p.95):「子類化並重寫方法在有些場合下是非常有用的解依賴手段,但在使用的時候我們得注意別把想要測試的行為給篡改了。」
  • 選手法的順序參數化建構函式「在解開建構函式中隱藏的依賴方面是一項易用的技術,而且它往往也是我第一個訴諸的技術」,但建構函式裡建立了大量物件或存取大量全域變數時會導致參數列表過長,這時才換路(p.98)。
  • 全域依賴:把測試安置到位 ≠ 解決問題(p.106):引入靜態設置方法「即便是在面對廣泛的全域依賴的情況下,我們也可以運用該技術來將測試安置妥當。然而遺憾的是,它對全域依賴的狀況並無多大改善。」真要解決得靠 參數化方法(301 頁)與 參數化建構函式(297 頁),各有代價(前者類上多出很多額外方法影響理解、後者每個物件多一個成員變數,太多的話會影響應用的記憶體使用量)。
  • 如果一個全域變數真的到處都在用(p.107):「這便意味著你的程式碼沒有進行任何層次化設計。」多數情況下全域變數只是全域可存取,並沒有被全域使用。
  • 依賴不由自己控制的庫是自找的(p.121):「真正的錯誤卻出在我們自己身上,是我們自己選擇直接依賴於不由我們控制的庫的,這一舉動等於是在自尋煩惱。」「當我們需要使用標記為 sealed 或 final 的類時,最好將它們隔離在一層外覆類後面。」外覆的代價是產品程式碼得先遍歷原先的容器,把每個物件都打包進外覆物件——「這就是安全性所要付出的代價」(p.121)。

🔗 相關工具