简介

gostub用于在测试中添加变量,并在测试运行后重置原始值。

这可以用于对静态变量和静态函数进行打桩。要打桩静态变量,请使用stub函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
var configFile = "config.json"

func GetConfig() ([]byte, error) {
    return ioutil.ReadFile(configFile)
}

// Test code
stubs := gostub.Stub(&configFile, "/tmp/test.config")

data, err := GetConfig()
// data will now return contents of the /tmp/test.config file

通过使用变量引用静态函数,并使用该局部变量调用静态函数,gostub还可以在测试中对静态函数进行打桩:

1
2
3
4
5
var timeNow = time.Now

func GetDate() int {
    return timeNow().Day()
}

您可以使用gostub对timeNow变量进行打桩来进行测试:

1
2
3
4
5
6
stubs := gostub.Stub(&timeNow, func() time.Time {
    return time.Date(2015, 6, 1, 0, 0, 0, 0, time.UTC)
})
defer stubs.Reset()

// Test can check that GetDate returns 6

如果像上面的测试那样对函数进行打桩以返回常量值,则可以改用StubFunc:

1
2
stubs := gostub.StubFunc(&timeNow, time.Date(2015, 6, 1, 0, 0, 0, 0, time.UTC))
defer stubs.Reset()

StubFunc也可以用于对返回多个值的函数进行打桩:

1
2
3
4
5
6
var osHostname = osHostname
// [...] production code using osHostname to call it.

// Test code:
stubs := gostub.StubFunc(&osHostname, "fakehost", nil)
defer stubs.Reset()

StubEnv可用于设置测试的环境变量,并且在重置时将环境值重置为其原始值:

1
2
3
stubs := gostub.New()
stubs.SetEnv("GOSTUB_VAR", "test_value")
defer stubs.Reset()

应推迟在测试结束时运行Reset方法,以将所有打桩变量重置为原始值。

您可以通过再次调用打桩来设置多个打桩:

1
2
3
stubs := gostub.Stub(&v1, 1)
stubs.Stub(&v2, 2)
defer stubs.Reset()

对于仅设置简单打桩的​​简单情况,可以将设置和清理压缩为一行:

1
defer gostub.Stub(&v1, 1).Stub(&v2, 2).Reset()

这将设置打桩,然后推迟Reset调用。

如果需要在测试执行期间更改打桩或添加更多打桩,则应保留打桩调用的return参数:

1
2
3
4
5
6
7
8
stubs := gostub.Stub(&v1, 1)
defer stubs.Reset()

// Do some testing
stubs.Stub(&v1, 5)

// More testing
stubs.Stub(&b2, 6)

必须向打桩调用传递指向应该打桩的变量的指针,以及可以分配给该变量的值。

常用方法

gostub用于在测试时打桩变量,一旦测试运行时,重置原来的值。

Stubs代表一系列可以重置的打桩变量:

1
2
3
4
5
type Stubs struct {
   // stubs is a map from the variable pointer (being stubbed) to the original value.
   stubs   map[reflect.Value]reflect.Value
   origEnv map[string]envVal
}

Stub使用stubVal替代存储在varToStub变量的值,返回*Stubs类型变量:

  • varToStub必须是指向变量的指针。
  • stubVal是可赋值到变量的类型
1
2
3
func Stub(varToStub interface{}, stubVal interface{}) *Stubs {
   return New().Stub(varToStub, stubVal)
}

StubFunc用返回stubval值的函数替换函数变量,返回*Stubs类型变量:

  • funcVarToStub是指向函数变量的指针。如果函数返回多个值,返回的多个值被传递给StubFunc。
1
2
3
func StubFunc(funcVarToStub interface{}, stubVal ...interface{}) *Stubs {
   return New().StubFunc(funcVarToStub, stubVal...)
}
  • func New() Stubs New返回用于打桩变量的Stubs变量
  • func (s *Stubs) Reset() Reset重置打桩的所有变量到其原始值
  • func (s *Stubs) ResetSingle(varToStub interface{}) ResetSingle重置打桩的单个变量到其原始值
  • func (s *Stubs) SetEnv(k, v string) *Stubs SetEnv设置指定的环境变量到指定值
  • func (s *Stubs) UnsetEnv(k string) *Stubs UnsetEnv还原指定环境变量的值
  • func (s *Stubs) Stub(varToStub interface{}, stubVal interface{}) *Stubs Stub使用stubVal替代存储在varToStub变量的值
    • varToStub必须是指向变量的指针。
    • stubVal是可赋值到变量的类型
  • func (s *Stubs) StubFunc(funcVarToStub interface{}, stubVal …interface{}) Stubs StubFunc用返回stubval值的函数替换函数变量,返回Stubs类型变量
    • funcVarToStub是指向函数变量的指针。如果函数返回多个值,返回的多个值被传递给StubFunc。

使用场景

GoStub框架的使用场景很多,依次为:

  • 基本场景:为全局变量打桩
  • 基本场景:为函数打桩
  • 基本场景:为过程打桩
  • 复合场景:由任意相同或不同的基本场景组合而成

为全局变量打桩

假设num为被测函数中使用的一个全局整型变量,当前测试用例中假定num的值大于100,比如为200,则打桩的代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
   "fmt"

   "github.com/prashantv/gostub"
)

var counter = 100

func stubGlobalVariable() {
   stubs := gostub.Stub(&counter, 200)
   defer stubs.Reset()
   fmt.Println("Counter:", counter)
}

func main() {
   stubGlobalVariable()
}

// output:
// Counter: 200

stubs是GoStub框架的函数接口Stub返回的对象,该对象有Reset操作,即将全局变量的值恢复为原值。

为函数打桩

通常函数分为工程自定义函数与库函数。

假设工程中自定义函数如下:

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

Exec函数是不能通过GoStub框架打桩的。若要想对Exec函数通过GoStub框架打桩,则仅需对该函数声明做很小的重构,即将Exec函数定义为匿名函数,同时将它赋值给Exec变量,重构后的代码如下:

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

说明:对于新增函数,请按上面的方式定义

当Exec函数重构成Exec变量后,丝毫不影响既有代码中对Exec函数的调用。由于Exec变量是函数变量,所以我们一般将这类变量也叫做函数。

现在我们可以对Exec函数打桩了,代码如下所示:

1
2
3
4
stubs := Stub(&Exec, func(cmd string, args ...string) (string, error) {
            return "xxx-vethName100-yyy", nil
})
defer stubs.Reset()

其实GoStub框架专门提供了StubFunc函数用于函数打桩,我们重构打桩代码:

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

工程代码中会调用Golang库函数或第三方库函数,由于不能重构库函数,因此需要在工程代码中增加一层适配层,在适配层中定义库函数的变量,然后在工程代码中使用函数变量。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package Adapter

import (
   "time"

   "fmt"

   "os"

   "github.com/prashantv/gostub"
)

var timeNow = time.Now
var osHostname = os.Hostname

func getDate() int {
   return timeNow().Day()
}
func getHostName() (string, error) {
   return osHostname()
}

func StubTimeNowFunction() {
   stubs := gostub.Stub(&timeNow, func() time.Time {
      return time.Date(2015, 6, 1, 0, 0, 0, 0, time.UTC)
   })
   fmt.Println(getDate())
   defer stubs.Reset()
}

func StubHostNameFunction() {
   stubs := gostub.StubFunc(&osHostname, "LocalHost", nil)
   defer stubs.Reset()
   fmt.Println(getHostName())
}

使用示例:

1
2
3
4
5
6
7
8
package main

import "GoExample/GoStub/StubFunction"

func main() {
   Adapter.StubTimeNowFunction()
   Adapter.StubHostNameFunction()
}

为过程打桩

当一个函数没有返回值时,该函数我们一般称为过程。很多时候,我们将资源清理类函数定义为过程。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package main

import (
   "fmt"

   "github.com/prashantv/gostub"
)

var CleanUp = cleanUp

func cleanUp(val string) {
   fmt.Println(val)
}

func main() {
   stubs := gostub.StubFunc(&CleanUp)
   CleanUp("Hello go")
   defer stubs.Reset()
}

复合场景

不论是调用Stub函数还是StubFunc函数,都会生成一个stubs对象,该对象仍然有Stub方法和StubFunc方法,所以在一个测试用例中可以同时对多个全局变量、函数或过程打桩。这些全局变量、函数或过程会将初始值存在一个map中,并在延迟语句中通过Reset方法统一做回滚处理。

多次打桩代码如下:

1
2
3
4
5
6
7
8
stubs := gostub.Stub(&v1, 1)
defer stubs.Reset()

// Do some testing
stubs.Stub(&v1, 5)

// More testing
stubs.Stub(&b2, 6)

多次打桩的级联表达式代码如下:

1
defer gostub.Stub(&v1, 1).Stub(&v2, 2).Reset()

使用GoConvey测试框架和GoStub测试框架编写的测试用例如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package main

import (
   "fmt"
   "testing"

   "GoExample/GoStub/StubFunction"

   "time"

   "github.com/prashantv/gostub"
   . "github.com/smartystreets/goconvey/convey"
)

var counter = 100
var CleanUp = cleanUp

func cleanUp(val string) {
   fmt.Println(val)
}

func TestFuncDemo(t *testing.T) {
   Convey("TestFuncDemo", t, func() {
      Convey("for succ", func() {
         stubs := gostub.Stub(&counter, 200)
         defer stubs.Reset()
         stubs.Stub(&Adapter.TimeNow, func() time.Time {
            return time.Date(2015, 6, 1, 0, 0, 0, 0, time.UTC)
         })
         stubs.StubFunc(&CleanUp)
         fmt.Println(counter)
         fmt.Println(Adapter.TimeNow().Day())
         CleanUp("Hello go")
      })
   })
}

不适用情况

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

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

  • 被测函数中有一个循环,用于一个批量操作,当某一次操作失败,则返回失败,并进行错误处理。假设该操作为Apply,则在异常的测试用例中有将Apply打桩为在多次调用中呈现不同行为的需求,即Apply的前几次调用返回成功但最后一次调用却返回失败

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

二次开发

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

接口

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

  • 函数接口
  • 方法接口

对于复杂情况,都是针对一个函数的多次调用而产生不同的行为,即存在多个返回值列表。显然用户打桩时应该指定一个数组切片[]Output,那么数组切片的元素Output应该是什么呢?

每一个函数的返回值列表的大小不是确定的,且返回值类型也不统一,所以Output本身也是一个数组切片,Output的元素是interface{}。

于是Output有了下面的定义:

1
type Output []interface{}

对于函数接口的声明如下所示:

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

对于方法接口的声明如下所示:

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

但还存在下面两种情况:

  • 当被打桩函数在批量操作的场景下,即前面几次都返回成功而最后一次却返回失败,outputs中存在多个相邻的值是一样的
  • 当被打桩函数在重试调用的场景下,即被打桩函数在前面几次都返回失败而最后一次却返回成功,outputs中存在多个相邻的值是一样的

重复是万恶之源,我们保持零容忍,所以引入Times变量到Output中,于是Output的定义就演进为:

于是Output有了下面的定义:

1
2
3
4
5
type Values []interface{}
type Output struct {
    StubVals Values
    Times int
}

接口使用

场景一:多次读数据库

假设我们在一个函数f中读了3次数据库,比如调用了3次函数ReadLeaf,即通过3个不同的url读取了3个不同的value。ReadLeaf在db包中定义,示例如下:

1
2
3
var ReadLeaf = func(url string)(string, error) {
    ...
}

假设对该函数打桩之前还未生成stubs对象,覆盖3次读数据库的场景的打桩代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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中进行批量操作,比如在一个循环中调用了5次Apply函数,前4次操作都成功但第5次操作却失败了。Apply在resource包中定义,示例如下:

1
2
3
var Apply = func(id string) error {
    ...

假设对该函数打桩之前已经生成了stubs对象,覆盖前4次Apply都成功但第5次Apply却失败的场景的打桩代码如下:

1
2
3
4
5
6
outputs := []Output{
    Output{StubVals: Values{nil}, Times: 4},
    Output{StubVals: Values{ErrAny}},
}
stubs.StubFuncSeq(&resource.Apply, outputs)
...

场景三:底层操作有重试

假设我们在一个函数f中调用了3次底层操作函数,比如调用了3次Command函数,即第一次调用创建对象,第二次调用查询对象的状态,在状态达不到期望的情况下第三次掉用删除对象,其中第二次调用时为了提高正确性,进行了10次尝试。Command在exec包中定义,属于库函数,我们不能直接打桩,所以要在适配层adapter包中进行二次封装:

1
2
3
var Command = func(cmd string, arg ...string)(string, error) {
    ...

假设对该函数打桩之前已经生成了stubs对象,覆盖前9次尝试失败且第10次尝试成功的场景的打桩代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 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(&adapter.Command, outputs)
...

接口实现

函数接口实现

函数接口的实现很简单,直接委托方法接口实现:

1
2
3
func StubFuncSeq(funcVarToStub interface{}, outputs []Output) *Stubs {
    return New().StubFuncSeq(funcVarToStub, outputs)
}

提供函数接口的目的是,在Stubs对象生成之前就可以使用该接口。

方法接口实现

我们回顾一下方法接口的声明:

1
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方法的实现,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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的代码很简单,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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)
    }
}

说明:当Times的值小于等于1时,就按1次记录,否则按实际次数记录。这是一个特殊处理,目的是用户在构造Output时,一般不需要显式的给Times赋值,除非有多次,这样就提高了GoStub框架的易用性。

生成闭包

生成闭包的代码实现中调用了新封装的函数getResultValues,如下所示:

1
2
3
4
5
6
7
8
9
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方法的实现,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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
}

说明:StubFuncSeq要求len(slice)必须大于等于桩函数的调用次数,否则会显式panic,并有异常日志"output seq is less than call seq!"。

将待打桩函数替换为闭包

这里直接复用既有的变量打桩方法Stub即可实现,如下所示:

1
return s.Stub(funcVarToStub, stubVal.Interface())

至此,StubFuncSeq方法实现完了,oh yeah!

反模式

多个测试用例的桩函数绑定在一起

读者会写出诸如下面的测试代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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接口后,有些读者就会将上面的测试代码写成下面的反模式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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个左右很常见)。如果将所有测试用例的桩函数都写在一起,将非常复杂,而且很多时候会超过人脑的掌握极限,所以笔者将这种模式称为反模式。

我们提倡每个用例管理自己的桩函数,即分离关注点。

函数返回值列表都相同仍使用StubFuncSeq接口打桩

显然,StubFuncSeq接口的功能强于StubFunc接口,这就导致有些读者习惯了使用StubFuncSeq接口,而忽略或很少使用StubFunc接口。

假设函数f中有一个循环,可以从数组切片中获取到不同用户的Id,然后根据Id清理该用户的资源。比如总共有3个用户,依次调用resource包中的Clear函数进行资源清理,该函数的示例如下:

1
2
3
var Clear = func(id string) error {
    ...

假设对该函数打桩之前已经生成了stubs对象,覆盖3次都清理成功的场景的打桩代码如下:

1
2
3
4
5
outputs := []Output{
    Output{StubVals: Values{nil}, Times: 3},
}
stubs.StubFuncSeq(&resource.Clear, outputs)
...

这段代码尽管没毛病,但如果函数通过StubFunc接口打桩,则不管桩函数被调用多少次,都会返回唯一的值列表。

我们重构一下代码:

1
2
stubs.StubFunc(&resource.Clear, nil)
...

很明显,重构后的代码简单了很多。

可见,当函数返回值列表都相同时仍使用StubFuncSeq接口打桩是一种反模式。我们在给函数打桩时,优先使用StubFunc接口,当且仅当StubFunc接口不满足测试需求时才考虑使用StubFuncSeq接口。

参考:
https://www.jianshu.com/p/70a93a9ed186 https://blog.51cto.com/9291927/2345604 https://www.jianshu.com/p/53a531852619