🎯 什麼情境該想到我

當你「要測的程式碼直接呼叫了某個靜態方法,而那個靜態方法裡面藏著測試時碰不得的東西」的時候。

書中 p.291 描述這個處境:「如果你的專案中有靜態方法,則很可能它們並不會給你帶來麻煩,除非其中包含了一些你沒法或不想在測試的時候依賴的東西。(這個的技術術語叫做『靜態黏著』。)遇到這類情況,你可能會想『唉,要是有物件接縫就好了,那樣的話就可以利用它來嵌入測試用的行為了。』」

解法(p.291):「方案之一便是往目標類上引入委託實例方法。這時候,你需要想辦法將對那些靜態方法的呼叫替換為對目標類的實例方法的呼叫。」書中的示範是在一個只含靜態方法的類上加一個實例方法,讓它「只是簡單地將呼叫轉發至靜態方法」,然後把呼叫端從直接呼叫靜態方法,改成呼叫傳進來的那個物件的實例方法。

書中 p.290 也交代了這類靜態方法從哪來:「人們會因為各種各樣的原因使用靜態方法。其中最常見的原因便是為了實現單件模式(書 p.293)。而另一個常見的原因是使用靜態方法來建立實用類。」實用類「一般沒有任何實例變數和實例方法。而是全部由靜態方法和靜態常量組成」,多數時候是因為「無法為一組方法尋找出合適的公共抽象」。

⚙️ 怎麼用(步驟)

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

  1. 找出會在測試中帶來問題的那個靜態方法。
  2. 在它所屬類上新建一個實例方法(記得用簽名保持手法)。讓該實例方法委託那個靜態方法。
  3. 找出你想要納入測試的類中有哪些地方使用了那個靜態方法。使用參數化方法(書 p.301)或其他解依賴技術來提供一個實例給程式碼中想要呼叫那個靜態方法的地方(即改為從該實例間接呼叫那個靜態方法)。

★★ 在 Go 裡:它的動機在 Go 裡幾乎不存在

★ 書中的問題:★★ 一個 static 方法需要被替換,★ 但 static 無法被覆寫
  → ★ 解法:★★ 加一個實例方法轉發給 static,★ 然後用子類化覆寫那個實例方法
★★ Go 裡這條路走不通,而且不需要:
① ★ Go 沒有繼承 ⇒ ★★ 「用子類覆寫實例方法」不成立(見 [[子類化並重寫方法]])
② ★★ Go 的 package 層函式可以直接被「函式欄位」或「介面」取代
   → ★ 不需要先包成實例方法
// ★ 問題:★★ 一段程式碼直接呼叫 package 層函式
func (s *Service) Process(o Order) error {
    rate := fx.Rate("USD", "TWD")     // ★★ package 層函式,測試時想替換
    …
}
 
// ✅ ★ Go 的一步到位:★★ 直接變成欄位(不需要「先包成方法」這個中間步驟)
type Service struct{ rate func(from, to string) (Rate, error) }
func New(opts ...Option) *Service {
    s := &Service{rate: fx.Rate}      // ★ 直接指向那個 package 層函式
    …
}

★★ 關鍵差異:★ Go 的函式是值 ⇒ s.rate = fx.Rate 直接成立。
★ 所以 Feathers 的「兩步」(★ 先包成實例方法、★ 再用子類覆寫)
★★ 在 Go 裡合併成「一步」:直接把那個函式賦值給一個欄位。

★★ 它在 Go 裡真正對應的一個有用動作:包裝第三方的 package 層 API

// ★ 問題:★★ 第三方套件只提供 package 層函式,沒有任何介面
//   stripe.Charges.New(...)  ← ★ 掛在一個 package 層的變數上
//   time.Now()、os.Getenv()、rand.Int()
 
// ✅ ★ 把它們包成一個型別的方法 ⇒ ★★ 這樣才能用介面替換
type Clock struct{}
func (Clock) Now() time.Time { return time.Now() }
 
type clock interface{ Now() time.Time }     // ★ 消費方的介面

★★ 判準:★ 你需要「一組相關的操作」還是「一個操作」?
一組(Now、Since、Sleep)→ ★ 包成型別 + 介面(這一頁的 Go 版)。
一個 → ★★ 直接用函式欄位(見 換函式為函式指標),★ 不要包型別。

★ 一個 Go 生態的具體例子

// ★★ os 套件的函式無法被介面替換 ⇒ ★ 標準庫自己提供了抽象
// ★ Go 1.16+ 的 io/fs:
func Process(fsys fs.FS, name string) error {   // ★★ 收介面而非直接用 os.Open
    f, err := fsys.Open(name)
    …
}
// ★ 生產:Process(os.DirFS("/data"), "a.txt")
// ★★ 測試:Process(fstest.MapFS{"a.txt": {Data: []byte("…")}}, "a.txt")

★★ fstest.MapFS 是 Go 標準庫提供的「不需要碰檔案系統」的測試替身——
★ 這正是這一頁的手法在標準庫裡的體現,★★ 而且它已經幫你做好了。

🧪 我實際套用的紀錄

  • (待填)

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

  • 會讓設計暫時變醜,而且作者自己承認。 p.292:「對於許多靜態方法來說,該技術還算是相當直觀的。然而當遇到實用類的時候,你可能就會感覺不自然了。一個具有 5 到 10 個靜態方法,卻只有一兩個實例方法的類看起來的確挺怪異的。而如果這僅有的兩個實例方法還只是一層空殼,其功能只是將任務簡單地轉發給相應的靜態方法,那就更加怪異了。」

  • 但這是有出口的醜。 同段接著說:「但好處是,該技術引入了物件接縫,後者使你能夠在測試時替換進另一種行為。隨著時間的推移,你可能就會發現每個對該類的方法的呼叫都通過實例方法進行轉發了,這時候你便可以將那些靜態方法的方法體轉移到相應的實例方法中,並刪除所有靜態方法了。」——所以委託空殼是過渡態,不是終點。

  • 需要額外的重構才能真的塞得進去。 p.292:「注意,只有當我們有辦法在外部建立一個⋯⋯物件從而能通過該物件來呼叫⋯⋯時,該做法才能成功。這就意味著我們需要做一些額外的重構,不過,在靜態型別的語言裡,可以依靠編譯器(書 p.251)來幫我們將物件放置到位。」

  • 書中對實用類本身也有保留(p.290):Java 在寫作當時尚不支援在基本型別上呼叫數學方法,因此實用類是個正當的解決方案,「但它同時也是個特例。在幾乎所有的情況下,你也可以使用傳統的帶有實例資料和方法的類來完成工作」。

  • ★★ 它的動機在 Go 幾乎不存在:Go 的函式是值,s.rate = fx.Rate 直接成立——Feathers 的「兩步」在 Go 合併成一步。

  • ★★ 在 Go 裡真正有用的形態是「包裝第三方的 package 層 API」(time.Now、os.Getenv、stripe.Charges.New),因為它們無法被介面涵蓋。

  • ★ 判準:需要「一組相關操作」→ 包成型別 + 介面;「一個操作」→ 直接用函式欄位,不要包型別。

  • ★ 標準庫已經為檔案系統做好了這件事:io/fs 收 fs.FS 介面,測試用 fstest.MapFS——不需要碰真的檔案系統。

  • ★ 不要為了「以後可能要替換」而預先包一層,那是過度設計。

🔗 相關工具