🎯 什麼情境該想到我

當你「只是想換掉方法裡的某一個呼叫——它會帶來討厭的副作用,或你想感知被傳進去的值」的時候。

⚙️ 怎麼用(步驟)

適用情境(p.275):「許多時候,在測試時遇到的依賴問題都是相當局部的。比如我們可能會遇到一個想要替換掉的方法呼叫。於是若能解開對那個方法的依賴的話,就能夠防止測試帶來古怪的副作用,或可以感知被傳給該呼叫的值。」

核心做法(p.275):「有一個選擇是,將該呼叫提取到一個新方法中,並用一個測試子類來覆蓋它。這一手法也被稱為提取並重寫呼叫。」提取之後,「一旦有了我們自己的 formStyles 方法,便可以通過重寫它來解開依賴了」;因為回傳值和正在測試的東西無關,就可以直接讓重寫版本回傳一個空列表;如果測試需要各種各樣的值,也可以通過修改這個方法來配置(p.276)。

步驟(p.276)

  1. 確定你想要提取的呼叫。找出它所呼叫的方法宣告,複製其方法簽名以便實施簽名保持(249頁)。
  2. 在當前類別上建立一個新方法,用剛剛複製的方法簽名來武裝它。
  3. 把對目標方法的呼叫複製到新方法中,然後在原來的地方改呼叫這個新方法。

書中補充:「如果手頭有自動重構工具,則該手法實施起來簡直太容易了。你只需使用方法提取來對目標方法進行提取即可。然而如果沒有重構工具,則可以考慮如下步驟,遵循它們可以讓你在即使沒有測試的情況下也能安全地完成提取。」(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 欄位上,看得見;而原手法把依賴藏在可覆寫方法裡,從簽章看不出來。

  • ★ 判準:那個依賴有多個相關操作 → 介面;只有一個 → 函式欄位。

  • ★ 介面要定義在消費方、未匯出、只放用到的方法。

🔗 相關工具