如何高效地编写Go单元测试

前言

单元测试是代码质量的保证,良好的单元测试不仅能够提前暴露代码问题,还极大便利了代码重构,它能保证代码重构前后功能保持一致,让重构活动能够顺利的进行下去。

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.Tt *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 = // 步骤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 = 
    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.Patchos.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.PatchInstanceMethodmonkey.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桩对象,MockExtensionMockContext分别对应于接口extension.Extensionactor.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语言的单元测试框架还远不止这几种,下面列出一些较为活跃的测试框架。

testify:断言库,也支持接口类型的mock

assertions:断言库

gostub:可以为全局变量、函数、过程进行打桩

monkey:易用的函数/方法打桩测试框架,不支持私有方法的打桩

goconvey:含Web界面的单元测试框架

SuperMonkey:monkey的升级版,支持私有方法的打桩

Goblin:BDD 测试框架

go-fuzz:官方出品的Go Fuzz测试框架

httpexpect:端到端 HTTP & REST 测试框架

gomock:官方出品的Go Mock测试框架

posted @ 2023-08-15 11:38  易先讯  阅读(134)  评论(0编辑  收藏  举报