GoStub框架二次开发实践

_张晓龙_ · · 1382 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

序言

要写出好的测试代码,必须精通相关的测试框架。对于Golang的程序员来说,至少需要掌握下面三个测试框架:

  • GoConvey
  • GoStub
  • GoMock

通过上一篇文章《GoStub框架使用指南》的学习,大家熟悉了GoStub框架的基本使用方法,可以优雅的对全局变量、函数或过程打桩,提高了单元测试水平。

尽管GoStub框架已经解决了很多场景的函数打桩问题,但对于一些复杂的情况,却只能干瞪眼:

  1. 被测函数中多次调用了数据库读操作函数接口 ReadDb,并且数据库为key-value型。被测函数先是 ReadDb 了一个父目录的值,然后在 for 循环中读了若干个子目录的值。在多个测试用例中都有将ReadDb打桩为在多次调用中呈现不同行为的需求,即父目录的值不同于子目录的值,并且子目录的值也互不相等

  2. 被测函数中多次调用了同一底层操作函数,比如 exec.Command,函数参数既有命令也有命令参数。被测函数先是创建了一个对象,然后查询对象的状态,在对象状态达不到期望时还要删除对象,其中查询对象是一个重要的操作,一般会进行多次重试。在多个测试用例中都有将 exec.Command 打桩为多次调用中呈现不同行为的需求,即创建对象、查询对象状态和删除对象对返回值的期望都不一样

  3. ...

针对GoStub框架不适用的复杂情况,本文将对该框架进行二次开发,优雅的变不适用为适用,提高GoStub框架的适应能力。

接口

根据开闭原则,我们通过新增接口来应对复杂情况,那么应该增加两个接口:

  1. 函数接口
  2. 方法接口

对于复杂情况,都是针对一个函数的多次调用而产生不同的行为,即存在多个返回值列表。显然用户打桩时应该指定一个数组切片[]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

但还存在下面两种情况:

  1. 当被打桩函数在重试调用的场景下,outputs中存在多个相邻的值是一样的
  2. 当被打桩函数在多次返回正常且返回值列表值相同的场景下,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. 入参校验。(1)funcVarToStub必须为指向函数的指针变量;(2)函数返回值列表的大小必须和Output.StubVals切片的长度相等
  2. 将outputs中的Times变量都消除,转化成一个纯的多组返回值列表,即切片[]Values,设切片变量为slice
  3. 构造一个闭包函数,自由变量为i,i的值为[0, len(slice) - 1],闭包函数的返回值列表为slice[i]
  4. 将待打桩函数替换为闭包函数

入参校验

入参校验的代码参考了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 := 0
len := 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框架。


有疑问加站长微信联系(非本文作者)

本文来自:简书

感谢作者:_张晓龙_

查看原文:GoStub框架二次开发实践

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

1382 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传