🎯 什麼情境該想到我

當你「想把一個類別納入測試,卻發現建構函式裡把物件建立寫死了,測試時既換不掉也不想建」的時候。

書中 p.276 講得很直白:「在你試圖將一個類納入測試時,可能會發現建構函式中的物件建立令你頭疼不已。有時候你並不想在測試的時候建立這些物件。還有些時候你或許只是想放置一個感知物件。然而現實是你沒法做到這一點,因為這些物件的建立被固定在建構函式體中了。」同頁邊註一句話總結:「建構函式中固定了的初始化工作可能會給測試帶來很大的麻煩。」

p.277 的例子裡,某個類別在建構函式中直接建立了一個交易管理員物件。書中說:「如果這個物件是在其他地方被建立的話,我們便能更容易地引入分離。要實現這個條件,選擇之一便是使用提取並重寫工廠方法。」

⚙️ 怎麼用(步驟)

書中 p.278「步驟」小節:

  1. 找出建構函式中的新建物件處。
  2. 將所有涉及新建該物件的程式碼通通轉移到一個工廠方法中。
  3. 建立一個測試子類,重寫剛才的那個工廠方法以避免測試期間對問題類型的依賴。

p.277 對第 3 步的說明:「有了工廠方法之後,便可以將它子類化並重寫了,我們可以在重寫的工廠方法中返回我們想要返回的東西。」

★★ 在 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 的對應:注入一個「建構函式」

// ★ 原始問題:建構函式裡 new 了一個測試環境建不出來的東西
func NewService(dsn string) *Service {
    return &Service{conn: mustConnect(dsn)}    // ★★ 測試時沒有真的資料庫
}
 
// ✅ ★ Go:把「怎麼建立」變成參數(★ 這就是依賴注入)
func NewService(conn Conn) *Service { return &Service{conn: conn} }
 
// ★ 或注入一個工廠函式(★ 當「建立時機」必須延後時)
type Service struct{ newConn func(ctx context.Context) (Conn, error) }

★★ Go 裡這個問題大多不存在,因為 Go 的慣例本來就是「建構函式收依賴」:

★ Feathers 的手法之所以必要,是因為 Java 的建構函式常常 new 一堆東西
  (★ 而那時「建構函式裡的行為」無法被覆寫,見 [[替換實例變數]])
→ ★★ Go 社群的慣例是 func New(deps…) *T ⇒ ★ 問題從一開始就不存在

★★ 所以這一頁在 Go 的價值是「診斷」而非「手法」:
★ 如果你發現 Go 的建構函式裡出現 sql.Open/http.Client{}/os.Getenv,
那就是一個該改成參數的依賴。

★ 一個 Go 特有的例外:init() 裡的建立

// ★★ init() 裡建立的東西無法被測試替換,而且沒有任何注入點
var db *sql.DB
func init() { db, _ = sql.Open("pgx", os.Getenv("DSN")) }   // ★★ 最難測的形態

★ 對策:★★ 改成 func New(...) 並在 main() 裡呼叫(見 登錄器)。
★ init() 的問題不只是測試——★★ 它的跨檔案執行順序不直觀,而且錯誤無處可回。

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 在 C++ 完全行不通。 p.277 邊註:「提取並重寫工廠方法是個相當強大的手法,但它存在一些語言相關的問題。例如它在 C++ 裡面是行不通的。C++ 並不允許在建構函式中的虛擬函式呼叫被決議到衍生類別中去。而 Java 及許多其他語言則允許這一點。」

  • C++ 的替代路線:同段接著說「那麼在 C++ 中怎麼辦呢?可以考慮用替換實例變數(書 p.317)和提取並重寫獲取方法(書 p.278)來代替」,並指出替換實例變數一節有示例及討論。

  • 這招的代價是:為了測試而在正式類別上開一個 protected 的工廠方法,並在測試端多養一個子類別。書中把這視為為了取得分離而付出的必要成本——p.278 步驟第 3 步明講建立測試子類的目的就是「以避免測試期間對問題類型的依賴」。

  • ★★ 這一招在 Go 不成立(無虛擬分派),而且大多不需要——Go 的慣例本來就是「建構函式收依賴」。

  • ★★ 這一頁在 Go 的價值是「診斷」:建構函式裡出現 sql.Open/http.Client{}/os.Getenv → 那是該改成參數的依賴。

  • ★★ init() 裡建立的東西是最難測的形態:無法替換、跨檔案順序不直觀、錯誤無處可回。改成 New() 並在 main() 呼叫。

  • ★ 需要延後建立時,注入一個工廠函式(newConn func(ctx) (Conn, error))。

🔗 相關工具