Monkey架構使用指南

來源:互聯網
上載者:User
這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。

序言

要寫出好的測試代碼,必須精通相關的測試架構。對於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")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架構了,這或許就是一個守破離的過程吧:)

至此,我們已經知道:

  1. 全域變數可通過GoStub架構打樁
  2. 過程可通過Monkey架構打樁
  3. 函數可通過Monkey架構打樁
  4. 方法可通過Monkey架構打樁
  5. interface可通過GoMock架構打樁

我們在測試實踐中要舉一反三,深度掌握GoConvey + GoStub + GoMock + Monkey架構組合使用的正確姿勢,寫出高品質的測試代碼。

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

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.