首页 > 其他分享 >如何高效地编写Go单元测试

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

时间:2023-08-15 11:45:23浏览次数:31  
标签:monkey 单元测试 打桩 Go 编写 os mock 函数

前言

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

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 = 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.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测试框架

标签:monkey,单元测试,打桩,Go,编写,os,mock,函数
From: https://www.cnblogs.com/gongxianjin/p/17630936.html

相关文章

  • 【腾讯云 Cloud Studio 实战训练营】在线 IDE 编写 canvas 转换黑白风格头像
    关于CloudStudioCloudStudio是基于浏览器的集成式开发环境(IDE),为开发者提供了一个永不间断的云端工作站。用户在使用CloudStudio时无需安装,随时随地打开浏览器就能在线编程。CloudStudio作为在线IDE,包含代码高亮、自动补全、Git集成、终端等IDE的基础功能,同时支持实......
  • MongoDB——Centos五步安装并设置服务自启(一)
     简介:官方地址:MongoDB:TheDeveloperDataPlatform|MongoDBMongoDB是一个开源的NoSQL数据库管理系统,它使用文档型数据模型来存储数据,以下是MongoDB的基本信息文档型数据存储:MongoDB使用BSON(BinaryJSON)格式存储数据,它是一种类似于JSON的二进制表示形式。每个文档是一个包含字......
  • 《高级程序员 面试攻略 》go抢占式调度 通俗拟人解释
    当我们谈论Go语言的抢占式调度时,可以使用一个通俗的拟人解释来描述它的工作原理。假设你正在组织一场音乐会,有许多音乐家需要在舞台上演奏乐器。你作为指挥,负责协调他们的演奏。每个音乐家都是一个Goroutine(Go协程),而你是调度器(Scheduler)。在开始音乐会之前,你将每个音乐家的......
  • 《高级程序员 面试攻略 》通俗拟人解释 swoole的协程 和 go的协程有什么区别
    Swoole的协程和Go的协程(Goroutine)都是用于实现轻量级并发的机制,但它们有一些区别。1.语言和环境:Swoole协程是在PHP语言中实现的,而Go协程是在Go语言中实现的。因此,它们在语言和运行时环境上存在差异。1.编程模型:Swoole协程使用的是“同步风格”的编程模型,类似于传统......
  • 学习go语言编程之网络编程
    Socket编程Golang语言标准库对Socket编程进行了抽象,无论使用什么协议建立什么形式的连接,都只需要调用net.Dial()即可。Dial()函数Dial()函数的原型如下:funcDial(network,addressstring)(Conn,error)参数含义如下:network:网络协议名字,如:tcp,udp等Dial()函数支持的网络......
  • 学习go语言编程之并发编程
    并发基础并发包含如下几种主流的实现模型:多进程多线程基于回到的非阻塞/异步IO协程协程与传统的系统级线程和进程相比,协程最大的优势在于“轻量级”,可以轻松创建上百万个而不会导致系统资源枯竭,而线程和进程通常最多不超过1万个。Golang在语言级别支持协程,叫goroutine。......
  • Paper Reading: A pareto-based ensemble of feature selection algorithms
    目录研究动机文章贡献相关概念集成特征选择帕累托最优非支配排序拥挤距离本文方法实验结果数据集和实验设置与FS方法比较与集成FS方法比较优点和创新点PaperReading是从个人角度进行的一些总结分享,受到个人关注点的侧重和实力所限,可能有理解不到位的地方。具体的细节还需要......
  • Go 语言并发
    启动单个goroutinepackagemainimport( "fmt" "time")funchello(){ fmt.Println("hello")}funcmain(){ gohello() fmt.Println("欢迎来到编程狮") time.Sleep(time.Second)}sync.WaitGrouppackagemainimport( "fmt" &qu......
  • Go 错误处理
     Go语言通过内置的错误接口提供了非常简单的错误处理机制。error类型是一个接口类型typeerrorinterface{Error()string}packagemainimport("fmt")//定义一个DivideError结构typeDivideErrorstruct{divideeintdividerint}//实现`error`......
  • Go 语言反射(Reflect)
    Go语言提供了一种机制,在不知道具体类型的情况下,可以用反射来更新变量值,查看变量类型Typeofpackagemainimport( "fmt" "reflect")funcmain(){ varbooknumfloat32=6 varisbookbool=true bookauthor:="www.w3cschool.cn" bookdetail:=make(map[string]string) bo......