🎯 什麼情境該想到我

當你「想單獨對付某塊程式碼,卻發現全域變數擋在你的路上」的時候。(p.313)

書上對全域變數的害處沒有再展開,只回頭指向引入靜態設置方法那一節「作了相當完整的敘述」(p.313)。這裡談的是解法:「要想解開一個類別裡面對全域變數的依賴,方法之一是在該類別中為每個相應的全域變數引入一個獲取方法。有了這些獲取方法,便可以通過子類化並重寫方法(314 頁)來令它們返回測試用物件了。不過也有些情況下你可能會需要動用介面提取(285 頁)。」(p.313)

靜態方法呼叫也算全域引用。 書中用一個 Java 例子舉證:RegisterSale 透過 Inventory 類別上的靜態方法取得庫存物件。Feathers 自問自答:「『什麼?』你可能會嚷起來『這不明明是個靜態方法呼叫嗎?』沒錯。但從我們的意圖(測試)來說,它其實就相當於一個全域變數。在 Java 中,該類別本身就是一個全域物件,而且似乎它還需要引用一些狀態才能完成它的工作。」(p.313)

⚙️ 怎麼用(步驟)

書中列出的步驟(p.314):

  1. 找出你想要替換的全域引用。
  2. 給它編寫一個相應的獲取方法。確保該獲取方法的存取權限允許你在派生類別中重寫它。
  3. 將對全域物件的引用替換為對該獲取方法的呼叫。
  4. 建立測試子類別並重寫獲取方法。

書中示範時特別提醒:「注意,我們將該方法設為受保護的,這樣才能在測試子類別中重寫它。」(p.313)

書中的完整流程還多做了一步:因為被引用的 Inventory 是個單例類別,「因此需要先降低其建構函式的存取權限為受保護的」,才能從它派生出一個 FakeInventory 放進測試用的邏輯;最後才寫 RegisterSale 的測試用子類別,在裡面回傳這個假物件。(p.314)

★★ 在 Go 裡:它的第二步不成立,所以要換一個做法

★ 原手法的兩步:
① ★ 把 someGlobal 的直接引用換成一個 getter 方法 getSomeGlobal()
② ★★ 在測試子類別裡覆寫那個 getter ← ★ 這一步在 Go 不成立(無虛擬分派)
// ★★ Go 的做法:★ 第一步相同(封裝成一個可替換的取得點),
//    ★★ 但第二步改成「函式欄位」或「介面欄位」
type Service struct {
    getConfig func() Config       // ★ 取代「可覆寫的 getter」
}
func New() *Service { return &Service{getConfig: cfg.Get} }   // ★ 指向真的全域
 
// ★ 測試
s := &Service{getConfig: func() Config { return Config{Timeout: time.Second} }}

★★ 這個做法的三個具體好處:

① ★★ 依賴變成可見的(★ 它是一個欄位)
② ★★ 不需要為了測試放寬封裝(★ 原手法要把 private getter 改成 protected)
③ ★ 生產路徑與測試路徑是同一段程式碼

★★ 但更好的做法通常是「不要 getter,直接注入值」

// ★ 中間形態:注入一個 getter
type Service struct{ getConfig func() Config }
 
// ✅ ★★ 終點形態:直接注入值(★ 若那個值在一次呼叫內不會變)
type Service struct{ cfg Config }
func New(cfg Config) *Service { return &Service{cfg: cfg} }

★★ 判準:★ 那個值會在「這個物件的生命週期內」改變嗎?
不會(多數設定)→ ★ 直接注入值(★★ 最簡單,而且不可能拿到不一致的兩次讀取)。
會(★ 動態 feature flag、熱重載的設定)→ ★★ 注入 getter。

★★ 「注入 getter」的一個隱性風險:★ 同一個請求裡呼叫兩次可能拿到不同的值
→ ★ 對策:★★ 在請求開始時讀一次,把值傳下去(見 以查詢取代暫時變數 的反向)。

★★ Go 的一個具體場景:time.Now 與 os.Getenv

// ★★ 這兩個是最常見的「隱形全域引用」
func (o *Order) IsOverdue() bool {
    return o.due.Before(time.Now())        // ★★ 全域的「現在」
}
if os.Getenv("FEATURE_X") == "1" { … }     // ★★ 全域的設定
 
// ✅ ★ 注入
func (o *Order) IsOverdue(now time.Time) bool { return o.due.Before(now) }
type Service struct{ featureX bool }        // ★ 啟動時讀一次

★★ time.Now() 是全 Go 生態最常見的未封裝全域引用,
★ 而它的代價很具體:★★ 「月底」「閏年」「跨時區」「DST」這些情境無法寫測試。
→ ★ 見 換函式為函式指標、參數化方法。

★ 遷移的順序(★ 每一步可停、可 commit)

第 1 步 ★ 把直接引用換成一個取得點(★ 這一頁)—— ★★ 行為完全不變
第 2 步 ★ 把取得點變成一個欄位(函式欄位)—— ★ 測試變得可能
第 3 步 ★★ 把欄位從「getter」變成「值」—— ★ 若那個值不會變
第 4 步 ★ 值往上推到 main()(見 [[封裝全域引用]])

★★ 關鍵性質:★ 第 1、2 步就能讓測試變成可能,
而 3、4 步是「讓程式碼更好」——★★ 所以可以先停在第 2 步,先把測試補起來。

🧪 我實際套用的紀錄

  • (待填)

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

  • 這一招本身不完整,要靠子類化並重寫方法收尾。 書上明講獲取方法只是鋪路,真正的替換發生在測試子類別重寫它的時候(p.313)。如果你的語言/情境沒辦法做子類化並重寫方法,這招就沒有下半場。

  • 獲取方法得是可重寫的存取權限。 步驟第 2 步直接把「確保該獲取方法的存取權限允許你在派生類別中重寫它」寫進去(p.314)——也就是說你會為了測試而放寬原本的封裝(書中示範是設為受保護的)。這是為了能測而付出的設計代價。

  • 有時候獲取方法還不夠。 「不過也有些情況下你可能會需要動用介面提取(285 頁)。」(p.313)

  • 單例會逼你動它的建構函式。 書中的例子必須把 Inventory 的建構函式從私有降為受保護的才能派生出假物件(p.314)——等於為了測試在產品程式碼上開了一個口。

  • ★★ 原手法的第二步(在測試子類覆寫 getter)在 Go 不成立。 改成函式欄位或介面欄位。

  • ★★ 更好的做法通常是「不要 getter,直接注入值」。 判準:那個值在這個物件的生命週期內會改變嗎?

  • ★★ 「注入 getter」的隱性風險:同一個請求裡呼叫兩次可能拿到不同的值。 對策是請求開始時讀一次,把值傳下去。

  • ★★ time.Now() 是全 Go 生態最常見的未封裝全域引用,代價是「月底」「閏年」「跨時區」「DST」無法寫測試。

  • ★ os.Getenv 應該在啟動時讀一次成為欄位,不要在業務邏輯裡呼叫。

  • ★ 第 1、2 步就能讓測試變成可能,3、4 步是讓程式碼更好——可以先停在第 2 步,先把測試補起來。

🔗 相關工具