🎯 什麼情境該想到我
當你「只是想換掉方法裡的某一個呼叫——它會帶來討厭的副作用,或你想感知被傳進去的值」的時候。
⚙️ 怎麼用(步驟)
適用情境(p.275):「許多時候,在測試時遇到的依賴問題都是相當局部的。比如我們可能會遇到一個想要替換掉的方法呼叫。於是若能解開對那個方法的依賴的話,就能夠防止測試帶來古怪的副作用,或可以感知被傳給該呼叫的值。」
核心做法(p.275):「有一個選擇是,將該呼叫提取到一個新方法中,並用一個測試子類來覆蓋它。這一手法也被稱為提取並重寫呼叫。」提取之後,「一旦有了我們自己的 formStyles 方法,便可以通過重寫它來解開依賴了」;因為回傳值和正在測試的東西無關,就可以直接讓重寫版本回傳一個空列表;如果測試需要各種各樣的值,也可以通過修改這個方法來配置(p.276)。
步驟(p.276)
- 確定你想要提取的呼叫。找出它所呼叫的方法宣告,複製其方法簽名以便實施簽名保持(249頁)。
- 在當前類別上建立一個新方法,用剛剛複製的方法簽名來武裝它。
- 把對目標方法的呼叫複製到新方法中,然後在原來的地方改呼叫這個新方法。
書中補充:「如果手頭有自動重構工具,則該手法實施起來簡直太容易了。你只需使用方法提取來對目標方法進行提取即可。然而如果沒有重構工具,則可以考慮如下步驟,遵循它們可以讓你在即使沒有測試的情況下也能安全地完成提取。」(p.276)
★★ 在 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 變大。
★ 所以只對「測試真的需要替換的那幾步」這樣做,不要全部欄位化。
★★ Go 的對應:把那個呼叫換成一個介面欄位或函式欄位
// ★ 原始問題:一個方法中間夾了一個測試環境跑不動的呼叫
func (s *Service) Process(o Order) error {
…
audit.Log(o.ID, "processed") // ★★ 這一行測試時跑不動(要連 audit 服務)
…
}
// ✅ ★ Go:把它變成一個可注入的依賴(★ 介面定義在消費方,見 [[工具-針對介面編程]])
type auditor interface{ Log(id string, ev string) error }
type Service struct{ audit auditor }
func (s *Service) Process(o Order) error {
…
if err := s.audit.Log(o.ID, "processed"); err != nil { … }
…
}★★ 和 Feathers 的做法比,Go 版多了一個好處:
★ 「這個方法依賴什麼」寫在 struct 的欄位上,看得見——
★ 而「提取並重寫呼叫」的做法把依賴藏在一個可覆寫的方法裡,
★★ 從簽章完全看不出來。
★ 只有一個呼叫點時更輕的做法:★★ 函式欄位。
type Service struct{ logAudit func(id, ev string) error }★ 判準:★★ 那個依賴有多個相關操作 → 介面;只有一個 → 函式欄位(見 策略模式)。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
書中最推薦的使用場合:「提取並重寫呼叫是個非常有用的重構手法,我經常會用到它。如果你的目的是解開對全域變數和靜態方法的依賴,它是個理想的選擇。」(p.276)
-
呼叫點太多就別用:「一般來說,如果對於同一個全域對象沒有太多位於不同地點的呼叫的話,我傾向於採用該手法;否則,我往往會採用以獲取方法替換全域引用的辦法。」(p.276)跨多個方法多個類別的情況,書中在 封裝全域引用 一節說「使用封裝全域引用就要乾淨一些了」(p.271)。
-
代價是要開一個測試子類:這條手法靠子類化並重寫該方法來換掉行為(p.275),所以被提取出來的方法必須是可被覆寫的。
-
整章共通的誠實取捨:這些技術是要在沒有測試的情況下使用的,「其目的是為了將測試安置到位」;「這些技術並不能立竿見影地讓設計變得更好」(p.258)。書中也提醒運用前先讀第 23 章,並且「錯誤仍可能發生,因此你應該小心行事」(p.258)。
-
與《重構》的差別:它會用到方法提取(見 提煉函式),但兩者目的不同——書中註明本章手法「在步驟上有所不同,我將之適當剪裁以使得它們能夠被安全地用在沒有測試的情況下」(p.258);這裡提取出方法只是為了造出一個能被測試子類覆寫的接縫。
-
★★ 這一招在 Go 不成立(無虛擬分派)。改成「介面欄位」或「函式欄位」。
-
★ Go 版多一個好處:依賴寫在 struct 欄位上,看得見;而原手法把依賴藏在可覆寫方法裡,從簽章看不出來。
-
★ 判準:那個依賴有多個相關操作 → 介面;只有一個 → 函式欄位。
-
★ 介面要定義在消費方、未匯出、只放用到的方法。