🎯 什麼情境該想到我
當你「想單獨對付某塊程式碼,卻發現全域變數擋在你的路上」的時候。(p.313)
書上對全域變數的害處沒有再展開,只回頭指向引入靜態設置方法那一節「作了相當完整的敘述」(p.313)。這裡談的是解法:「要想解開一個類別裡面對全域變數的依賴,方法之一是在該類別中為每個相應的全域變數引入一個獲取方法。有了這些獲取方法,便可以通過子類化並重寫方法(314 頁)來令它們返回測試用物件了。不過也有些情況下你可能會需要動用介面提取(285 頁)。」(p.313)
靜態方法呼叫也算全域引用。 書中用一個 Java 例子舉證:RegisterSale 透過 Inventory 類別上的靜態方法取得庫存物件。Feathers 自問自答:「『什麼?』你可能會嚷起來『這不明明是個靜態方法呼叫嗎?』沒錯。但從我們的意圖(測試)來說,它其實就相當於一個全域變數。在 Java 中,該類別本身就是一個全域物件,而且似乎它還需要引用一些狀態才能完成它的工作。」(p.313)
⚙️ 怎麼用(步驟)
書中列出的步驟(p.314):
- 找出你想要替換的全域引用。
- 給它編寫一個相應的獲取方法。確保該獲取方法的存取權限允許你在派生類別中重寫它。
- 將對全域物件的引用替換為對該獲取方法的呼叫。
- 建立測試子類別並重寫獲取方法。
書中示範時特別提醒:「注意,我們將該方法設為受保護的,這樣才能在測試子類別中重寫它。」(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 步,先把測試補起來。
🔗 相關工具
- 工具-打破依賴以便測試 — 這條技術屬於的大類
- 子類化並重寫方法 — 本手法的下半場;書 p.313 明確指向它
- 介面提取 — 書 p.313 說有些情況下獲取方法不夠,得動用它
- 引入靜態設置方法 — 書 p.313 把全域變數的害處整段留給它,不再重述
- 封裝全域引用 — 對付全域變數的另一條路(書 p.310 把兩者並列為物件導向手法)
- 換函式為函式指標 — 過程式語言裡對應的「換成可替換的間接層」手法(C 裡用不了本手法,見書 p.310)
- 回連 修改程式碼的藝術
- 封裝全域引用 —— 姊妹手法與完整遷移路徑
- 換函式為函式指標 —— ★ Go 的第二步
- 參數化方法 —— 把「現在」變成參數
- 提取並重寫獲取方法 —— 原手法的繼承版
- 全域資料 —— 什麼時候全域是對的