這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
序言
要寫出好的測試代碼,必須精通相關的測試架構。對於Golang的程式員來說,至少需要掌握下面四個測試架構:
- GoConvey
- GoStub
- GoMock
- Monkey
通過前面四篇文章,我們已經掌握了架構GoConvey + GoStub + GoMock組合使用的正確姿勢,同時已經知道:
- 全域變數可通過GoStub架構打樁
- 過程可通過GoStub架構打樁
- 函數可通過GoStub架構打樁
- interface可通過GoMock架構打樁
但還有兩個問題比較棘手:
- 方法(成員函數)無法通過GoStub架構打樁,當產品代碼的OO設計比較多時,打樁點可能離被測函數比較遠,導致UT用例寫起來比較痛
- 過程或函數通過GoStub架構打樁時,對產品代碼有侵入性
下面我們舉兩個例子,闡述GoStub架構對產品代碼的侵入性
例一:函數定義侵入
func Exec(cmd string, args ...string) (string, error) { ...}
上面的函數Exec的定義為常規方式,但這時不能通過GoStub架構對函數Exec進行打樁,除非將函數Exec定義為非常規方式(侵入性):
var Exec = func(cmd string, args ...string) (string, error) { ...}
例二:適配層侵入
產品代碼中很多函數都會調用Golang的庫函數或第三方的庫函數,這些庫函數的定義顯然是常規方式,要想通過GoStub架構對這些函數打樁,一般會在適配層定義相關的變數(侵入性):
package adaptervar Stat = os.Statvar Marshal = json.Marshalvar UnMarshal = json.Unmarshal...
本文將介紹第四個架構Monkey的使用方法,目的是解決這兩個棘手的問題,同時考慮將GoStub的優點整合到Monkey。
Monkey簡介
Monkey是Golang的一個猴子補丁(monkeypatching)架構,在運行時通過彙編語句重寫可執行檔,將待打樁函數或方法的實現跳轉到樁實現,原理和熱補丁類似。如果讀者想進一步瞭解Monkey的工作原理,請閱讀部落格:http://bouk.co/blog/monkey-patching-in-go/。
通過Monkey,我們可以解決函數或方法的打樁問題,但Monkey不是安全執行緒的,不要將Monkey用於並發的測試中。
安裝
在命令列運行下面的命令:
go get github.com/bouk/monkey
運行完後你會發現,在$GOPATH/src/github.com目錄下,新增了bouk/monkey子目錄,這就是本文的主角。
使用情境
Monkey架構的使用情境很多,依次為:
- 基本情境:為一個函數打樁
- 基本情境:為一個過程打樁
- 基本情境:為一個方法打樁
- 複合情境:由任意相同或不同的基本情境組合而成
- 特殊情境:樁中樁的一個案例
為一個函數打樁
Exec是infra層的一個操作函數,實現很簡單,代碼如下所示:
// infra/os-encap/exec.gofunc Exec(cmd string, args ...string) (string, error) { cmdpath, err := exec.LookPath(cmd) if err != nil { fmt.Errorf("exec.LookPath err: %v, cmd: %s", err, cmd) return "", infra.ErrExecLookPathFailed } var output []byte output, err = exec.Command(cmdpath, args...).CombinedOutput() if err != nil { fmt.Errorf("exec.Command.CombinedOutput err: %v, cmd: %s", err, cmd) return "", infra.ErrExecCombinedOutputFailed } fmt.Println("CMD[", cmdpath, "]ARGS[", args, "]OUT[", string(output), "]") return string(output), nil}
Exec函數的實現中調用了庫函數exec.LoopPath和exec.Command,因此Exec函數的傳回值和運行時的底層環境密切相關。在UT中,如果被測函數調用了Exec函數,則應根據用例的情境對Exec函數打樁。
Monkey的API非常簡單和直接,我們直接看打樁代碼:
import ( "testing" . "github.com/smartystreets/goconvey/convey" . "github.com/bouk/monkey" "infra/osencap")func TestExec(t *testing.T) { Convey("test has digit", t, func() { Convey("for succ", func() { guard := Patch(osencap.Exec, func(cmd string, args ...string) (string, error) { return outputExpect, nil }) defer guard.Unpatch() output, err := osencap.Exec(any, any) So(output, ShouldEqual, outputExpect) So(err, ShouldBeNil) }) })}
Patch是Monkey提供給使用者用於函數打樁的API,Patch的傳回值是一個PatchGuard對象指標,主要用於在測試結束時刪除當前的補丁,Patch函數的聲明如下:
func Patch(target, replacement interface{}) *PatchGuard
為一個過程打樁
當一個函數沒有傳回值時,該函數我們一般稱為過程。很多時候,我們將資源清理類函數定義為過程。
我們對過程DestroyResource的打樁代碼為:
guard := Patch(DestroyResource, func(key string) {})defer guard.Unpatch()
為一個方法打樁
假設資料庫Etcd有一個方法Get,當使用者輸入團隊id時,該方法將返回團隊成員的名字列表:
type Etcd struct {}func (e *Etcd) Get(id string) []string { names := make([]string, 0) ... return names
我們對Get方法的打樁代碼如下:
var e *Etcdguard := PatchInstanceMethod(reflect.TypeOf(e), "Get", func(_ string) []string { return []string{"LiLei", "HanMeiMei", "ZhangMing"}})defer guard.Unpatch()
任意相同或不同的基本情境組合
假設Px為函數、過程或方法的打樁調用,則任意相同或不同基本情境組合的打樁過程形式化表達為:
Px1defer UnpatchAll()Px2...Pxn
該測試執行完後,函數UnpatchAll
將刪除所有的補丁。
樁中樁的一個案例
在某些特殊情境下(比如還原序列化),函數或方法既有傳回值,又有出參。出參一般為指標類型,包括具體的指標類型(比如*int)和抽象的指標類型(一般為interface{})。我們常用的函數json.Unmarshal
就屬於這種情況。
筆者在實踐中遇到的出參類型大多是具體的指標類型,其指標變數指向的記憶體不管在傳入前確定還是在傳入後確定,都將影響後面的代碼邏輯。
下面呈現樁中樁的一個案例,以便大家靈活使用Monkey架構。
何謂樁中樁?
interface中聲明了一個方法,既有傳回值,又有出參。在測試中,先通過GoMock架構打樁多態到mock方法,然後又通過Monkey架構跳轉到補丁方法,最終修改出參並返回。在這個過程中,mock方法可以看作一個樁,補丁方法又可以看作mock方法的一個樁,即補丁方法是一個樁中樁。
定義一個具體類型Movie:
type Movie struct { Name string Type string Score int}
定義一個interface類型Repository:
type Repository interface { ... Retrieve(key string, movie *Movie) error}
樁中樁的一個測試案例:
func TestDemo(t *testing.T) { Convey("test demo", t, func() { Convey("retrieve movie", func() { ctrl := NewController(t) defer ctrl.Finish() mockRepo := mock_db.NewMockRepository(ctrl) mockRepo.EXPECT().Retrieve(Any(), Any()).Return(nil) Patch(redisrepo.GetInstance, func() Repository { return mockRepo }) defer UnpatchAll() PatchInstanceMethod(reflect.TypeOf(mockRepo), "Retrieve", func(name string, movie *Movie) error { movie = &Movie{Name: name, Type: "Love", Score: 95} return nil }) repo := redisrepo.GetInstance() var movie *Movie = nil err := repo.Retrieve("Titanic", movie) So(err, ShouldBeNil) So(movie.Name, ShouldEqual, "Titanic") So(movie.Type, ShouldEqual, "Love") So(movie.Score, ShouldEqual, 95) }) ... })}
我們先通過Monkey架構的Patch API將mock對象注入,然後通過Monkey架構的PatchInstanceMethod API將mock方法跳轉到補丁方法,間接完成對指標變數movie的記憶體配置及賦值,並返回nil。
Monkey的缺陷及應對方案
inline函數
Golang中雖然沒有inline關鍵字,但仍存在inline函數,一個函數是否是inline函數由編譯器決定。inline函數的特點是簡單短小,在原始碼的層次看有函數的結構,而在編譯後卻不具備函數的性質。inline函數不是在調用時發生控制轉移,而是在編譯時間將函數體嵌入到每一個調用處,所以inline函數在調用時沒有地址。
inline函數沒有地址的特性導致了Monkey架構的第一個缺陷:對inline函數打樁無效。
類比一個簡單的inline函數:
func IsEqual(a, b string) bool { return a == b}
對HasDigit函數進行打樁測試:
func TestIsEqual(t *testing.T) { Convey("test is equal", t, func() { Convey("for patch true", func() { guard := Patch(IsEqual, func(_, _ string) bool { return true }) defer guard.Unpatch() ok := IsEqual("hello", "world") So(ok, ShouldBeTrue) }) })}
在命令列運行這個測試,結果不符合期望:
$ go test -v func_test.go -test.run TestIsEqual=== RUN TestIsEqual test is equal for patch true ✘Failures: * /Users/zhangxiaolong/Desktop/D/go-workspace/src/test/monkey/func_test.go Line 67: Expected: true Actual: false1 total assertion--- FAIL: TestIsEqual (0.00s)FAILexit status 1FAIL command-line-arguments 0.006s
應對方案:通過命令列參數-gcflags=-l
禁止inline
在命令列增加參數-gcflags=-l
重新運行測試,結果符合期望:
go test -gcflags=-l -v func_test.go -test.run TestIsEqual=== RUN TestIsEqual test is equal for patch true 1 total assertion--- PASS: TestIsEqual (0.00s)PASSok command-line-arguments 0.007s
方法名首字母小寫
這一年多,Golang的版本在快速演化,上個月已經發布了go1.9版本。然而,一些團隊可能一直還在用go1.6版本,並有計劃在近期升級到go1.7或以上版本。
Monkey架構的實現中大量使用了反射機制,尤其是方法的補丁實現函數PatchInstanceMethod
。但是,go1.6版本和更高版本(比如go1.7)的反射機制有些差異:在go1.6版本中反射機制會匯出所有方法(不論首字母是大寫還是小寫),而在更高版本中反射機制僅會匯出首字母大寫的方法。
反射機制的這種差異導致了Monkey架構的第二個缺陷:在go1.6版本中可以成功打樁的首字母小寫方法,當go版本升級後Monkey架構會顯式觸發panic,表示unknown method
:
m, ok := target.MethodByName(methodName)if !ok { panic(fmt.Sprintf("unknown method %s", methodName))}
說明:反射機制的差異並不波及Patch
函數的實現,所以go版本升級前後首字母小寫函數名的打樁不受影響。
正交設計四原則
告訴我們,要向穩定的方向依賴。首字母小寫方法或函數不是public的,僅在包內可見,不是一個穩定的依賴方向。如果在UT測試中對首字母小寫方法或函數打樁的話,會導致重構的成本比較大。
應對方案:不管現在團隊使用的go版本是哪一個,都不要對首字母小寫方法或函數打樁,不但可以確保測試案例在go版本升級前後的穩定性,而且能有效降低重構的成本。
API
在討論Monkey的API之前,我們先回顧一下GoStub架構的API。
GoStub架構的API既包括函數API,也包括方法API。由於Monkey架構的API只涉及函數API,所以在這裡我們只回顧GoStub架構的函數API。
我們先看GoStub架構的第一個函數API:
func Stub(varToStub interface{}, stubVal interface{}) *Stubs
這個API我們一般用於對全域變數打樁:
stubs := Stub(&num, 150)defer stubs.Reset()
然而,這個API也可以用於函數打樁:
stubs := Stub(&Exec, func(cmd string, args ...string) (string, error) { return "xxx-vethName100-yyy", nil})defer stubs.Reset()
GoStub架構的Stub API對函數的打樁方法是不是和Monkey架構的API的使用方法很像?這是毋庸置疑的,這樣的API才是原生的API,StubFunc API是專門針對函數或過程打樁的改進版:
func StubFunc(funcVarToStub interface{}, stubVal ...interface{}) *Stubs
StubFunc替代Stub對函數的打樁樣本:
stubs := StubFunc(&Exec,"xxx-vethName100-yyy", nil)defer stubs.Reset()
是不是簡潔優雅了很多?這是毋庸置疑的。
說明:一般情況下,Golang的樁函數都關注的是傳回值,所以這種封裝很適用。但在特殊情境下,即樁函數在關注傳回值的同時也關注出參,這時就要用原生的API。
為了應對多次調用樁函數而呈現不同行為
的複雜情況,筆者二次開發了GoStub架構,提供了下面的API:
type Values []interface{}type Output struct { StubVals Values Times int}func (s *Stubs) StubFuncSeq(funcVarToStub interface{}, outputs []Output) *Stubs
只有原生的API導致了Monkey架構的第三個缺陷:API不夠簡潔優雅,同時不支援多次調用樁函數(方法)而呈現不同行為的複雜情況。
應對方案:筆者計劃二次開發,增加下面四個API:
func PatchFunc(target interface{}, stubVal ...interface{}) *PatchGuardfunc PatchInstanceMethodFunc(target reflect.Type, methodName string, stubVal ...interface{}) *PatchGuardfunc PatchFuncSeq(target interface{}, outputs []Output) *PatchGuardfunc PatchInstanceMethodFuncSeq(target reflect.Type, methodName string, outputs []Output) *PatchGuard
小結
本文主要介紹了Monkey架構的使用方法,基本上解決了序言中提到的那兩個棘手的問題,同時針對Monkey架構的缺陷,提供了應對方案。
我們在產品代碼中,盡量要少使用全域變數,同時筆者將會在近期完成對Monkey架構的二次開發。這樣的話,Monkey架構基本上就可以全部替代GoStub架構了,這或許就是一個守破離
的過程吧:)
至此,我們已經知道:
- 全域變數可通過GoStub架構打樁
- 過程可通過Monkey架構打樁
- 函數可通過Monkey架構打樁
- 方法可通過Monkey架構打樁
- interface可通過GoMock架構打樁
我們在測試實踐中要舉一反三,深度掌握GoConvey + GoStub + GoMock + Monkey架構組合使用的正確姿勢,寫出高品質的測試代碼。
當然,在Golang的UT測試實踐中,除過這幾個通用的測試架構,還有一些專用的測試架構需要掌握,比如GoSqlMock和HttpExpect,讀者可根據實際需求自行學習。