🎯 什麼情境該想到我
當你「用的是直譯語言,只想讓某一個方法在測試中不要真的被執行」的時候。
書上的定位:「一些新的直譯語言提供了非常好的解依賴途徑。在被直譯的時候,方法可以被及時重定義。」(p.323)
書中的 Ruby 例子:Account 有個 deposit 會呼叫 report_deposit。「如果我們不想看到 report_deposit 在測試中被執行,則只需在測試檔案中重定義,並將測試程式碼放在新的定義之後。」(p.323)
關鍵在於「只重定義方法、不重定義類別」: 「有一點非常值得注意,即我們並沒有重定義整個的 Account 類別,而只是重定義了它的 report_deposit 方法。Ruby 直譯器將 ruby 程式碼檔案的每行都看作可執行語句。而 class Account 語句就相當於打開了 Account 類別的定義,從而新的方法定義可以被添加進去。而 def report_deposit(value) 語句則開始往打開的類別中添加新方法。Ruby 直譯器並不關心該方法是否已有定義體存在,如果有的話就替換掉。」(p.323–324)
(簡體版譯者在此加註:這個手法一般叫「文本替換」。)
⚙️ 怎麼用(步驟)
書中列出的步驟——標題就寫明「在 Ruby 中使用文本重定義的步驟」(p.324):
- 找出你想要替換定義的方法所在的類別。
- 在測試原始檔開頭添加一行 require,引入包含目標類別的模組。
- 在測試原始檔的開頭給每一個你想要替換的方法提供新的定義。
順序很重要:書中示範的檔案結構是 require → 重新打開類別、重定義方法 → 然後才是測試程式碼,並明講要「將測試程式碼放在新的定義之後」(p.323)。
★★ 在 Go 裡:完全不存在,而且這是好事
# ★ Ruby 的做法:★★ 在測試裡重新打開一個類別,直接換掉方法
class Sender
def self.send_mail(to, body) # ★ 覆蓋掉原本的實作
$sent << [to, body]
end
end★★ Go 沒有任何等價機制:
① ★ 編譯期決定 ⇒ ★★ 沒有「執行期重新定義函式」
② ★ 同一個 package 內不能有兩個同名函式
③ ★★ 沒有 monkey patching(★ 除非用 unsafe 或 //go:linkname 這類不該用的手段)
★★ 這是 Go 的設計選擇,而它的代價與收益都很明確:
★ 代價:★ 你不能「事後」把一個寫死的呼叫換掉——
★★ 必須改程式碼,把它變成一個可注入的 seam。
★ 收益:★★ 「這段程式碼在測試裡會不會變成別的東西」永遠是確定的
——★ 不會有「某個測試檔悄悄改掉了全域行為」這種事。
★★ Go 的替代:三種 seam,按侵入性排序
// ★ ① 函式欄位(★ 最輕,見 [[換函式為函式指標]])
type Service struct{ sendMail func(to, body string) error }
// ★ ② 套件層的 var(★★ 可以「事後」替換,但要小心)
var sendMail = realSendMail // ★ 測試裡:sendMail = fake;defer 還原
// ⚠️ ★★ 它是全域可變狀態 ⇒ ★ 不能用 t.Parallel(),而且測試順序會互相影響
// → ★ 只在「真的沒有別的辦法」時用,★★ 而且一定要 defer 還原
// ★★ ③ 介面欄位(★ 最正規,見 [[介面提取]])
type mailer interface{ Send(to, body string) error }★★ ② 是 Go 生態裡「最接近文本重定義」的東西,而它有一個具體的陷阱:
func TestA(t *testing.T) {
old := sendMail
sendMail = fake // ★★ 忘記 defer 還原 → ★ TestB 拿到 fake
defer func() { sendMail = old }() // ✅ ★ 一定要有這一行
}
// ★★ 而且:★ 有 var 替換的測試「不能」t.Parallel() —— 那是資料競爭★ Go 1.17+ 有一個更好的工具:t.Setenv —— 它用 Cleanup 自動還原,
★★ 而且標準庫明文說「因為它影響整個行程,不能用在 parallel 測試」——
★ 這正是所有全域替換的共同限制。
★★ 這一頁在 Go 的實用價值:它提醒你「seam 必須事先設計」
★★ Ruby/Python 可以事後 patch ⇒ ★ 你可以先寫爛碼,之後再想辦法測
★★ Go 不行 ⇒ ★ 「可測性」必須在寫的時候就決定
→ ★★ 而這是 Go 的一個實質優點:★ 它讓「這段碼可不可測」在 code review
時就看得出來(★ 依賴是欄位,看得見)
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
⚠️ 語言限制:書中示範與步驟都限定在直譯語言,具體例子是 Ruby。(p.323–324)它靠的是「程式碼檔案每一行都是可執行語句、類別定義可以被重新打開」這個語言性質。
-
⚠️ 主要缺點:重定義是全域且不可逆的。 書中的側欄(p.324):
「Ruby 中的文本重定義也有它的缺點:新方法對舊方法的替換會一直有效,直到程式結束。所以如果你後面忘記了某方法已被重定義了的話就可能會遇到麻煩。」
換句話說,你在測試檔開頭做的這件事會污染整個測試程序的其餘部分——這是拿「完全不用改產品程式碼」換來的代價。
-
C/C++ 也有對應做法,但走的是另一條路。 「其實我們在 C/C++ 中也可以進行文本重定義——使用預處理。關於這個的一個例子可以參考第 4 章描述預處理期接縫(29 頁)一節中的例子。」(p.324)
-
★★ Go 完全沒有等價機制(編譯期決定、無 monkey patching)。這是設計選擇:可測性必須在寫的時候就決定,而不能事後 patch。
-
★★ 套件層
var替換是最接近的東西,但它是全域可變狀態:不能t.Parallel()、忘記defer還原會污染其他測試。 -
★
t.Setenv用Cleanup自動還原,但標準庫明文說它不能用在 parallel 測試 —— 這是所有全域替換的共同限制。 -
★ 三種 seam 按侵入性排序:函式欄位 → 套件層 var → 介面欄位。 優先前者與後者。
-
★ Go 的實質優點:「這段碼可不可測」在 code review 時就看得出來(依賴是欄位,看得見)。
🔗 相關工具
- 工具-打破依賴以便測試 — 這條技術屬於的大類
- 模板重定義 — 同樣是不靠繼承、直接換掉定義的路線,但走的是 C++ 泛型
- 子類化並重寫方法 — 物件導向語言中的預設解法;本手法免去了建立測試子類別這一步
- 回連 修改程式碼的藝術
- 換函式為函式指標 —— ★ Go 最輕的 seam
- 介面提取 —— 最正規的 seam
- 連接替換 —— 另一種不改程式碼的替換
- 工具-Go表格測試與子測試 —— t.Parallel 的限制