🎯 什麼情境該想到我
當你「要把一段程式碼放進測試用具,卻被全域變數或單件擋住,換不掉那個唯一的實例」的時候。
書中 p.292 講得很有情緒:「或許我是個純粹主義者,但我確實不喜歡全域變數。在協助團隊的過程中我常常發現,阻撓我們將一塊程式碼放進測試用具的最大敵人就是它們。比如你想把一組類放入測試用具,卻發現其中有些需要被設置成某些特定狀態才能使用。」
同頁務實地承認現實:「對全域變數的抱怨暫且放一邊,事實是,許多系統裡面都免不了有全域變數。有些系統中全域變數的存在形式是直接的——只是由於某個程式設計師在全域範圍內定義了一個變數。而另一些系統中它們則可能以嚴格遵循單件模式的單件形式存在。不管哪種情況,引入一個偽物件並用它來進行感知是個非常直接的做法。」
p.293 的「單件設計模式」附註指出衝突的根源:單件實作的三個共性是——建構函式通常被設為私有、有一個持有唯一實例的靜態成員、有一個提供存取的靜態方法(通常叫 instance)。而「雖說單件模式能夠防止人們在產品程式碼中建立不只一個目標類的實例,但它同樣阻止了人們在測試用具中建立第二個實例。這是一把雙刃劍。」
p.293 給出核心做法:「替換單件需要多花點工夫才行。首先是往單件類上添加一個靜態的設置方法以便用它來替換單件實例,然後將建構函式設為受保護的。之後就可以對單件類進行子類化,建立一個全新的物件並將它傳遞給那個靜態的設置方法了。」
⚙️ 怎麼用(步驟)
書中 p.296「步驟」小節:
- 降低建構函式的保護權限,這樣你才能夠通過子類化單件類來建立偽類及偽物件。
- 往單件類上添加一個靜態設置方法。後者的參數型別是對該單件類的引用。確保該設置方法在設置新的單件物件之前將舊的物件銷毀。
- 如果你需要存取單件類裡面的受保護或私有方法才能將其設置妥當的話,可以考慮對單件類子類化,也可以對其提取介面並改用該介面的引用來持有單件。
若全域變數只是個赤裸裸的全域變數,p.292 說「可以乾脆替換它本身。如果對它的引用是 const 或 final 的,你可能就需要將這些修飾符去掉了(同時在程式碼裡留下註釋說明你這麼做只是為了方便測試,在產品程式碼中不應利用這個漏洞)」。
全域工廠的變形(p.295):「偶爾我們也會在有些系統中發現另一種全域變數——一個全域工廠。它們並非持有唯一一個物件,而是在每次靜態方法被呼叫的時候提供全新的物件。對於這類情況,要想換入我們自己的物件就有點難度了,但通常你都可以通過讓這個工廠委託另一個工廠來達到你的目的。」書中示範是抽一個提供者介面出來,讓全域工廠轉為委託給一個可被設置的提供者,測試時在 setUp 裡換上會回傳偽物件的那個。
★★ 在 Go 裡:它適用,但有一個更好的替代
// ★ 問題:★★ 一個套件層的單件,測試無法替換它
package metrics
var client = newRealClient() // ★★ 沒有任何注入點
func Count(name string) { client.Inc(name) }
// ★ 原手法的 Go 版:★★ 加一個「設置」入口
func SetClientForTest(c Client) func() {
old := client
client = c
return func() { client = old } // ★ 回傳還原函式
}// ★ 測試
restore := metrics.SetClientForTest(fake)
defer restore()★★ 三個必守的紀律:
① ★★ 名字要有 ForTest 或 _test.go 限定
★ 一個叫 SetClient 的匯出函式 ⇒ ★★ 生產程式碼也會開始用它
→ ✅ ★ 更好:★★ 放在 export_test.go 裡 ⇒ ★ 它只存在於測試 build
② ★★ 一定要能還原(★ 回傳一個 restore 函式,或用 t.Cleanup)
③ ★★ 有這個 setter 的測試不能 t.Parallel() —— ★ 它是行程層級的狀態
★★ ① 的 export_test.go 做法是 Go 標準庫自己用的:
// ★ metrics/export_test.go —— ★★ 只在測試 build 中存在
package metrics
func SetClient(c Client) func() { … }
// → ★ 生產程式碼完全沒有這個入口 ⇒ ★★ 不可能被誤用★★ 但更好的做法是:不要有那個單件
// ✅ ★ 讓型別可被建立與注入(★ 而「套件層的預設實例」只是方便)
type Metrics struct{ c Client }
func New(c Client) *Metrics { return &Metrics{c: c} }
func (m *Metrics) Count(name string) { m.c.Inc(name) }
// ★ 套件層的便利 API 仍然可以保留(★ 像 slog.Default())
var def = New(newRealClient())
func Count(name string) { def.Count(name) }★★ 這是 Go 標準庫的通用模式(http.DefaultClient、slog.Default()):
★ 提供一個預設實例當方便,★★ 但同時提供「建立自己的實例」的方式。
→ ★ 而函式庫的使用者應該用後者(見 全域資料:
★★ 「函式庫不該依賴 http.DefaultClient」)。
★★ 一個 Go 特有的併發陷阱
// ★★ 替換單件是「寫入一個套件層變數」⇒ ★ 若同時有 goroutine 在讀它,就是資料競爭
var client = newRealClient()
func TestX(t *testing.T) {
restore := SetClientForTest(fake) // ★★ 寫入
defer restore()
go backgroundWorker() // ★★ 這個 goroutine 在讀 client
… // → ★ go test -race 會報 DATA RACE
}★ 三個對策:
① ★★ 用 atomic.Pointer[Client] 包起來(★ 若真的需要執行期替換)
② ★ 確保替換發生在「沒有任何 goroutine 在跑」的時刻(★ 測試開始時)
③ ★★ 最好的:★ 不要有單件(見上)
★ 什麼時候這一招是正當的
- ★★ 第三方套件的單件(★ 你改不動它)→ ★ 若它提供 setter 就用,否則包一層。
- ★ 遺留系統的過渡期 → ★★ 先加 setter 把測試補起來,再逐步注入化(見 封裝全域引用 的三步)。
- ★ 全域的橫切設施(★ metrics、tracing)→ ★★ 它們本質上是行程層級的,
★ 而測試通常不需要驗證它們(★ 只需要「不要爆」)。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
這招明擺著在破壞封裝,作者直接回應了這份不安。 p.293:「這種做法可能會令你心裡感到不安,因為你覺得單件類的保護給打破了,但是,別忘了,存取限制的目的在於防止錯誤,而我們編寫測試的目的同樣也是防止錯誤。而為了在這種情況下引入測試,我們不得已才用了強硬一點的手段。」
-
會醜化系統——這句話是全節最誠實的取捨。 p.296:「我猜你看到這兒肯定在想『不就為了把這個測試安置到位嗎,用得著這麼大動干戈的嘛?』你說得沒錯,這些模式的確會明顯醜化系統。但別忘了,手術也從來都不是漂亮的,尤其是開始的時候。」p.294 也自嘲:「僅僅為了換入一個新物件就大費周章地如此折騰一氣,看起來似乎太誇張了點。」
-
後門會被誤用嗎?會。 p.294:「但你或許會發出疑問『人們會不會錯用我們為測試而留的後門,在產品程式碼中建立出多於一個的「單件」出來呢?』答案是可能的。但我覺得,如果某個實例在系統中的唯一性是如此重要的話,最好的辦法就是確保團隊的每個成員都意識到這一重要性。」
-
不要改用布林開關或條件編譯這些捷徑。 p.294:可以往類裡加一個布林變數決定回傳產品還是測試用物件,C++/C# 也可以用條件編譯,「以上這兩個替代手法都可行,但它們的侵入性太強,而且如果在整個程式碼基中普遍採用的話就會變得比較笨拙。一般而言我喜歡把產品程式碼和測試程式碼分得清清楚楚,互相井水不犯河水。」相比之下「在單件上使用設置方法和受保護的建構函式侵入性不強,卻能幫助你將測試安置到位」。
-
改用介面提取這條替代路線有它的缺點。 p.294–295 邊註:除了降低建構函式存取限制並利用子類化之外,還可以在單件類上提取出一個介面、並在該介面上提供能接受該介面實作物件的設置方法,「但該做法也有它的缺點,那就是你必須修改用來引用單件的引用型別以及 instance() 方法的回傳型別。這些修改可能會很棘手,而且這些改動的方向並不好。我們認為『更好的方向』是減少對單件的全域引用,最終使單件類可以成為一個普通類。」
-
狀態會外洩到其他測試。 p.296:「在所有這些關於引入靜態設置方法的手法裡,你對程式狀態的修改對於所有測試來說都是可見的。如果你使用的是 xUnit 測試框架,則可以使用裡面的 tearDown 方法來將狀態復位,以便後續的測試在一個已知的狀態環境下執行。」作者的判準是「僅當錯誤的狀態用於後續的測試會引起誤解時我才會使用這種方法」;若是用全域變數保存會影響系統結果的狀態,則「通常我就會在 setUp 和 tearDown 方法裡面做同樣的事情——保證系統處於一個乾淨的狀態」。
-
長期出路是參數傳遞。 p.296:「考察一下需要存取你的全域變數的類,看看能否給它們一個公共基類。如果可以,就在建立它們的時候將全域物件傳遞給它們,並逐漸往消除全域變數的方向靠攏。人們常常會害怕系統中每個類都會需要全域物件,但結果往往會令你大吃一驚。」書中舉了一個嵌入式系統的例子:把記憶體管理和錯誤回報機制封裝在類中並傳遞出去,久了「在需要這些服務的類與不需要它們的類之間就形成了一道清晰的隔離」。
-
★★ setter 要放
export_test.go,讓它只存在於測試 build 中——生產程式碼不可能誤用。這是標準庫自己的做法。 -
★★ 一定要能還原(回傳 restore 函式或用
t.Cleanup),否則測試互相污染。 -
★★ 有這個 setter 的測試不能
t.Parallel(),它是行程層級的狀態。 -
★★ 替換單件時若有 goroutine 在讀它,就是資料競爭(
-race會報)。用atomic.Pointer或確保替換發生在沒有 goroutine 在跑的時刻。 -
★★ 更好的做法是不要有那個單件:提供
New(deps)建立實例,套件層的預設實例只是方便(slog.Default()模式)。 -
★ 函式庫不該依賴預設單件(
http.DefaultClient),應該讓呼叫端傳入。 -
★ 正當的場合:第三方套件的單件、遺留系統的過渡期、全域橫切設施(metrics/tracing)。