🎯 什麼情境該想到我
當你「想把一個類別納入測試,卻發現建構函式裡把物件建立寫死了,測試時既換不掉也不想建」的時候。
書中 p.276 講得很直白:「在你試圖將一個類納入測試時,可能會發現建構函式中的物件建立令你頭疼不已。有時候你並不想在測試的時候建立這些物件。還有些時候你或許只是想放置一個感知物件。然而現實是你沒法做到這一點,因為這些物件的建立被固定在建構函式體中了。」同頁邊註一句話總結:「建構函式中固定了的初始化工作可能會給測試帶來很大的麻煩。」
p.277 的例子裡,某個類別在建構函式中直接建立了一個交易管理員物件。書中說:「如果這個物件是在其他地方被建立的話,我們便能更容易地引入分離。要實現這個條件,選擇之一便是使用提取並重寫工廠方法。」
⚙️ 怎麼用(步驟)
書中 p.278「步驟」小節:
- 找出建構函式中的新建物件處。
- 將所有涉及新建該物件的程式碼通通轉移到一個工廠方法中。
- 建立一個測試子類,重寫剛才的那個工廠方法以避免測試期間對問題類型的依賴。
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))。