這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
序言
要寫出好的測試代碼,必須精通相關的測試架構。對於Golang的程式員來說,至少需要掌握下面三個測試架構:
通過上一篇文章《GoStub架構使用指南》的學習,大家熟悉了GoStub架構的基本使用方法,可以優雅的對全域變數、函數或過程打樁,提高了單元測試水平。
儘管GoStub架構已經解決了很多情境的函數打樁問題,但對於一些複雜的情況,卻只能乾瞪眼:
被測函數中多次調用了資料庫讀操作函數介面 ReadDb,並且資料庫為key-value型。被測函數先是 ReadDb 了一個父目錄的值,然後在 for 迴圈中讀了若干個子目錄的值。在多個測試案例中都有將ReadDb打樁為在多次調用中呈現不同行為的需求,即父目錄的值不同於子目錄的值,並且子目錄的值也互不相等
被測函數中多次調用了同一底層操作函數,比如 exec.Command,函數參數既有命令也有命令參數。被測函數先是建立了一個對象,然後查詢對象的狀態,在對象狀態達不到期望時還要刪除對象,其中查詢對象是一個重要的操作,一般會進行多次重試。在多個測試案例中都有將 exec.Command 打樁為多次調用中呈現不同行為的需求,即建立對象、查詢對象狀態和刪除對象對傳回值的期望都不一樣
...
針對GoStub架構不適用的複雜情況,本文將對該架構進行二次開發,優雅的變不適用為適用,提高GoStub架構的適應能力。
介面
根據開閉原則,我們通過新增介面來應對複雜情況,那麼應該增加兩個介面:
- 函數介面
- 方法介面
對於複雜情況,都是針對一個函數的多次調用而產生不同的行為,即存在多個傳回值列表。顯然使用者打樁時應該指定一個數組切片[]Output,那麼數組切片的元素Output應該是什麼呢?
每一個函數的傳回值列表的大小不是確定的,且傳回值類型也不統一,所以Output本身也是一個數組切片,Output的元素是interface{}。
於是Output有了下面的定義:
type Output []interface{}
對於函數介面的聲明如下所示:
func StubFuncSeq(funcVarToStub interface{}, outputs []Output) *Stubs
對於方法介面的聲明如下所示:
func (s *Stubs) StubFuncSeq(funcVarToStub interface{}, outputs []Output) *Stubs
但還存在下面兩種情況:
- 當被打樁函數在重試調用的情境下,outputs中存在多個相鄰的值是一樣的
- 當被打樁函數在多次返回正常且傳回值列表值相同的情境下,outputs中存在多個相鄰的值是一樣的
重複是萬惡之源,我們保持零容忍,所以引入Times變數到Output中,於是Output的定義就演化為:
於是Output有了下面的定義:
type Values []interface{}type Output struct { StubVals Values Times int}
介面使用
情境一:多次讀資料庫
假設我們在一個函數f中讀了3次資料庫,比如調用了3次函數ReadLeaf,即通過3個不同的url讀取了3個不同的value。ReadLeaf在db包中定義,樣本如下:
var ReadLeaf = func(url string)(string, error) { ...}
假設對該函數打樁之前還未產生stubs對象,覆蓋3次讀資料庫的情境的打樁代碼如下:
info1 := "..."info2 := "..."info3 := "..."outputs := []Output{ Output{StubVals: Values{info1, nil}}, Output{StubVals: Values{info2, nil}}, Output{StubVals: Values{info3, nil}},}stubs := StubFuncSeq(db.ReadLeaf, outputs)defer stubs.Reset()...
說明:不指定Times時,Times的值為1
情境二:底層操作有重試
假設我們在一個函數f中調用了3次底層操作函數,比如調用了3次Command函數,即第一次調用建立對象,第二次調用查詢對象的狀態,在狀態達不到期望的情況下第三次掉用刪除對象,其中第二次調用時為了提高正確性,進行了10次嘗試。Command在exec包中定義,屬於庫函數,我們不能直接打樁,所以要在適配層adapter包中進行二次封裝:
var Command = func(cmd string, arg ...string)(string, error) { ...}
假設對該函數打樁之前已經產生了stubs對象,覆蓋前9次嘗試失敗且第10次嘗試成功的情境的打樁代碼如下:
info1 := "..."info2 := ""info3 := "..."outputs := []Output{ Output{StubVals: Values{info1, nil}}, Output{StubVals: Values{info2, ErrAny}, Times: 9}, Output{StubVals: Values{info3, nil}},}stubs.StubFuncSeq(db.ReadLeaf, outputs)...
介面實現
函數介面實現
函數介面的實現很簡單,直接委託方法介面實現:
func StubFuncSeq(funcVarToStub interface{}, outputs []Output) *Stubs { return New().StubFuncSeq(funcVarToStub, outputs)}
提供函數介面的目的是,在Stubs對象產生之前就可以使用該介面。
方法介面實現
我們回顧一下方法介面的聲明:
func (s *Stubs) StubFuncSeq(funcVarToStub interface{}, outputs []Output) *Stubs
方法介面的實現相對比較複雜,需要藉助反射和閉包這兩個強大的功能。
為了便於實現,我們分而治之,先進行to do list的拆分:
- 入參校正。(1)funcVarToStub必須為指向函數的指標變數;(2)函數傳回值列表的大小必須和Output.StubVals切片的長度相等
- 將outputs中的Times變數都消除,轉化成一個純的多組傳回值列表,即切片[]Values,設切片變數為slice
- 構造一個閉包函數,自由變數為i,i的值為[0, len(slice) - 1],閉包函數的傳回值列表為slice[i]
- 將待打樁函數替換為閉包函數
入參校正
入參校正的代碼參考了StubFunc方法的實現,如下所示:
funcPtrType := reflect.TypeOf(funcVarToStub)if funcPtrType.Kind() != reflect.Ptr || funcPtrType.Elem().Kind() != reflect.Func { panic("func variable to stub must be a pointer to a function")}funcType := funcPtrType.Elem()if funcType.NumOut() != len(outputs[0].StubVals) { panic(fmt.Sprintf("func type has %v return values, but only %v stub values provided", funcType.NumOut(), len(outputs[0].StubVals)))}
構造slice
構造slice的代碼很簡單,如下所示:
slice := make([]Values, 0)for _, output := range outputs { t := 0 if output.Times <= 1 { t = 1 } else { t = output.Times } for j := 0; j < t; j++ { slice = append(slice, output.StubVals) }}
產生閉包
產生閉包的代碼實現中調用了新封裝的函數getResultValues,如下所示:
i := 0len := len(slice)stubVal := reflect.MakeFunc(funcType, func(_ []reflect.Value) []reflect.Value { if i < len { i++ return getResultValues(funcPtrType.Elem(), slice[i - 1]...) } panic("output seq is less than call seq!")})
新封裝的函數getResultValues的實現參考了StubFunc方法的實現,如下所示:
func getResultValues(funcType reflect.Type, results ...interface{}) []reflect.Value { var resultValues []reflect.Value for i, r := range results { var retValue reflect.Value if r == nil { retValue = reflect.Zero(funcType.Out(i)) } else { tempV := reflect.New(funcType.Out(i)) tempV.Elem().Set(reflect.ValueOf(r)) retValue = tempV.Elem() } resultValues = append(resultValues, retValue) } return resultValues}
將待打樁函數替換為閉包
這裡直接複用既有的變數打樁方法Stub即可實現,如下所示:
return s.Stub(funcVarToStub, stubVal.Interface())
至此,StubFuncSeq方法實現完了,oh yeah!
反模式
通過上一篇文章《GoStub架構使用指南》的學習,讀者會寫出諸如下面的測試代碼:
func TestFuncDemo(t *testing.T) { Convey("TestFuncDemo", t, func() { Convey("for succ", func() { var liLei = `{"name":"LiLei", "age":"21"}` stubs := StubFunc(&adapter.Marshal, []byte(liLei), nil) defer stubs.Reset() //several So assert }) Convey("for fail", func() { stubs := StubFunc(&adapter.Marshal, nil, ERR_ANY) //several So assert }) })}
GoStub架構有了StubFuncSeq介面後,有些讀者就會將上面的測試代碼寫成下面的反模式:
func TestFuncDemo(t *testing.T) { Convey("TestFuncDemo", t, func() { var liLei = `{"name":"LiLei", "age":"21"}` outputs := []Output{ Output{StubVals: Values{[]byte(liLei), nil}}, Output{StubVals: Values{ErrAny, nil}}, } stubs := StubFuncSeq(&adapter.Marshal, outputs) defer stubs.Reset() Convey("for succ", func() { //several So assert }) Convey("for fail", func() { //several So assert }) })}
有的讀者可能認為上面的測試代碼更好,但一般情況下,一個測試函數有多個測試案例,即第二級的Convey數(5個左右很常見)。如果將所有測試案例的樁函數都寫在一起,將非常複雜,而且很多時候會超過人腦的掌握極限,所以筆者將這種模式稱為反模式。
我們提倡每個用例管理自己的樁函數,即分離關注點。
小結
針對GoStub架構不適用的複雜情況,本文對該架構進行了二次開發,包括新增介面StubFuncSeq的定義、使用及實現,優雅的變不適用為適用,提高了GoStub架構的適應能力。本文在最後還提出了StubFuncSeq介面使用的反模式,使得讀者保持警惕,從而正確的使用GoStub架構。