Monkey框架使用指南

longmanma發表於2021-09-09

序言

要寫出好的測試程式碼,必須精通相關的測試框架。對於Golang的程式設計師來說,至少需要掌握下面四個測試框架:

  • GoConvey

  • GoStub

  • GoMock

  • Monkey

透過前面四篇文章,我們已經掌握了框架GoConvey + GoStub + GoMock組合使用的正確姿勢,同時已經知道:

  1. 全域性變數可透過GoStub框架打樁

  2. 過程可透過GoStub框架打樁

  3. 函式可透過GoStub框架打樁

  4. interface可透過GoMock框架打樁

但還有兩個問題比較棘手:

  1. 方法(成員函式)無法透過GoStub框架打樁,當產品程式碼的OO設計比較多時,打樁點可能離被測函式比較遠,導致UT用例寫起來比較痛

  2. 過程或函式透過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框架的使用場景很多,依次為:

  1. 基本場景:為一個函式打樁

  2. 基本場景:為一個過程打樁

  3. 基本場景:為一個方法打樁

  4. 複合場景:由任意相同或不同的基本場景組合而成

  5. 特殊場景:樁中樁的一個案例

為一個函式打樁

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")const any = "any"func TestExec(t *testing.T) {
    Convey("test has digit", t, func() {
        Convey("for succ", func() {
            outputExpect := "xxx-vethName100-yyy"
            guard := Patch(osencap.Exec, func(_ string, _ ...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:

  • 第一個引數是目標函式的函式名

  • 第二個引數是樁函式的函式名,習慣用法是匿名函式或閉包

  • 返回值是一個PatchGuard物件指標,主要用於在測試結束時刪除當前的補丁

為一個過程打樁

當一個函式沒有返回值時,該函式我們一般稱為過程。很多時候,我們將資源清理類函式定義為過程。
我們對過程DestroyResource的打樁程式碼為:

guard := Patch(DestroyResource, func(_ string) {

})defer guard.Unpatch()

為一個方法打樁

當微服務有多個例項時,先透過Etcd選舉一個Master例項,然後Master例項為所有例項較均勻的分配任務,並將任務分配結果Set到Etcd,最後Master和Node例項Watch到任務列表,並過濾出自身需要處理的任務列表。

我們用類Etcd的方法Get來模擬獲取任務列表的功能,入參為instanceId:

type Etcd struct {

}func (e *Etcd) Get(instanceId string) []string {
    taskList := make([]string, 0)
    ...    return taskList

我們對Get方法的打樁程式碼如下:

var e *Etcd
guard := PatchInstanceMethod(reflect.TypeOf(e), "Get", func(_ *Etcd, _ string) []string {    return []string{"task1", "task5", "task8"}
})defer guard.Unpatch()

PatchInstanceMethod API是Monkey提供給使用者用於方法打樁的API:

  • 在使用前,先要定義一個目標類的指標變數x

  • 第一個引數是reflect.TypeOf(x)

  • 第二個引數是字串形式的函式名

  • 返回值是一個PatchGuard物件指標,主要用於在測試結束時刪除當前的補丁

任意相同或不同的基本場景組合

假設Px為用於函式、過程或方法打樁的API呼叫,則任意相同或不同基本場景組合的打樁過程形式化表達為:

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(_ *mock_db.MockRepository, name string, movie *Movie) error {
                movie = &Movie{Name: name, Type: "Love", Score: 95}                return nil
            })
            repo := redisrepo.GetInstance()            var movie *Movie
            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)
FAIL
exit 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)
PASS
ok      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(&osencap.Exec, func(_ string, _ ...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(&osencap.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不夠簡潔優雅,同時不支援多次呼叫樁函式(方法)而呈現不同行為的複雜情況。

解決方案:筆者計劃二次開發Monkey框架,增加下面四個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框架的三個缺陷,分別提供瞭解決方案。

至此,我們已經知道:

  1. 全域性變數可透過GoStub框架打樁

  2. 過程可透過Monkey框架打樁

  3. 函式可透過Monkey框架打樁

  4. 方法可透過Monkey框架打樁

  5. interface可透過GoMock框架打樁

我們在測試實踐中要舉一反三,深度掌握GoConvey + GoStub + GoMock + Monkey框架組合使用的正確姿勢,寫出高質量的測試程式碼。
我們在產品程式碼中,儘量不要使用全域性變數,同時筆者將會在近期完成對Monkey框架的二次開發。這樣的話,Monkey框架基本上就可以全部替代GoStub框架了,這或許就是一個守破離的案例吧:)

當然,在Golang的UT測試實踐中,除過這幾個通用的測試框架,還有一些專用的測試框架需要掌握,比如和,讀者可根據實際需求自行學習。



作者:_張曉龍_
連結:

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/2730/viewspace-2810942/,如需轉載,請註明出處,否則將追究法律責任。