🎯 什麼情境該想到我
當你「面對的是 C 這種程序式語言,沒有共同介面也沒有共同基底類別可以拿來換掉那個函式」的時候。
⚙️ 怎麼用(步驟)
書中先講清楚背景:物件導向方法學給了我們很多替換物件的契機,只要兩個類別實作了共同的介面、或具有共同的基底類別,就能輕鬆把其中一個的物件替換成另一個的物件;但像 C 這樣的程序式語言的程式設計師「沒這個福氣」——若不用預處理手段,就完全沒法在編譯期把一個函式替換成另一個函式(p.296–297)。
連接替換的做法:建立一個啞元庫,該庫裡面包含一個跟原函式簽名完全一樣的函式。如果你的目的是感知,那麼需要在該函式裡面設置一些機制來保存通知訊息並對它們進行查找——可以使用檔案、全域變數,或其他辦法,只要你覺得方便就行(p.297)。書中的示例是在測試用的函式裡建立一個全域清單,把每次該函式被呼叫時的有關資訊記下來;測試時就可以在測完一組物件之後檢查該清單,確認該函式是否按照正確的順序被呼叫了(p.297)。
在 Java 中也可以用這一手法:建立一個同名同方法集的類別,然後修改類別路徑,讓呼叫被決議到你的新類別上,從而避開原類別上的糟糕依賴(p.297)。
書中列的步驟(p.297):
- 找出你想要偽造的函式或類別。
- 為它們編寫另一份定義。
- 修改你的建置參數,讓你的偽造品能夠代替真品被連接到專案中。
★★ 在 Go 裡:有三種等價機制,但它們都不適合當單元測試的 seam
(go help buildconstraint/go help mod edit 查證,2026-09-25)
// ★ ① build tag(★ 檔案層級的替換)
//go:build integration
package store
func newStore() Store { return realStore() }
// ★★ 語法(Go 1.17+)://go:build,★ 支援 || && ! 與括號
// ★ 必須在 package 宣告之前,★★ 且後面要接一個空行
// ★ ② go.mod 的 replace(★ 模組層級的替換)
// go mod edit -replace=example.com/lib=./local-fake
// → ★★ 用於「本機開發時指向本地版本」,★ 或 fork 一個有 bug 的依賴
// ★ ③ 換掉整個 package 的 import 路徑
// → ★★ 沒有機制,只能手改(★ Go 沒有 C 的 link-time 替換)★★ 三者都不是好的單元測試 seam,理由是同一個:
★ 它們讓「測試跑的程式碼」和「生產跑的程式碼」是不同的檔案/模組
——★★ 而那正是 Feathers 反覆警告的事:你測的不是你部署的。
★★ build tag 適合什麼:區分「測試的種類」,不是「替換依賴」
//go:build integration
package store_test
// ★★ 這個檔案只在 go test -tags=integration 時編譯
// → ★ 用途:★★ 把「需要真的資料庫」的測試和「純邏輯」的測試分開
// ★ CI 的 PR 階段只跑純邏輯(★ 快);★ nightly 才跑整合測試(慢)★★ 這是 build tag 在測試上唯一正當的用途:
★ 它分的是「這個測試要不要跑」,而不是「這段程式碼是真的還是假的」。
★★ 為什麼 Go 不需要連接替換:seam 已經在別的地方了
★ C/C++ 需要連接替換,是因為:★★ 它們沒有便宜的執行期多型
→ ★ 一個直接的函式呼叫 foo() 在編譯後就是一個固定的位址
★★ Go 有:★ 介面(動態分派)、★ 函式值(見 [[換函式為函式指標]])
→ ★★ 而兩者都是「同一份程式碼、不同的值」⇒ ★ 測的就是部署的
★ 判準:★★ 你想替換的東西能不能變成一個欄位?
能 → ★ 用介面或函式欄位,不要用 build tag。
不能(★ 例如 syscall 層、CGO、平台特定的實作)→ ★★ 那才是 build tag 的場合。
★★ 一個 Go 特有的正當用途:平台差異
// ★ 標準庫自己的做法
// file_unix.go
//go:build unix
func chmod(name string, mode FileMode) error { … }
// file_windows.go
//go:build windows
func chmod(name string, mode FileMode) error { … }★★ 這是「連接替換」在 Go 裡真正對應的東西:
★ 同一個函式簽章、依平台選擇不同實作——★★ 而且那個選擇是編譯期的、確定的。
⚠️ 它的代價:★ go vet 與 linter 預設只檢查當前平台的檔案
→ ★★ 另一個平台的檔案可能編不過而你不知道。
★ 對策:★★ CI 要跑 GOOS=linux go build ./...、GOOS=windows go build ./...。
★ 一個要避免的誘惑:用 build tag 做 feature flag
//go:build !enterprise
func hasFeatureX() bool { return false }★★ 這會產生 2ⁿ 個 build 組合,★ 而你只測了其中一兩個。
→ ★ 正確做法:執行期的 feature flag(★ 一個設定值或一個 flag 服務)
——★★ 它讓「開關功能」不需要重新編譯與重新部署。
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
殺雞用牛刀:書在後文談程序式語言的解依賴選項時明說,連接替換(296 頁)與定義補全(266 頁)雖然可用,但「對於解開一些小的依賴又顯得有點殺雞用牛刀了」(p.310)。小依賴不要動用它。
-
C++ 上作者沒實證:作者自陳「我個人從未對 C++ 類別用過這一手法,但我想道理是一樣的」,並認為 C++ 編譯器的名字粉碎機制肯定會帶來一些困難;但如果呼叫的是 C 函式,則該手法是非常可行的(p.297)。
-
最適合的場景很窄:其最大的用處就是用來偽造外部函式庫內的函式;而最好偽造的又屬那些「純資料匯」的函式庫——對於這類函式庫,你只是呼叫其中的函式,通常並不關心其回傳值,圖形函式庫就是這樣的一個例子(p.297;「匯」原文譯註亦稱「宿」「池」)。
-
與《重構》的分界:這不是在改善設計結構,而是在建置/連接階段動手腳,只為了讓原本無法替換的呼叫在測試中被換掉。它甚至沒有碰到產品原始碼一行——動機是「讓程式能進測試」,不是「讓程式更好讀」。
-
★★ build tag、
replace、換 import 路徑都不是好的單元測試 seam:它們讓「測試跑的碼」和「生產跑的碼」是不同檔案——你測的不是你部署的。 -
★★ build tag 在測試上唯一正當的用途是「區分測試的種類」(
-tags=integration把需要真資料庫的測試分開),而不是「替換依賴」。 -
★★ Go 不需要連接替換,因為 seam 已經在介面與函式值上 —— 兩者都是「同一份程式碼、不同的值」。
-
★ 判準:你想替換的東西能不能變成一個欄位? 能 → 用介面或函式欄位;不能(syscall、CGO、平台差異)→ 才是 build tag 的場合。
-
★★ 平台特定檔案的 CI 陷阱:linter 預設只檢查當前平台,另一個平台的檔案可能編不過而你不知道。要跑
GOOS=... go build ./...。 -
★★ 不要用 build tag 做 feature flag —— 會產生 2ⁿ 個 build 組合而你只測了一兩個。用執行期 flag。
-
//go:build必須在 package 宣告前,且後面要接一個空行(Go 1.17+ 語法)。
🔗 相關工具
- 工具-打破依賴以便測試 — 這條技術屬於的大類
- 參數化建構函式、參數化方法 — 物件導向語言裡比較常用的替換手段;連接替換是這些路都走不通時的備案
- 定義補全 — 書在 p.310 把它與連接替換並列為程序式語言可用的手法,並同樣提醒對小依賴是殺雞用牛刀
- 回連 修改程式碼的藝術
- 換函式為函式指標 —— ★ Go 真正的 seam
- 介面提取 —— 另一個真正的 seam
- 定義補全 —— C/C++ 的連結期技巧
- 文本重定義 —— 另一種不改生產碼的替換
- 工具-低風險發布模式 —— 執行期 feature flag