🎯 什麼情境該想到我
當你「想測一個方法,但它裡面夾了一段在測試環境下根本跑不起來、或你根本不關心的行為」的時候。
這是全書解依賴技術的核心:「子類化並重寫方法是物件導向程式中解依賴的核心技術。實際上本章所講的許多其他的解依賴手法都是該手法的變種。」(p.314)
核心理念只有一句:「你可以在測試環境下利用繼承來將並不關心的行為架空,或存取到你所關心的行為。」(p.314)——也就是同時服務分離(把礙事的依賴切掉)與感知(讓你看得到內部發生了什麼)兩個目的。
書中的例子:MessageForwarder 有個私有方法 createForwardMessage,測試時不想依賴 MimeMessage 類別,「因為 MimeMessage 用到了一個叫做 session(會話)的變數,而在測試的時候是沒法建構出一個真正的 session 出來的」(p.315)。解法是把 createForwardMessage 設為受保護的,然後在一個測試用子類別中重寫它。
代價有多小?「在產品程式碼中,我們實例化的是 MessageForwarder;而在測試程式碼中,實例化的則是 TestingMessageForwarder。你看,我們以最少的修改換來了所需要的分離。實際上只不過是將 createForwardMessage 的存取權限從私有換成了受保護的。」(p.315)
⚙️ 怎麼用(步驟)
書中列出的步驟(p.316–317):
- 找出你想要分離出來的依賴,或者想要進行感知的地點。找出盡量少的一組方法來完成你的目標。
- 確定了重寫哪些方法之後,還得確保它們都是可重寫的。 這一步根據語言的不同有所不同:C++ 中首先要將它們設為虛擬函式;Java 中它們必須是非 final 方法;而在許多 .NET 語言中,你還得明確地將這些方法設為可重寫的。
- 在某些語言中,你需要調整這些方法的存取權限才能在子類別中重寫它們。 比如在 Java 和 C# 中,必須至少是受保護的方法才能被子類別中的方法重寫;而在 C++ 中私有虛擬函式仍然是可以在子類別中重寫的。
- 建立一個子類別並在其中重寫這些方法。確保你的確能夠在測試用例中建構該類別。
「疊紙視圖」——怎麼找出該重寫哪個方法
Feathers 描述了他自己在腦中用的一個視覺化技巧(p.316):
「我看著一個方法,然後在腦海裡設想所有可用於將其語句和表達式分組的方式。對於一個方法中我可以確定出來的幾乎任何細小的程式碼片段,我猜想能否將其提取到一個方法中,進而在測試中將其替換為另一個方法。這就好像我將一層半透明的紙放在程式碼之上,在這層半透明的紙上我可以放置用於替換目標程式碼片段的程式碼。這疊紙就是我的測試物件,而從上往下看到的方法便是會在測試中被執行到的方法。」
書中的圖 25-6 標題就是「TestingAccount 浮於 Account 之上」(p.316)。
但他馬上補了一句重要的紀律:「疊紙視圖法能夠幫助我看到什麼是可能的,但當我真正開始使用子類化並重寫方法手法時,仍然還是盡量去重寫既有的方法。畢竟我們的目的是將測試安置到位,而在沒有測試的情況下提取方法常常是危險的。」(p.316)——想像可以很自由,實際下手要保守。
★★ 在 Go 裡:這一招不成立,因為 Go 沒有虛擬分派
// ★ Go 的嵌入看起來像繼承,★★ 但方法呼叫不會分派到外層型別
type Base struct{}
func (b Base) Hook() string { return "base" }
func (b Base) Run() string { return "run: " + b.Hook() } // ★★ b 永遠是 Base
type Testing struct{ Base }
func (t Testing) Hook() string { return "fake" }
Testing{}.Run() // ★★ "run: base" —— ★ 不是 "run: fake"★★ 所以「在測試子類別裡覆寫方法」這整套手法在 Go 完全無效——
★ 而且它是靜默無效:★★ 程式碼編譯得過、測試跑得過,
只是你以為被架空的那段其實還在真的執行。
→ ★ 這是從 Java/C# 轉 Go 最容易踩的一個坑(見 工具-Go嵌入機制)。
★★ Go 的等價做法:把「可變的那一步」變成一個欄位
// ★ Go 的 seam 是「函式值」或「介面」,而不是「可覆寫的方法」
type Forwarder struct {
// ★★ 原本要被覆寫的那一步,變成一個可注入的欄位
buildMessage func(Mail) (Message, error)
now func() time.Time
send func(context.Context, Message) error
}
func New(opts ...Option) *Forwarder {
f := &Forwarder{
buildMessage: realBuildMessage, // ★ 生產環境的預設值
now: time.Now,
send: realSend,
}
for _, o := range opts { o(f) }
return f
}// ★ 測試裡替換那一步 —— ★★ 不需要繼承、不需要 mock 框架
f := New(WithBuildMessage(func(Mail) (Message, error) {
return Message{ID: "fixed"}, nil
}))★★ 三個具體優勢:
① ★★ 編譯期就知道「哪些步驟是可替換的」—— ★ 它們是欄位,看得見
② ★★ 不需要為了測試放寬封裝(★ Feathers 那招要把 private 改成 protected)
③ ★ 生產路徑與測試路徑是同一段程式碼,★★ 只有那一個欄位不同
⚠️ 代價:★ 函式欄位比方法多一次間接、★★ 而且會讓 struct 變大。
★ 所以只對「測試真的需要替換的那幾步」這樣做,不要全部欄位化。
★ Feathers 的紀律在 Go 裡仍然成立
★ 書中兩條紀律換成 Go 的說法:
① ★★ 「盡量重寫既有的方法」→ ★ Go:盡量把「既有的一整步」換成欄位,
而不是先做方法提取(★ 沒有測試時提取是危險的)
② ★★ 「架空掉的東西必須真的是這個測試不關心的」
→ ★ 在 Go 更明顯:★★ 你注入的假函式回傳什麼,就是測試的前提
→ ★ 若那個前提不成立,★★ 測試會通過而程式是錯的
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
難易程度取決於原本的功能分解好不好。 「通常,一個類別當中的功能分解的好壞決定了你使用該手法來分離依賴時的難易程度。好的情況下,你想要分離出的依賴會被隔離在一個小小的方法當中。而在糟糕的情況下,你可能就需要重寫一個較大的方法才能分離出依賴了。」(p.315)方法越大,你架空掉的東西就越多,風險就越高。
-
它很強,所以要小心用。 「子類化並重寫方法是個強大的手段,但用的時候也需小心。」(p.316)書中拿自己的例子檢討:「比如在前面的例子中我可以返回一個沒有主題、寄件人地址等資訊的空消息物件,但這麼做是有特定前提的——比如我正在測試的是能否將一個消息從系統中的一個地方傳到另一個地方,而並不關心消息的具體內容和地址,這時候這麼做才是安全的。」(p.316)架空掉的東西必須真的是這個測試不關心的東西,否則你測到的是假象。
-
你會為了測試放寬封裝。 書中的做法就是把私有方法改成受保護的(p.315)。這確實讓設計「暫時變差」——一個原本封裝良好的實作細節,因為測試而被打開成可被繼承覆寫的擴充點。Feathers 的立場是:這是換取測試安置到位的最小代價,而且它確實是代價,不是免費的。
-
語言差異會咬你。 步驟 2、3 列的差異不是註腳:C++ 要虛擬函式、Java 要非 final、.NET 多半要明確標記可重寫;存取權限規則 Java/C# 與 C++ 又不同(p.316–317)。
-
建構函式裡的行為它救不了。 在不支援建構函式中呼叫虛擬函式的語言(如 C++)裡,光子類化並重寫方法是無效的——書中在替換實例變數一節專門示範了這個陷阱(p.317–318)。
-
別為了用它而先做方法提取。 沒有測試保護時提取方法本身就是危險動作(p.316)。
-
★★ 這一招在 Go 完全無效,而且是靜默無效:嵌入沒有虛擬分派,
Testing{}.Run()仍然呼叫Base.Hook()。編譯過、測試過,但你以為被架空的那段還在真的執行。 -
★★ Go 的 seam 是「函式值欄位」或「介面」,不是「可覆寫的方法」。
-
★ 只對「測試真的需要替換的那幾步」欄位化,不要全部欄位化(會讓 struct 變大、多一次間接)。
-
★ 注入的假函式回傳什麼,就是測試的前提。 前提不成立 → 測試通過而程式是錯的。
-
★ Go 裡不需要為了測試放寬封裝(Feathers 那招要把 private 改成 protected)。
🔗 相關工具
- 工具-打破依賴以便測試 — 這條技術屬於的大類
- 以獲取方法替換全域引用 — 先做出獲取方法,再靠本手法在測試子類別中重寫它(書 p.313)
- 替換實例變數 — 當語言不允許在建構函式中分發虛擬函式時,本手法失效,改用它(書 p.317–318)
- 模板重定義 — C++ 中不走繼承的另一條路;但書 p.322 說仍以基於繼承的手法為優先
- 換函式為函式指標 — 過程式語言(C)裡用不了本手法時的替代方案(書 p.310)
- 回連 修改程式碼的藝術
- 工具-Go嵌入機制 —— ★ 為什麼 Go 沒有虛擬分派
- 換函式為函式指標 —— ★ Go 的等價做法
- 範本方法模式 —— 同樣在 Go 不成立
- 工具-針對介面編程 —— 介面定義在消費方