這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
一直以來,我都認為在 go 裡面 mock 是非常困難的。不像動態語言或者跑在 VM 上的語言,go 要求在開發的時候就給 mock 介入預留空間,不然測試的時候會不得其門而入。開發的時候需要頭疼的事情可多了,還要求再考慮下可測試性,真有點強人所難。另外第三方庫並不一定給 mock 預留空間,遇到這種情況只能乾瞪眼繞路走。很多時候,無法 mock 掉某些帶副作用的函數,就不能覆蓋掉目標路徑。既然測試不到關鍵的路徑,那乾脆就不寫測試了。結果是,項目裡很多 go 代碼事實上一直都沒有被測試覆蓋掉。
但最近我發現了一個庫:https://github.com/bouk/monkey
似乎可以跟開頭的煩惱永別了?小範圍地體驗了下,感覺還是挺好用的。
長話短說,monkey
庫通過修改記憶體位址的方式,替換目標函數的實際執行地址,實現(幾乎)任意函數的 mock。你可以指定目標函數,然後定義一個匿名函數替換掉它。替換的記錄會存在一個全域表裡,不需要的時候可以通過它重新恢複原來的目標函數。由於採用的是修改記憶體位址的黑科技,作者建議千萬不要用在測試環境以外的地方。目前僅支援x86架構上的 Linux 和 Mac,Windows 似乎沒有測試過?不管怎樣,支援 Linux 和 Mac 就足以覆蓋開發機和 CI 環境了。
monkey
庫用起來非常簡單,直接邊上範例程式碼,邊解釋好了:
package mainimport ( "fmt" "github.com/bouk/monkey" "os" "os/exec" "reflect" "testing")// 假如我們要測試函數 callfunc call(cmd string) (int, string) { bytes, err := exec.Command("sh", "-c", cmd).CombinedOutput() output := string(bytes) if err != nil { return 1, reportExecFailed(output) } return 0, output}// 上面的函數會調用它,這個函數一定要mock掉!func reportExecFailed(msg string) string { os.Exit(1) // 討人嫌的副作用 return msg}func TestExecSussess(t *testing.T) { // 恢複 patch 修改 // 實際使用中會把 UnpatchAll 放到 teardown 函數裡 // 不過在 go 內建的 testing 裡就這麼處理了 defer monkey.UnpatchAll() // mock 掉 exec.Command 返回的 *exec.Cmd 的 CombinedOutput 方法 monkey.PatchInstanceMethod( reflect.TypeOf((*exec.Cmd)(nil)), "CombinedOutput", func(_ *exec.Cmd) ([]byte, error) { return []byte("results"), nil }, ) // mock 掉 reportExecFailed 函數 monkey.Patch(reportExecFailed, func(msg string) string { return msg }) rc, output := call("any") if rc != 0 { t.Fail() } if output != "results" { t.Fail() }}func TestExecFailed(t *testing.T) { defer monkey.UnpatchAll() // 上次 mock 的是執行成功的情況,這一次輪到執行失敗 monkey.PatchInstanceMethod( reflect.TypeOf((*exec.Cmd)(nil)), "CombinedOutput", func(_ *exec.Cmd) ([]byte, error) { return []byte(""), fmt.Errorf("sth bad happened") }, ) monkey.Patch(reportExecFailed, func(msg string) string { return msg }) rc, output := call("any") if rc != 1 { t.Fail() } if output != "" { t.Fail() }}
執行 go test xx_test.go
,可以運行上面的代碼。
測試中常有的一個需求:在位置 A 需要 mock 掉函數,在位置 B 裡需要調用原來的函數才能運行下去。這時候需要使用 monkey
庫提供的 PatchGuard
結構體。官方文檔中有個樣本,這裡稍微調整下:
package mainimport ( "fmt" "github.com/bouk/monkey" "strings")func main() { var guard *monkey.PatchGuard guard = monkey.Patch(fmt.Println, func(a ...interface{}) (n int, err error) { s := make([]interface{}, len(a)) for i, v := range a { s[i] = strings.Replace(fmt.Sprint(v), "hell", "*bleep*", -1) } // 以下代碼等價於 // guard.Unpatch() // defer guard.Restore() // return fmt.Println(s...) guard.Unpatch() n, err = fmt.Println(s...) guard.Restore() return }) fmt.Println("what the hell?") // what the *bleep*? fmt.Println("what the hell?") // what the *bleep*?}
上面的代碼關鍵在於,調用原來的函數之前先調用一次 Unpatch
,恢複到 mock 之前的情況;然後在調用了原函數之後,調用一次 Restore
,重新打上 mock。剩下的,無非是根據輸入參數來判斷現在是運行到位置 A,還是位置 B。