前言
单元测试是代码质量的保证,良好的单元测试不仅能够提前暴露代码问题,还极大便利了代码重构,它能保证代码重构前后功能保持一致,让重构活动能够顺利的进行下去。
Go对单元测试的支持已经相当友好了,原生的标准库就已经支持了单元测试。在Go中编写单元测试也非常简单,Go认为以_test.go
结尾的文件都是单元测试的文件,因此对每个需要进行单元测试的.go
文件,通常都创建一个对应的_test.go
。
比如,现在我们需要对util.go
文件的函数进行单元测试,该文件的内容如下:
package util
// 生成num个*号,用于匿名化
func genStarWithNum(num int) string {
result := strings.Builder{}
for num > 0 {
result.WriteString("*")
num--
}
return result.String()
}
下面我们新建一个util_test.go
文件,单元测试代码如下:
package util
func TestGenStarWithNum(t *testing.T) {
result := genStarWithNum(1)
if result != "*" {
t.Errorf("generate start with 1 failed.")
}
result = genStarWithNum(5)
if result != "*****" {
t.Errorf("generate start with 5 failed.")
}
}
从单元测试代码中可以看出,Go的单元测试用例方法的入参都是t *testing.T
。t *testing.T
中有很多方法,常用的有t.Errorf()
,当在测试用例中调用该方法时,可以使当前用例执行失败。
执行单元测试用例也很简单,在util包所在目录下,执行go test .
命令即可:
F:\src\code.huawei.com\5gcore\cp\domain\agf\platform\util>go test .
ok code.huawei.com/5gcore/cp/domain/agf/platform/util 0.384s
如果执行失败,则会有对应的错误信息,比如:
--- FAIL: TestGenStarWithNum (0.00s)
util_test.go:17: generate start with 5 failed.
FAIL
FAIL code.huawei.com/5gcore/cp/domain/agf/platform/util 0.371s
FAIL
Jetbrains GoLand IDE支持直接运行单元测试,不用在命令行上执行go test命令,非常的好用。
对于简单的模块,使用Go原生的go test框架足以。但是当模块较为复杂,比如依赖了很多第三方的库或者跟平台强相关的功能时,只是用go test往往不能使测试用例正常地执行结束,更别说达到代码验证的目的了。这种情况下,就需要为对应模块的打桩,确保测试用例能够正常的执行下去,而这些能力,go test并未提供。
本文将介绍几个常用的Go单元测试框架,这些框架提供了各种打桩功能,让我们能够更高效的编写Go的单元测试用例。
使用testify进行断言
原生的go test框架并没有提供断言的能力,从上一节的util_test.go
例子中也能看出,我们需要使用大量的if
语句来对函数的输出结果进行判断,并进行对应的错误处理。这样会导致单元测试代码中充斥着大量的if
分支,影响了代码的可读性。针对该问题,我们可以通过引入第三方的Go断言库来解决。
在第三方断言库的选择上,就活跃度和易用性而言,testify都是最佳的选择。
还是针对上一节util.genStarWithNum()
的例子,下面我们用testify来对其进行单元测试的编写:
package util
func TestGenStarWithNum(t *testing.T) {
ast := assert.New(t)
ast.Equal("*", genStarWithNum(1))
ast.Equal("*****", genStarWithNum(5))
}
从上述单元测试用例来看,testify让测试代码更加简洁,可读性更加好了。除了Equal()
方法,比较常用的断言方法还有Nil()
、NotNil()
、True()
、False()
方法。
ast := assert.New(t)
err := db.Insert(obj)
ast.Nil(err) // 断言err为空
ast.NotNil(err) // 断言err不为空
isClose := cache.isClose()
ast.True(isClose) // 断言isClose为true
ast.False(isClose) // 断言isClose为false
除此之外,testify还有非常多各式各样的断言方法,绝对能够满足你在单元测试中的断言需求。
使用gostub为全局变量打桩
全局变量也经常在代码中用到,因为它具有全局性,所以在每个单元测试用例结束之后,都要讲全局变量恢复成原有的值,避免影响到其他用例,比如:
package test
func TestGlobalVar(t *testing.T) {
val1 := global.Val1 // 步骤1:记住原来的值
global.Val1 = 5 // 步骤2:赋值新的值
... // 测试用例代码
global.Val1 = val1 // 步骤3:恢复原来的值
}
如果测试用例涉及到的全局变量很多,这样对每个全局变量都进行着三个步骤,则显得代码很繁琐:
package test
func TestGlobalVar(t *testing.T) {
val1 := global.Val1
val2 := global.Val2
val3 := global.Val3
global.Val1 = 5
global.Val2 = 6
global.Val3 = 7
... // 测试用例代码
global.Val1 = val1
global.Val2 = val2
global.Val3 = val3
}
有没有更好的方法呢?
第三方库gostub为我们提供了一个更加简洁的实现方法。还是针对上述的例子,我们可以使用gostub库来进行优化:
package test
func TestGlobalVar(t *testing.T) {
// 为三个全局变量进行打桩
stub := gostub.Stub(&global.Val1, 5).Stub(&global.Val2, 6).Stub(&global.Val3, 7)
... // 测试用例代码,这里使用这三个全局变量时的值分别为5,6,7
stub.Reset() // 将全局变量的打桩恢复原样
}
从上述例子看,使用gostub为全局变量打桩使得代码更加简洁了,只需调用一次stub.Reset()
就能复原所有的全局变量值。
除了为全局变量打桩,gostub常用的场景还有为函数打桩,但是gostub函数打桩对代码有一定的限制,因此并不是十分的好用。下一节,我们将介绍一个更加好用的为函数进行打桩的神器。
使用monkey为函数打桩
我们在代码逻辑中经常会调用一些依赖实际运行环境的一些方法,比如发送HTTP请求,如果在单元测试中直接调用这些方法,通常会导致测试用例的异常退出。解决该问题的思路有两种,(1)在单元测试中创建一个HTTP Server Stub,用于接收并响应HTTP请求;(2)为发送HTTP请求函数打桩,使得测试用例中调用函数时能够按照自定义的值返回,而不是异常退出。因为第一种思路还需要新创建一个HTTP Server,比较麻烦,通常我们都会采用第二种思路。
monkey框架为Go的函数打桩提供了一个简单易用的方式,它既可以为普通函数打桩,也能为有接收者的方法打桩,两种方式稍微有点区别。
为普通函数打桩
先来看一个例子,在下面的一段逻辑中,我们调用了os
包中的Stat()
和IsExist()
函数来判断一个文件是否存在。
// 判断制定路径的文件/目录是否存在
func isPathExist(path string) bool {
_, err := os.Stat(path)
return err == nil || os.IsExist(err)
}
因为在单元测试中,我们一般都不会专门去创建一个文件来测试该方法,代价太大了。更好的方法是给Stat()
和IsExist()
打桩,让它们能够按照我们的预期返回结果。下面,我们使用monkey框架对该函数进行单元测试:
func TestIsPathExist_Exist(t *testing.T) {
ast := assert.New(t)
// 调用monkey.Patch为os.Stat进行打桩
// monkey.Patch函数的第一个参数为需要打桩的函数,第二个参数为桩函数,即实际调用的函数
monkey.Patch(os.Stat, func(name string) (os.FileInfo, error) {
return &mockFileInfo{}, nil
})
ast.True(isPathExist("mockfile"))
// 用例结束后记得调用monkey.UnpatchAll解除打桩,避免影响其他用例
monkey.UnpatchAll()
}
上述例子中,我们通过monkey.Patch
为os.Stat
进行了打桩,让isPathExist
返回(&mockFileInfo{}, nil)
,模拟文件真实存在的场景。
如果想要测试文件不存在的场景,我们可以这样:
func TestIsPathExist_NotExist(t *testing.T) {
ast := assert.New(t)
// 调用monkey.Patch为os.Stat进行打桩
// monkey.Patch函数的第一个参数为需要打桩的函数,第二个参数为桩函数,即实际调用的函数
monkey.Patch(os.Stat, func(name string) (os.FileInfo, error) {
return nil, errors.New("file not found")
})
ast.False(isPathExist("mockfile"))
// 用例结束后记得调用monkey.UnpatchAll解除打桩,避免影响其他用例
monkey.UnpatchAll()
}
为有接收者的方法打桩
monkey对有接收者的方法打桩用法跟普通函数打桩用法稍微有点差别,假如有如下的一段代码:
package cdrsave
type Saver struct {
writeMutex *sync.Mutex
...
}
// 数据写文件逻辑
func (s *Saver) Write(cdr []byte) bool {
s.writeMutex.Lock()
defer s.writeMutex.Unlock()
s.doWrite(cdr, cdrFile)
...
}
...
// 单例
var instance := &Saver{...}
func Instance() *Saver {
return instance
}
进行数据保存的业务逻辑如下:
func RecvChgReq(req *ChargingDataRequest) {
...
cdr := Req2Byte(req)
if (!cdrsave.Instance().Write(cdr)) {
... // 写入异常处理
return
}
... // 写入数据后的操作
}
现在我们需要对RecvChgReq()
函数进行单元测试,由于实际的业务逻辑涉及到文件的读写,而在测试用例中,往往不会专门创建文件来支持数据的读写,因此我们还是需要对Write
方法进行打桩。与isPathExist()
函数不同,Write
方法有接收者*Saver
,对它的打桩形式如下:
func TestRecvChgReq(t *testing.T) {
var s *Saver
// 使用monkey.PatchInstanceMethod对结构体的方法进行打桩
// 第一个参数为方法接收者的类型,通常通过reflect.TypeOf获得
// 第二个参数为方法名称
// 第三个参数为桩函数,需要注意的是桩函数的第一个参数固定性为方法接收者,其他参数与原方法一致
monkey.PatchInstanceMethod(reflect.TypeOf(s), "Write", func(saver *Saver, cdr []byte) bool {
return true
})
RecvChgReq(NewChargingDataRequest())
...
// 用例结束后记得调用monkey.UnpatchAll解除打桩,避免影响其他用例
monkey.UnpatchAll()
}
上述例子中,对于有接收者的方法,我们使用了monkey.PatchInstanceMethod
来对其进行打桩,用法也很简单。需要注意的是,桩函数的第一个参数一定是接收者类型。
另外,monkey还支持monkey.PatchInstanceMethod
和monkey.Patch
的混合使用,具体用法可查看官方的用法手册。
monkey无法对私有函数/方法进行打桩,比如我们无法对上述例子中的
Saver.doWrite()
方法进行打桩。monkey认为私有函数/方法通常是不稳定的,如果对这些函数/方法进行打桩会导致单元测试用例经常变动,得不偿失。
使用gomock为接口打桩
在单元测试中,我们也经常要对interface
进行打桩,比如在上一节为os.Stat
函数打桩时,编写的桩函数如下:
func(name string) (os.FileInfo, error) {
return &mockFileInfo{}, nil
}
因为os.Stat
函数的返回值是os.FileInfo
,它是一个接口类型,我们需要返回一个它的具体实现实例。os包已经对os.FileInfo
做了实现os.fileStat
,但是它是os包私有的,因此在测试用例里并不能直接使用。所以,我们需要给os.FileInfo
进行打桩,最直接的方法就是自己实现os.FileInfo
接口,如mockFileInfo
:
// 实现os.FileInfo接口
type mockFileInfo struct{}
func (m *mockFileInfo) Name() string {
return "test.gz"
}
func (m *mockFileInfo) Size() int64 {
return 1000
}
func (m *mockFileInfo) Mode() os.FileMode {
return os.ModeAppend
}
func (m *mockFileInfo) ModTime() time.Time {
return time.Now()
}
func (m *mockFileInfo) IsDir() bool {
return false
}
func (m *mockFileInfo) Sys() interface{} {
return nil
}
对于像os.FileInfo
这种简单的接口类型,我们可以快速地通过自己实现来进行打桩。但是对于一些复杂的接口类型,比如该接口有大量的方法定义,甚至有多层的接口嵌套,这种情况下自己实现接口的做法的代价就很高了,比如:
type Extension interface {
ExtMsgProc(c actor.ReceiverContext, message interface{}) bool
ExtCenterInit(CenterTemp)
ExtTokenBindProc(*ApiTokenBind)
ExtProcLbForSameSeq(*ApiTokenBind)
ExtIfProc(ApiIf)
ExtSpecInfoSend(result *ExecSpecResult) uint32
ExtSpecInfoQueryUsersSend(mmlCsType cfg.SMCTXCNTDBG_CSTYPE) bool
ExtCsPrivateInfoInit(ctrlPrivateInfo CtrlPrivateInfo)
ExtSetMMLRecoveryOK()
ExtSpecInfoNotifyAfterExecNormal(instanceId base.InstanceID, slotId uint32)
ExtObserveAlmAgent(c actor.ReceiverContext)
StarTimerForGaCheck()
debug.OprDbgItf
}
更好用的方法是使用Go官方出品的gomock框架,它实现了较为完整的为接口类型打桩的功能,包含了gomock包和mockgen工具两部分,其中前者完成对mock对象的管理;后者用来为接口类型生成对应的桩对象源文件,从而极大简化了人工实现接口的工作量。
使用gomock框架一般有以下几个步骤:
步骤1:安装gomock第三方库
执行如下命令安装gomock库:
go get -u github.com/golang/mock/gomock
如果在代码库中使用了vendor
对第三方库进行管理,可以在vendor
目录下看下是否已经有该库的依赖,如果已经存在了,则无需重复下载。
步骤2:安装mockgen工具
1、首先在需要进行接口打桩的服务代码的根目录上打开git bash
2、执行如下命令设置GOPATH为当前代码路径
export GOPATH="F:\CHF\code\cdfctrl\master"
3、执行如下命令安装mockgen工具:
go install code.huawei.com/5gcore/cp/domain/cdfctrl/vendor/github.com/golang/mock/mockgen
其中因为代码库使用了vendor
管理第三方库的管理,vendor
目录下已经存在github.com/golang/mock依赖,因此go install
后面跟的路径为vendor
目录下的mockgen依赖路径。
安装完成后,会在GOPATH路径下的bin目录下生成一个mockgen.exe文件
步骤3:为interface生成mock桩对象
mockgen命令支持两种生成模式:
1、source:从interface所在源文件中生成mock桩对象,通过-source启用
mockgen -source=interface.go [other options]
2、reflect:通过反射的机制生成mock桩对象,通过两个非标志参数来启用:导入路径和逗号分隔的interface列表。
mockgen [other options] [导入路径] [interface名]
之前尝试使用source方式生成mock桩对象一直失败,报
Loading input failed: loading package failed
错误,此问题当前还没解决。
现在,我们想要使用mockgen工具的reflect模式为前面的Extension
接口生成mock桩对象,该接口所在的导入路径为code.huawei.com/5gcore/cp/domain/c dfctrl/ctrl/extension
,因此执行如下命令:
./bin/mockgen.exe -destination=src/code.huawei.com/5gcore/cp/domain/cdfctrl/alpha/mock/mock_extension.go -package=mock code.huawei.com/5gcore/cp/domain/cdfctrl/ctrl/extension Extension
其中-destination
参数指定生成的mock桩对象所在的.go
源文件的路径,-package
参数指定生成的mock桩对象的包名。
生成的mock桩对象源码类似于一下这种,开头几行注释表明了该文件是mockgen工具生成的,生成的mock桩对象都是Mock+interface名
的命名形式,拥有*gomock.Controller
和*MockExtensionMockRecorder
两个属性。
/ Code generated by MockGen. DO NOT EDIT.
// Source: code.huawei.com/5gcore/cp/domain/cdfctrl/ctrl/extension (interfaces: Extension)
// Package extension is a generated GoMock package.
package mock
...
// MockExtension is a mock of Extension interface
type MockExtension struct {
ctrl *gomock.Controller
recorder *MockExtensionMockRecorder
}
...
使用reflect模式生成mock桩对象时,可能会遇到一些错误导致生成的mock桩对象少实现了1个方法,这种情况下如果错误无法解决,人工补全即可。
步骤4:使用mock桩对象进行单元测试
考虑有以下一段代码,CdfCtrl
里使用到了前文所述的Extension
接口。
type CdfCtrl struct {
ext extension.Extension
...
}
func (c *CdrfCtrl) Receive(ctx actor.Context) {
comm.CtrlLog(log.ERROR, c, "receive message type: %v, value: %v", reflect.TypeOf(ctx.Message()), ctx.Message())
if !c.ext.ExtMsgProc(ctx, ctx.Message()) {
... // 业务代码逻辑
return
}
...
}
现在我们需要对Receive
方法的这段代码分支进行单元测试:
func TestCdrfCtrl_Receive(t *testing.T) {
// 创建mock桩对象控制管理器
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
// 创建桩对象
mockExtension := mock.NewMockExtension(mockCtrl)
mockContext := mock.NewMockContext(mockCtrl)
// 为桩对象的函数返回值进行预期设置,并断言调用次数
mockContext.EXPECT().Message().Return(&omcm.OmCmMsg{}).AnyTimes()
mockExtension.EXPECT().ExtMsgProc(mockContext, mockContext.Message()).Return(false).Times(1)
// 创建被测对象,并进行测试
cdrfCtrl := &CdrfCtrl{}
cdrfCtrl.ext = mockExtension
cdrfCtrl.Receive(mockContext)
}
上述测试用例中,有两个mock桩对象,MockExtension
和MockContext
分别对应于接口extension.Extension
和actor.Context
。在创建mock桩对象前需要先把mock对象控制器gomock.Controller
创建出来,并作为mock桩对象工厂方法的入参。
接着调用mock桩对象的EXPECT()
方法为需要打桩的方法设定预期值,其中Return()
方法的参数就是预期返回值。通常在Return()
之后调用Times
系列方法断言调用次数。如果调用次数不正确,则用例执行也会失败。
需要注意的是,如果实际调用方法时的入参跟EXPECT
声明中的不匹配的话,会导致打桩失败。因此如果调用函数的入参不固定,可以使用gomock.Any()
进行匹配,比如上述例子中mockExtension
可以这样打桩:
mockExtension.EXPECT().ExtMsgProc(gomock.Any(), gomock.Any()).Return(false).Times(2)
gomock中一些常用的方法
调用方法
1、Call.Do()
:声明在匹配时要运行的操作。
2、Call.DoAndReturn()
:声明在匹配调用时要运行的操作,并且模拟返回该函数的返回值。
3、Call.Return()
:在匹配调用时模拟返回该函数的返回值。
4、Call.MaxTimes()
:设置最大的调用次数
5、Call.MinTimes()
:设置最小的调用次数
6、Call.AnyTimes()
:允许调用次数为 0 次或更多次
7、Call.Times()
:设置调用次数为 n 次
参数匹配
1、gomock.Any()
:匹配任意值
2、gomock.Eq()
:通过反射匹配到指定的类型
3、gomock.Nil()
:匹配nil
更多用可参见官方文档
总结
Go语言原生的test库可以应对一些简单的单元测试用例,对于一些比较复杂的用例编写显得很吃力。本文主要介绍了几种常用的Go单元测试框架,借助这些框架可以极大地提升Go语言单元测试的效率。当然,Go语言的单元测试框架还远不止这几种,下面列出一些较为活跃的测试框架。
标签:monkey,单元测试,打桩,Go,编写,os,mock,函数 From: https://www.cnblogs.com/gongxianjin/p/17630936.htmltestify:断言库,也支持接口类型的mock
assertions:断言库
gostub:可以为全局变量、函数、过程进行打桩
monkey:易用的函数/方法打桩测试框架,不支持私有方法的打桩
goconvey:含Web界面的单元测试框架
SuperMonkey:monkey的升级版,支持私有方法的打桩
Goblin:BDD 测试框架
go-fuzz:官方出品的Go Fuzz测试框架
httpexpect:端到端 HTTP & REST 测试框架
gomock:官方出品的Go Mock测试框架