Golang 单元测试框架的使用

一、使用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类源文件。

1、使用mockgen工具生成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 查看 bar 函数的内存地址为:0x10a2e20,与程序的输出一致,也就是我们可以使用 reflect 包获取到函数在内存中的地址。

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 的函数地址,等同于 target.Pointer(),getPointer 返回的是指向 target 函数的指针,其实现如下:

type funcValue struct {
	_ uintptr
	p unsafe.Pointer
}

func getPointer(v reflect.Value) unsafe.Pointer {
	return (*funcValue)(unsafe.Pointer(&v)).p
}

reflect.Value 的结构如下,getPointer 相当于直接拿到了未导出的属性 reflect.Value.ptr,这是指向 target 函数的指针,所以要拿到 target 的函数地址,还得进行一次解引用。通过 target.Pointer() 可以直接拿到 target 的函数地址是因为 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) 所指向的内存值放到 AX 寄存器,然后执行 CALL 指令。而 "".foo·f(SB) 使用到的内存大小是 8 个字节,并且值为 "".foo+0,即函数 foo 的地址,而 reflect.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 函数的地址,然后跳转
    }
}

从上面的代码可以看出,buildJmpDirective 构建了一个函数跳转的指令,把目标函数指针移动到寄存器 rdx 中,然后跳转到寄存器 rdx 中函数指针指向的地址。之后通过 modifyBinary 函数,先通过 entryAddress 方法获取到原函数所在的内存地址,之后通过 syscall.Mprotect 方法打开内存保护,将函数跳转指令以 bytes 数组的形式调用 copy 方法写入到原函数所在内存之中,最终达到替换的目的。此外,这里 replace 方法还保留了原函数的副本,方便后续函数 mock 的恢复。

3、修改函数开头的指令

replace(target, double uintptr) []byte 中,首先通过 bytes := entryAddress(target, len(code)) 拿到 target 函数开头 12 字节数据,放在 []byte 变量中。再执行 copy(original, bytes) 把这 12 字节数据保存下来,便于之后恢复用。最后执行 modifyBinary(target, code) 修改指令:


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

使用 GoConvey 书写单元测试,每个测试用例需要使用 Convey 函数包裹起来。它接受的第一个参数为 string 类型的描述;第二个参数一般为 *testing.T,即本例中的变量 t;第三个参数为不接收任何参数也不返回任何值的函数(习惯以闭包的形式书写)。

Convey 语句同样可以无限嵌套,以体现各个测试用例之间的关系,例如 TestDivision 函数就采用了嵌套的方式体现它们之间的关系。需要注意的是,只有最外层的 Convey 需要传入变量 t,内层的嵌套均不需要传入。最后,需要使用 So 语句来对条件进行判断。

源代码:

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()
        }
    }
}
Golang是一种开源编程语言,支持并发编程和垃圾回收。在编写Golang程序时,单元测试是非常重要的,因为它可以帮助我们确保代码的正确性。为了更好地进行单元测试,我们可以使用mock框架来模拟各种情况,以确保代码的正确性。以下是在Golang使用mock框架进行单元测试的步骤: 1. 安装mock框架 在终端中输入以下命令进行安装: ``` go get github.com/stretchr/testify/mock ``` 2. 创建需要测试的代码 假设我们需要测试以下代码: ``` package main import ( "errors" ) type Calculator interface { Add(a, b int) int Subtract(a, b int) int } type MyCalculator struct{} func (mc MyCalculator) Add(a, b int) int { return a + b } func (mc MyCalculator) Subtract(a, b int) int { return a - b } func Divide(a, b int) (int, error) { if b == 0 { return 0, errors.New("cannot divide by zero") } return a / b, nil } ``` 这个代码定义了一个Calculator接口和一个MyCalculator结构体,以及一个Divide函数,我们将使用mock框架来测试这个代码。 3. 创建mock类 我们需要创建一个mock类来模拟Calculator接口,以便在测试中使用。创建一个名为mockCalculator的文件,并在其中定义一个mockCalculator类: ``` package main import ( "github.com/stretchr/testify/mock" ) type MockCalculator struct { mock.Mock } func (mc *MockCalculator) Add(a, b int) int { args := mc.Called(a, b) return args.Int(0) } func (mc *MockCalculator) Subtract(a, b int) int { args := mc.Called(a, b) return args.Int(0) } ``` 这个类继承了mock框架的Mock类,并实现了Calculator接口的Add和Subtract方法。在这些方法中,我们调用了mc.Called方法来指示mock类返回什么值。 4. 编写测试用例 我们现在可以编写测试用例来测试我们的代码。创建一个名为main_test.go的文件,并在其中添加以下测试用例: ``` package main import ( "errors" "github.com/stretchr/testify/assert" "testing" ) func TestCalculator(t *testing.T) { mc := new(MockCalculator) mc.On("Add", 1, 2).Return(3) mc.On("Subtract", 3, 2).Return(1) calculator := MyCalculator{} assert.Equal(t, 3, calculator.Add(1, 2)) assert.Equal(t, 1, calculator.Subtract(3, 2)) mc.AssertExpectations(t) } func TestDivide(t *testing.T) { result, err := Divide(4, 2) assert.Nil(t, err) assert.Equal(t, 2, result) result, err = Divide(4, 0) assert.NotNil(t, err) assert.Equal(t, 0, result) } ``` 这些测试用例测试了我们的Calculator接口和Divide函数。在TestCalculator测试用例中,我们使用了mock类来模拟Calculator接口,并测试了Add和Subtract方法的正确性。在TestDivide测试用例中,我们测试了Divide函数的正确性。 5. 运行测试 在终端中输入以下命令来运行测试: ``` go test ``` 如果所有测试用例都通过,则表示我们的代码已通过单元测试。如果有任何测试用例失败,则需要查找代码中的错误并进行修复。 通过使用mock框架,我们可以更好地编写单元测试,以确保我们的代码在各种情况下都能正常工作。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值