一、使用gomonkey stub(打桩)

在测试包中创建一个模拟方法,用于替换生成代码中的方法。

1、stub函数

gomonkey.ApplyFunc(target,double)

其中target是被mock的目标函数,double是用户重写的函数。注意点:重写的函数要和原函数入参和出参保持一致,否则会报错。

在这里插入图片描述

2、stub方法

gomonkey.ApplyMethod(reflect.TypeOf(s), “target”,double {//mock方法实现})

s为目标变量,target为目标变量方法名,double为mock方法;同理double方法入参和出参需要和target方法保持一致。如下图示例:

在这里插入图片描述

// GetUserGiftNum 获取用户拥有的道具礼物数目,map[int]int key为礼物id, value为数目
func (g *GiftData) GetUserGiftNum(uid int64) (map[int]int, error) {
	key := library.UserGiftAccount(strconv.FormatInt(uid, 10))
	giftRecord, err := g.rankRedisRD.HGetAll(key)
	if err == redis.ErrNil {
		return map[int]int{}, nil
	}
	if err != nil {
		return map[int]int{}, err
	}
	ret := make(map[int]int)
	now := library.UnixNow()
	for record, numStr := range giftRecord {
		hasNum, err := strconv.Atoi(numStr)
		if err != nil || hasNum < 0 {
			continue
		}
		detail := strings.Split(record, ":")
		if len(detail) != 2 {
			continue
		}
		itemExpire, err := strconv.ParseInt(detail[1], 10, 64)
		if err != nil {
			continue
		}
		//过期道具跳过
		if itemExpire != 0 && now > itemExpire {
			continue
		}
		//统计可用道具数目
		giftId, err := strconv.Atoi(detail[0])
		if err != nil {
			continue
		}
		if _, ok := ret[giftId]; !ok {
			ret[giftId] = hasNum
		} else {
			ret[giftId] += hasNum
		}
	}
	return ret, nil
}

import (
      "testing"
      "github.com/smartystreets/goconvey/convey"
      "github.com/bouk/monkey"
)
func TestGetUserGiftNum_CorrectRet(t *testing.T) {
	giftRecord := map[string]string{
		"1:1000": "10",
		"1:2001": "100",
		"1:999":  "20",
		"2":      "200",
		"a":      "30",
		"2:1001": "20",
		"2:999":  "200",
	}

	expectRet := map[int]int{
		1: 110,
		2: 20,
	}

	patchesNow := gomonkey.ApplyFunc(library.UnixNow, func() int64 {
		return int64(1000)
	})
	defer patchesNow.Reset()

	var s *redis.RedisHelper
	patches := gomonkey.ApplyMethod(reflect.TypeOf(s), "HGetAll", func(_ *redis.RedisHelper, _ string)(map[string]string, error) {
		return giftRecord, nil
	})
	defer patches.Reset()

	p := &GiftData{rankRedisRD:new(redis.RedisConn)}
	userGiftNum, err := p.GetUserGiftNum(10000)

	assert.Nil(t, err)
	assert.JSONEq(t, Calorie.StructToString(expectRet), Calorie.StructToString(userGiftNum))

}

二、使用gomock 模拟外部依赖行为

  • 网络依赖——函数执行依赖于网络请求,比如第三方http-api,rpc服务,消息队列等等
  • 数据库依赖
  • I/O依赖
    当然,还有可能是依赖还未开发完成的功能模块。但是处理方法都是大同小异的——抽象成接口,通过mock和stub进行模拟测试。

其中GoMock包完成对桩对象生命周期的管理。mockgen工具用来生成interface对应的Mock类源文件。

mockgen -source={/path/file_name}.go > {/path/mock_file_name}.go
//源文件
package db

type Repository interface {
    Create(key string, value []byte) error
    Retrieve(key string) ([]byte, error)
    Update(key string, value []byte) error
    Delete(key string) error
}

2、在/path/路径下找到生成的mock_file_name.go文件

// Automatically generated by MockGen. DO NOT EDIT!
// Source: infra/db (interfaces: Repository)

package mock_db

import (
    gomock "github.com/golang/mock/gomock"
)

// MockRepository is a mock of Repository interface
type MockRepository struct {
    ctrl     *gomock.Controller
    recorder *MockRepositoryMockRecorder
}

// MockRepositoryMockRecorder is the mock recorder for MockRepository
type MockRepositoryMockRecorder struct {
    mock *MockRepository
}

// NewMockRepository creates a new mock instance
func NewMockRepository(ctrl *gomock.Controller) *MockRepository {
    mock := &MockRepository{ctrl: ctrl}
    mock.recorder = &MockRepositoryMockRecorder{mock}
    return mock
}

// EXPECT returns an object that allows the caller to indicate expected use
func (_m *MockRepository) EXPECT() *MockRepositoryMockRecorder {
    return _m.recorder
}

// Create mocks base method
func (_m *MockRepository) Create(_param0 string, _param1 []byte) error {
    ret := _m.ctrl.Call(_m, "Create", _param0, _param1)
    ret0, _ := ret[0].(error)
    return ret0
}

// Create indicates an expected call of Create
func (_mr *MockRepositoryMockRecorder) Create(arg0, arg1 interface{}) *gomock.Call {
    return _mr.mock.ctrl.RecordCall(_mr.mock, "Create", arg0, arg1)
}
...

3、使用mock对象进行打桩测试。mock类源文件生成后,就可以写测试用例了。

//导入mock相关的包, mock相关的包包括testing,gmock和mock_db
import (
	. "github.com/golang/mock/gomock"
	"test/mock_repository"
	"testing"
)
//mock控制器通过NewController接口生成,是mock生态系统的顶层控制,它定义了mock对象的作用域和生命周期,以及它们的期望。多个协程同时调用控制器的方法是安全的。当用例结束后,控制器会检查所有剩余期望的调用是否满足条件。
// 初始化控制器
ctrl := NewController(t)
defer ctrl.Finish()
// 创建mock对象, mock对象创建时需要注入控制器,如果有多个mock对象则注入同一个控制器
mockRepo := mock_repository.NewMockRepository(ctrl)
//mock对象的行为注入,对于mock对象的行为注入,控制器是通过map来维护的,一个方法对应map的一项。因为一个方法在一个用例中可能调用多次,所以map的值类型是数组切片。当mock对象进行行为注入时,控制器会将行为Add。当该方法被调用时,控制器会将该行为Remove。
mockRepo.EXPECT().Retrieve(Any()).Return(nil, ErrAny)
mockRepo.EXPECT().Create(Any(), Any()).Return(nil)
mockRepo.EXPECT().Retrieve(Any()).Return(objBytes, nil)

4、gomock的内联优化

内联就是把简短的函数在调用它的地方展开。在计算机发展历程的早期,这个优化是由程序员手动实现的。现在,内联已经成为编译过程中自动实现的基本优化过程的其中一步。内联是高性能编程的一种重要手段。每个函数调用都有开销:创建栈帧,读写寄存器,这些开销可以通过内联避免。当出现内联优化时,gomonkey的mock函数可能会失败。因为函数内联后被测代码压根不会调用mock函数。

go build 可以用*-gcflagsgo*编译器传入参数, go build用-ldflags给go链接器传入参数。

如何禁用内联?编译时加上参数:

-gcflags=all=-l

5、gomock的编译原理 参考连接

实际上 gomonkey 提供了让我们在运行时替换原函数/方法的能力。虽然说我们在语言层面很难去替换运行中的函数体,但是本身代码最终都会转换成机器可以理解的汇编指令,我们可以通过创建指令来改写函数。

gomonkey 提供了如下 mock 方法:

  • ApplyGlobalVar(target, double interface{}):使用 reflect 包,将 target 的值修改为 double
  • ApplyFuncVar(target, double interface{}):检查 target 是否为指针类型,与 double 函数声明是否相同,最后调用 ApplyGlobalVar
  • ApplyFunc(target, double interface{}):修改 target 的机器指令,跳转到 double 执行
  • ApplyMethod(target reflect.Type, methodName string, double interface{}):修改 method 的机器指令,跳转到 double 执行
  • ApplyFuncSeq(target interface{}, outputs []OutputCell):修改 target 的机器指令,跳转到 gomonkey 生成的一个函数执行,每次调用会顺序从 outputs 取出一个值返回
  • ApplyMethodSeq(target reflect.Type, methodName string, outputs []OutputCell):修改 target 的机器指令,跳转到 gomonkey 生成的一个方法执行,每次调用会顺序从 outputs 取出一个值返回
  • ApplyFuncVarSeq(target interface{}, outputs []OutputCell):gomonkey 生成一个函数顺序返回 outputs 中的值,调用 ApplyGlobalVar

gomock指令替换的实现: **通过将函数开头的机器指令替换为无条件JMP指令,跳转到 mock 函数执行。打桩的原理就是在运行时通过二进制指令改写可执行程序,将对目标函数或方法的成员跳转到桩的实现。**要实现这个功能,需要分三步走:

1、获取函数的内存地址

以如下代码为例子:

//go:noinline
func bar() string {
   return "bar"
}

func main() {
   fmt.Println(bar) // 0x10a2e20
   println(unsafe.Pointer(reflect.ValueOf(bar).Pointer())) // 0x10a2e20
}
go build -o main . && go tool objdump -s 'bar' main
TEXT main.bar(SB) /Users/roketyyang/Work/mock/gomonkey/f/f.go
  f.go:11               0x10a2e20               488d05f2450200          LEAQ go.string.*+217(SB), AX    
  f.go:11               0x10a2e27               4889442408              MOVQ AX, 0x8(SP)                
  f.go:11               0x10a2e2c               48c744241003000000      MOVQ $0x3, 0x10(SP)             
  f.go:11               0x10a2e35               c3                      RET 

在 gomonkey 中替换指令的实现为:

func (this *Patches) ApplyCore(target, double reflect.Value) *Patches {
   this.check(target, double) // 类型检查
   if _, ok := this.originals[target]; ok {
      panic("patch has been existed")
   }

   this.valueHolders[double] = double // 因为 mock 函数通常是一个闭包,也就是个局部作用域的对象,为了防止 mock 函数被 GC 回收掉,需要增加引用
   // 替换 target 的机器指令,返回的 origin 是 target 会被覆盖的机器指令
   original := replace(*(*uintptr)(getPointer(target)), uintptr(getPointer(double)))
   // 保存 target 被覆盖的机器指令,用于恢复 target
   this.originals[target] = original
   return this
}
*(*uintptr)(getPointer(target))target.Pointer()
type funcValue struct {
	_ uintptr
	p unsafe.Pointer
}

func getPointer(v reflect.Value) unsafe.Pointer {
	return (*funcValue)(unsafe.Pointer(&v)).p
}
reflect.Valuereflect.Value.ptrtarget.Pointer()reflect.Value.Pointer()reflect.Value.ptr
type Value struct {
   typ *rtype
   ptr unsafe.Pointer
   flag
}

以如下代码为例子,看下函数变量的值是怎么存储的:

package main

//go:noinline
func foo() string {
   return "foo"
}

func main() {
   funcVar := foo
   println(funcVar())
   funcVar2 := foo
   println(funcVar2())
}
go tool compile -S ff.go"".foo·f(SB)"".foo·f(SB)"".foo+0reflect.Value.ptr"".foo·f(SB)
"".main STEXT size=205 args=0x0 locals=0x28 funcid=0x0
		        ......
        0x0021 00033 (ff.go:10) MOVQ    "".foo·f(SB), AX ; funcVar()
        0x0028 00040 (ff.go:10) LEAQ    "".foo·f(SB), DX
        0x002f 00047 (ff.go:10) PCDATA  $1, $0
        0x002f 00047 (ff.go:10) CALL    AX
		        ......
        0x006f 00111 (ff.go:12) MOVQ    "".foo·f(SB), AX ; funcVar2()
        0x0076 00118 (ff.go:12) LEAQ    "".foo·f(SB), DX
        0x007d 00125 (ff.go:12) CALL    AX
		        ......
"".foo·f SRODATA dupok size=8
        0x0000 00 00 00 00 00 00 00 00                          ........
        rel 0+8 t=1 "".foo+0

2、生成跳转指令

gomonkey 替换指令的代码:

// target 目标函数地址
// double mock 函数的指针
func replace(target, double uintptr) []byte {
	code := buildJmpDirective(double) // 生成跳转到 mock 函数的机器指令
	bytes := entryAddress(target, len(code))
	original := make([]byte, len(bytes))
	copy(original, bytes)
	modifyBinary(target, code)
	return original
}

func buildJmpDirective(double uintptr) []byte {
    d0 := byte(double)
    d1 := byte(double >> 8)
    d2 := byte(double >> 16)
    d3 := byte(double >> 24)
    d4 := byte(double >> 32)
    d5 := byte(double >> 40)
    d6 := byte(double >> 48)
    d7 := byte(double >> 56)

    // 返回跳转的机器指令
    return []byte{
        0x48, 0xBA, d0, d1, d2, d3, d4, d5, d6, d7, // MOV rdx, double 将 mock 函数的指针值放到 rdx 中
        0xFF, 0x22,     // JMP [rdx] 因为rdx 中存储的是 mock 函数的指针,所以需要使用[],从内存中获得 mock 函数的地址,然后跳转
    }
}
buildJmpDirectivemodifyBinaryentryAddresssyscall.Mprotectcopyreplace

3、修改函数开头的指令

replace(target, double uintptr) []bytebytes := entryAddress(target, len(code))[]bytecopy(original, bytes)modifyBinary(target, code)

三、goconvey单元测试框架的使用

Convey*testing.T
ConveyTestDivisionConveySo

源代码:

package goconvey

import (
    "errors"
)

func Add(a, b int) int {
    return a + b
}

func Subtract(a, b int) int {
    return a - b
}

func Multiply(a, b int) int {
    return a * b
}

func Division(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("被除数不能为 0")
    }
    return a / b, nil

测试代码:子Convey 的执行策略是并行的,因此前面的子Convey 执行失败,不会影响后面的Convey 执行。但是一个Convey 下的子 So,执行是串行的。

package goconvey

import (
    "testing"
//使用官方推荐的方式导入 GoConvey 的辅助包以减少冗余的代码:. "github.com/smartystreets/goconvey/convey"
    . "github.com/smartystreets/goconvey/convey"
)
//每个单元测试的名称需要以 Test 开头,例如:TestAdd,并需要接受一个类型为 *testing.T 的参数。
func TestAdd(t *testing.T) {
    //每个测试用例需要使用 Convey 函数包裹起来。它接受的第一个参数为 string 类型的描述;
    // 第二个参数一般为 *testing.T,即本例中的变量 t;
    // 第三个参数为不接收任何参数也不返回任何值的函数(习惯以闭包的形式书写)。
    Convey("将两数相加", t, func() {
        //断言So 参数的理解,总共有三个参数:actual: 输入\assert:断言\expected:期望值
        So(Add(1, 2), ShouldEqual, 3)
    })
}

func TestSubtract(t *testing.T) {
    Convey("将两数相减", t, func() {
        So(Subtract(1, 2), ShouldEqual, -1)
    })
}

func TestMultiply(t *testing.T) {
    Convey("将两数相乘", t, func() {
        So(Multiply(3, 2), ShouldEqual, 6)
    })
}

func TestDivision(t *testing.T) {
    Convey("将两数相除", t, func() {
	//Convey 语句同样可以无限嵌套,以体现各个测试用例之间的关系
        Convey("除以非 0 数", func() {
            num, err := Division(10, 2)
            So(err, ShouldBeNil)
            So(num, ShouldEqual, 5)
        })

        Convey("除以 0", func() {
            _, err := Division(10, 0)
            So(err, ShouldNotBeNil)
        })
    })
}

四、编写易测试代码

熟悉业务是需要找到对外部依赖最少的代码,从依赖最少的代码入手可以极大减少初期编写单元测试的工作量。外部依赖代码主要是对数据库的操作、接口调用等等。将对外的依赖抽离出来,是最简单的一种处理方式。牢记两点:依赖反转、依赖注入。

对于大多数函数要想易于单元测试的话,建议从两个思路入手:

  • 明确函数依赖(不管显示的和隐式的,它都是客观存在的依赖)
  • 抽离出依赖(想办法让函数内部的依赖都可以从函数外部控制,和依赖注入很像)

几种具体抽离方法:

  • 对于依赖较少的函数,可以直接把依赖作为入参传递
  • 对于依赖较复杂的函数,把它写成某对象的方法,依赖都存储为该对象的成员变量(interface接口类型作为结构体的成员变量易扩展)。
  • 函数内部不直接调用静态方法,用变量保存静态方法的函数指针(外部包函数有时不要直接调,用变量做代理)

五、表格驱动测试

表格驱动测试是一种编写易于扩展测试用例的测试方法。表格驱动测试在 Go 语言中很常见(并非唯一),以至于很多标准库1都有使用。表格驱动测试使用匿名结构体。一个较好的办法是把测试的输入数据和期望的结果写在一起组成一个数据表:表中的每条记录都是一个含有输入和期望值的完整测试用例,有时还可以结合像测试名字这样的额外信息来让测试输出更多的信息。

func TestShortFilename(t *testing.T) {
    tests := []struct {
        in       string
        expected string
    }{
        {"???", "???"},
        {"filename.go", "filename.go"},
        {"hello/filename.go", "filename.go"},
        {"main/hello/filename.go", "filename.go"},
    }

    for _, tt := range tests {
        actual := getShortFilename(tt.in)
        if strings.Compare(actual, tt.expected) != 0 {
            t.Fail()
        }
    }
}