Monkey框架使用指南

序言

要寫出好的測(cè)試代碼,必須精通相關(guān)的測(cè)試框架。對(duì)于Golang的程序員來(lái)說(shuō),至少需要掌握下面四個(gè)測(cè)試框架:

  • GoConvey
  • GoStub
  • GoMock
  • Monkey

通過(guò)前面四篇文章,我們已經(jīng)掌握了框架GoConvey + GoStub + GoMock組合使用的正確姿勢(shì),同時(shí)已經(jīng)知道:

  1. 全局變量可通過(guò)GoStub框架打樁
  2. 過(guò)程可通過(guò)GoStub框架打樁
  3. 函數(shù)可通過(guò)GoStub框架打樁
  4. interface可通過(guò)GoMock框架打樁

但還有兩個(gè)問(wèn)題比較棘手:

  1. 方法(成員函數(shù))無(wú)法通過(guò)GoStub框架打樁,當(dāng)產(chǎn)品代碼的OO設(shè)計(jì)比較多時(shí),打樁點(diǎn)可能離被測(cè)函數(shù)比較遠(yuǎn),導(dǎo)致UT用例寫起來(lái)比較痛
  2. 過(guò)程或函數(shù)通過(guò)GoStub框架打樁時(shí),對(duì)產(chǎn)品代碼有侵入性

下面我們舉兩個(gè)例子,闡述GoStub框架對(duì)產(chǎn)品代碼的侵入性
例一:函數(shù)定義侵入

func Exec(cmd string, args ...string) (string, error) {
    ...
}

上面的函數(shù)Exec的定義為常規(guī)方式,但這時(shí)不能通過(guò)GoStub框架對(duì)函數(shù)Exec進(jìn)行打樁,除非將函數(shù)Exec定義為非常規(guī)方式(侵入性):

var Exec = func(cmd string, args ...string) (string, error) {
    ...
}

例二:適配層侵入

產(chǎn)品代碼中很多函數(shù)都會(huì)調(diào)用Golang的庫(kù)函數(shù)或第三方的庫(kù)函數(shù),這些庫(kù)函數(shù)的定義顯然是常規(guī)方式,要想通過(guò)GoStub框架對(duì)這些函數(shù)打樁,一般會(huì)在適配層定義相關(guān)的變量(侵入性):

package adapter

var Stat = os.Stat
var Marshal = json.Marshal
var UnMarshal = json.Unmarshal
...

本文將介紹第四個(gè)框架Monkey的使用方法,目的是解決這兩個(gè)棘手的問(wèn)題,同時(shí)考慮將GoStub的優(yōu)點(diǎn)集成到Monkey。

Monkey簡(jiǎn)介

Monkey是Golang的一個(gè)猴子補(bǔ)?。╩onkeypatching)框架,在運(yùn)行時(shí)通過(guò)匯編語(yǔ)句重寫可執(zhí)行文件,將待打樁函數(shù)或方法的實(shí)現(xiàn)跳轉(zhuǎn)到樁實(shí)現(xiàn),原理和熱補(bǔ)丁類似。如果讀者想進(jìn)一步了解Monkey的工作原理,請(qǐng)閱讀博客:http://bouk.co/blog/monkey-patching-in-go/。
通過(guò)Monkey,我們可以解決函數(shù)或方法的打樁問(wèn)題,但Monkey不是線程安全的,不要將Monkey用于并發(fā)的測(cè)試中。

安裝

在命令行運(yùn)行下面的命令:

go get github.com/bouk/monkey

運(yùn)行完后你會(huì)發(fā)現(xiàn),在$GOPATH/src/github.com目錄下,新增了bouk/monkey子目錄,這就是本文的主角。

使用場(chǎng)景

Monkey框架的使用場(chǎng)景很多,依次為:

  1. 基本場(chǎng)景:為一個(gè)函數(shù)打樁
  2. 基本場(chǎng)景:為一個(gè)過(guò)程打樁
  3. 基本場(chǎng)景:為一個(gè)方法打樁
  4. 復(fù)合場(chǎng)景:由任意相同或不同的基本場(chǎng)景組合而成
  5. 特殊場(chǎng)景:樁中樁的一個(gè)案例

為一個(gè)函數(shù)打樁

Exec是infra層的一個(gè)操作函數(shù),實(shí)現(xiàn)很簡(jiǎn)單,代碼如下所示:

// infra/os-encap/exec.go

func 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函數(shù)的實(shí)現(xiàn)中調(diào)用了庫(kù)函數(shù)exec.LoopPath和exec.Command,因此Exec函數(shù)的返回值和運(yùn)行時(shí)的底層環(huán)境密切相關(guān)。在UT中,如果被測(cè)函數(shù)調(diào)用了Exec函數(shù),則應(yīng)根據(jù)用例的場(chǎng)景對(duì)Exec函數(shù)打樁。
Monkey的API非常簡(jiǎn)單和直接,我們直接看打樁代碼:

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提供給用戶用于函數(shù)打樁的API:

  • 第一個(gè)參數(shù)是目標(biāo)函數(shù)的函數(shù)名
  • 第二個(gè)參數(shù)是樁函數(shù)的函數(shù)名,習(xí)慣用法是匿名函數(shù)或閉包
  • 返回值是一個(gè)PatchGuard對(duì)象指針,主要用于在測(cè)試結(jié)束時(shí)刪除當(dāng)前的補(bǔ)丁

為一個(gè)過(guò)程打樁

當(dāng)一個(gè)函數(shù)沒(méi)有返回值時(shí),該函數(shù)我們一般稱為過(guò)程。很多時(shí)候,我們將資源清理類函數(shù)定義為過(guò)程。
我們對(duì)過(guò)程DestroyResource的打樁代碼為:

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

})
defer guard.Unpatch()

為一個(gè)方法打樁

當(dāng)微服務(wù)有多個(gè)實(shí)例時(shí),先通過(guò)Etcd選舉一個(gè)Master實(shí)例,然后Master實(shí)例為所有實(shí)例較均勻的分配任務(wù),并將任務(wù)分配結(jié)果Set到Etcd,最后Master和Node實(shí)例Watch到任務(wù)列表,并過(guò)濾出自身需要處理的任務(wù)列表。

我們用類Etcd的方法Get來(lái)模擬獲取任務(wù)列表的功能,入?yún)閕nstanceId:

type Etcd struct {

}

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

我們對(duì)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:

  • 在使用前,先要定義一個(gè)目標(biāo)類的指針變量x
  • 第一個(gè)參數(shù)是reflect.TypeOf(x)
  • 第二個(gè)參數(shù)是字符串形式的函數(shù)名
  • 返回值是一個(gè)PatchGuard對(duì)象指針,主要用于在測(cè)試結(jié)束時(shí)刪除當(dāng)前的補(bǔ)丁

任意相同或不同的基本場(chǎng)景組合

假設(shè)Px為用于函數(shù)、過(guò)程或方法打樁的API調(diào)用,則任意相同或不同基本場(chǎng)景組合的打樁過(guò)程形式化表達(dá)為:

Px1
defer UnpatchAll()
Px2
...
Pxn

該測(cè)試執(zhí)行完后,函數(shù)UnpatchAll將刪除所有的補(bǔ)丁。

樁中樁的一個(gè)案例

在某些特殊場(chǎng)景下(比如反序列化),函數(shù)或方法既有返回值,又有出參。出參一般為指針類型,包括具體的指針類型(比如*int)和抽象的指針類型(一般為interface{})。我們常用的庫(kù)函數(shù)json.Unmarshal就屬于這種情況。

筆者在實(shí)踐中遇到的出參類型大多是具體的指針類型,其指針變量指向的內(nèi)存不管在傳入前確定還是在傳入后確定,都將影響后面的代碼邏輯。

下面呈現(xiàn)樁中樁的一個(gè)案例,以便大家靈活使用Monkey框架。

何謂樁中樁?
interface中聲明了一個(gè)方法,既有返回值,又有出參。在測(cè)試中,先通過(guò)GoMock框架打樁多態(tài)到mock方法,然后又通過(guò)Monkey框架跳轉(zhuǎn)到補(bǔ)丁方法,最終修改出參并返回。在這個(gè)過(guò)程中,mock方法可以看作一個(gè)樁,補(bǔ)丁方法又可以看作mock方法的一個(gè)樁,即補(bǔ)丁方法是一個(gè)樁中樁。

定義一個(gè)具體類型Movie:

type Movie struct {
    Name string
    Type string
    Score int
}

定義一個(gè)interface類型Repository:

type Repository interface {
    Retrieve(key string, movie *Movie) error
    ...
}

樁中樁的一個(gè)測(cè)試用例:

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)
        })
        ...
    })
}

我們先通過(guò)Monkey框架的Patch API將mock對(duì)象注入,然后通過(guò)Monkey框架的PatchInstanceMethod API將mock方法跳轉(zhuǎn)到補(bǔ)丁方法,間接完成對(duì)指針變量movie的內(nèi)存分配及賦值,并返回nil。

Monkey的缺陷及解決方案

inline函數(shù)

Golang中雖然沒(méi)有inline關(guān)鍵字,但仍存在inline函數(shù),一個(gè)函數(shù)是否是inline函數(shù)由編譯器決定。inline函數(shù)的特點(diǎn)是簡(jiǎn)單短小,在源代碼的層次看有函數(shù)的結(jié)構(gòu),而在編譯后卻不具備函數(shù)的性質(zhì)。inline函數(shù)不是在調(diào)用時(shí)發(fā)生控制轉(zhuǎn)移,而是在編譯時(shí)將函數(shù)體嵌入到每一個(gè)調(diào)用處,所以inline函數(shù)在調(diào)用時(shí)沒(méi)有地址。
inline函數(shù)沒(méi)有地址的特性導(dǎo)致了Monkey框架的第一個(gè)缺陷:對(duì)inline函數(shù)打樁無(wú)效。

模擬一個(gè)簡(jiǎn)單的inline函數(shù):

func IsEqual(a, b string) bool {
    return a == b
}

對(duì)HasDigit函數(shù)進(jìn)行打樁測(cè)試:

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)
        })
    })
}

在命令行運(yùn)行這個(gè)測(cè)試,結(jié)果不符合期望:

$ 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:   false


1 total assertion

--- FAIL: TestIsEqual (0.00s)
FAIL
exit status 1
FAIL    command-line-arguments  0.006s

解決方案:通過(guò)命令行參數(shù)-gcflags=-l禁止inline
在命令行增加參數(shù)-gcflags=-l重新運(yùn)行測(cè)試,結(jié)果符合期望:

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的版本在快速演進(jìn),上個(gè)月已經(jīng)發(fā)布了go1.9版本。然而,一些團(tuán)隊(duì)可能一直還在用go1.6版本,并有計(jì)劃在近期升級(jí)到go1.7或以上版本。
Monkey框架的實(shí)現(xiàn)中大量使用了反射機(jī)制,尤其是方法的補(bǔ)丁實(shí)現(xiàn)函數(shù)PatchInstanceMethod。但是,go1.6版本和更高版本(比如go1.7)的反射機(jī)制有些差異:在go1.6版本中反射機(jī)制會(huì)導(dǎo)出所有方法(不論首字母是大寫還是小寫),而在更高版本中反射機(jī)制僅會(huì)導(dǎo)出首字母大寫的方法。
反射機(jī)制的這種差異導(dǎo)致了Monkey框架的第二個(gè)缺陷:在go1.6版本中可以成功打樁的首字母小寫的方法,當(dāng)go版本升級(jí)后Monkey框架會(huì)顯式觸發(fā)panic,表示unknown method:

m, ok := target.MethodByName(methodName)
if !ok {
    panic(fmt.Sprintf("unknown method %s", methodName))
}

說(shuō)明:反射機(jī)制的差異并不波及Patch函數(shù)的實(shí)現(xiàn),所以go版本升級(jí)前后首字母小寫的函數(shù)名的打樁不受影響。

正交設(shè)計(jì)四原則告訴我們,要向穩(wěn)定的方向依賴。首字母小寫的方法或函數(shù)不是public的,僅在包內(nèi)可見,不是一個(gè)穩(wěn)定的依賴方向。如果在UT測(cè)試中對(duì)首字母小寫的方法或函數(shù)打樁的話,會(huì)導(dǎo)致重構(gòu)的成本比較大。
解決方案:不管現(xiàn)在團(tuán)隊(duì)使用的go版本是哪一個(gè),都不要對(duì)首字母小寫的方法或函數(shù)打樁,不但可以確保測(cè)試用例在go版本升級(jí)前后的穩(wěn)定性,而且能有效降低重構(gòu)的成本。

API

在討論Monkey的API之前,我們先回顧一下GoStub框架的API。
GoStub框架的API既包括函數(shù)API,也包括方法API。由于Monkey框架的API只涉及函數(shù)API,所以在這里我們只回顧GoStub框架的函數(shù)API。

我們先看GoStub框架的第一個(gè)函數(shù)API:

func Stub(varToStub interface{}, stubVal interface{}) *Stubs

這個(gè)API我們一般用于對(duì)全局變量打樁:

stubs := Stub(&num, 150)
defer stubs.Reset()

然而,這個(gè)API也可以用于函數(shù)打樁:

stubs := Stub(&osencap.Exec, func(_ string, _ ...string) (string, error) {
            return "xxx-vethName100-yyy", nil
})
defer stubs.Reset()

GoStub框架的Stub API對(duì)函數(shù)的打樁方法是不是和Monkey框架的API的使用方法很像?這是毋庸置疑的,這樣的API才是原生的API,StubFunc API是專門針對(duì)函數(shù)或過(guò)程打樁的改進(jìn)版:

func StubFunc(funcVarToStub interface{}, stubVal ...interface{}) *Stubs

StubFunc替代Stub對(duì)函數(shù)的打樁示例:

stubs := StubFunc(&osencap.Exec,"xxx-vethName100-yyy", nil)
defer stubs.Reset()

是不是簡(jiǎn)潔優(yōu)雅了很多?

說(shuō)明:一般情況下,Golang的樁函數(shù)都關(guān)注的是返回值,所以這種封裝很適用。但在特殊場(chǎng)景下,即樁函數(shù)在關(guān)注返回值的同時(shí)也關(guān)注出參,這時(shí)就要用原生的API。

為了應(yīng)對(duì)多次調(diào)用樁函數(shù)而呈現(xiàn)不同行為的復(fù)雜情況,筆者二次開發(fā)了GoStub框架,提供了下面的API:

type Values []interface{}
type Output struct {
    StubVals Values
    Times int
}

func (s *Stubs) StubFuncSeq(funcVarToStub interface{}, outputs []Output) *Stubs

只有原生的API導(dǎo)致了Monkey框架的第三個(gè)缺陷:API不夠簡(jiǎn)潔優(yōu)雅,同時(shí)不支持多次調(diào)用樁函數(shù)(方法)而呈現(xiàn)不同行為的復(fù)雜情況。

解決方案:筆者計(jì)劃二次開發(fā)Monkey框架,增加下面四個(gè)API:

func PatchFunc(target interface{}, stubVal ...interface{}) *PatchGuard
func PatchInstanceMethodFunc(target reflect.Type, methodName string, stubVal ...interface{}) *PatchGuard
func PatchFuncSeq(target interface{}, outputs []Output) *PatchGuard
func PatchInstanceMethodFuncSeq(target reflect.Type, methodName string, outputs []Output) *PatchGuard

小結(jié)

本文主要介紹了Monkey框架的使用方法,基本上解決了序言中提到的那兩個(gè)棘手的問(wèn)題,同時(shí)針對(duì)Monkey框架的三個(gè)缺陷,分別提供了解決方案。

至此,我們已經(jīng)知道:

  1. 全局變量可通過(guò)GoStub框架打樁
  2. 過(guò)程可通過(guò)Monkey框架打樁
  3. 函數(shù)可通過(guò)Monkey框架打樁
  4. 方法可通過(guò)Monkey框架打樁
  5. interface可通過(guò)GoMock框架打樁

我們?cè)跍y(cè)試實(shí)踐中要舉一反三,深度掌握GoConvey + GoStub + GoMock + Monkey框架組合使用的正確姿勢(shì),寫出高質(zhì)量的測(cè)試代碼。
我們?cè)诋a(chǎn)品代碼中,盡量不要使用全局變量,同時(shí)筆者將會(huì)在近期完成對(duì)Monkey框架的二次開發(fā)。這樣的話,Monkey框架基本上就可以全部替代GoStub框架了,這或許就是一個(gè)守破離的案例吧:)

當(dāng)然,在Golang的UT測(cè)試實(shí)踐中,除過(guò)這幾個(gè)通用的測(cè)試框架,還有一些專用的測(cè)試框架需要掌握,比如GoSqlMockHttpExpect,讀者可根據(jù)實(shí)際需求自行學(xué)習(xí)。

附:筆者近期發(fā)布了 gomonkey 框架,功能比較強(qiáng)大,可以輕松替代GoStub+Monkey框架,而且計(jì)劃后續(xù)也提供部分GoMock的功能。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 序言 要寫出好的測(cè)試代碼,必須精通相關(guān)的測(cè)試框架。對(duì)于Golang的程序員來(lái)說(shuō),至少需要掌握下面四個(gè)測(cè)試框架: G...
    _張曉龍_閱讀 20,302評(píng)論 4 16
  • 序言 要寫出好的測(cè)試代碼,必須精通相關(guān)的測(cè)試框架。對(duì)于Golang的程序員來(lái)說(shuō),至少需要掌握下面四個(gè)測(cè)試框架: G...
    _張曉龍_閱讀 4,297評(píng)論 1 13
  • Android 自定義View的各種姿勢(shì)1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 178,926評(píng)論 25 709
  • Spring Cloud為開發(fā)人員提供了快速構(gòu)建分布式系統(tǒng)中一些常見模式的工具(例如配置管理,服務(wù)發(fā)現(xiàn),斷路器,智...
    卡卡羅2017閱讀 136,537評(píng)論 19 139
  • 關(guān)于時(shí)間的思考 上篇提到了些關(guān)于時(shí)空維度的認(rèn)識(shí)和想法,本文將探討一些自己對(duì)時(shí)間的思考。 人們對(duì)于時(shí)間的思考思考一直...
    掃地曾1900閱讀 1,263評(píng)論 1 2

友情鏈接更多精彩內(nèi)容