mock接口测试
Go单测-mock接口测试
介绍如何在单元测试中使用gomock和gostub工具mock接口和打桩。在开发中也会经常用到各种各样的接口类型。本文就举例来演示如何在编写单元测试的时候对接口类型进行mock以及如何进行打桩。
有一本书叫《Writing An Interpreter In Go》, 作者在讲解如何编写解释器的时候,都是从写一个_test.go
开始的,也就是说作者习惯于先写单元测试,以测试驱动开发,其实这是一个非常好的习惯,不过,作者在写_test.go
文件的时候,都是先假设这个结构体、函数已经存在了,并且没有把关键的对象抽象成接口,因此,作者在运行go test
的时候,是无法完成测试的,因为连编译都过不了,必须一边完善代码,一边重复运行go test
,一直到完成开发。
基于这种开发模式下,其实我更期望能有一个Mock实现,写测试代码的时候畅通无阻,即使是没有实现,也能把各个测试用例覆盖到,当真实的实现完成后,我们只需要把mock实现替换成真实的实现就好了。
这么做还带来另一个好处,如果公司有SDET岗位,则可以直接让测试人员编写单元测试,开发任务和测试任务可以并行。源码地址
一、gomock
gomock是Go官方提供的测试框架,它在内置的testing包或其他环境中都能够很方便的使用。我们使用它对代码中的那些接口类型进行mock,方便编写单元测试。
二、安装mockgen
互联网开源库更新迭代比较快,建议直接查看官方文档:https://github.com/golang/mock
首先需要确保你的$GOPATH/bin
已经加入到环境变量中。
Go版本号<1.16时:
GO111MODULE=on go get github.com/golang/mock/mockgen@v1.6.0
Go版本>=1.16时:
go install github.com/golang/mock/mockgen@v1.6.0
如果是在你的CI流水线中安装,则需要安装与你的CI环境匹配的合适版本。
三、运行mockgen
mockgen
有两种操作模式:源码(source)模式和反射(reflect)模式。
源码模式
源码模式根据源文件mock接口。它是通过使用 -source
标志启用。在这个模式下可能有用的其他标志是 -imports
和 -aux_files
。
例如:
mockgen -source=foo.go [other options]
反射模式
反射模式通过构建使用反射来理解接口的程序来mock接口。它是通过传递两个非标志参数来启用的:一个导入路径和一个逗号分隔的符号列表。可以使用 ”.”引用当前路径的包。
例如:
mockgen database/sql/driver Conn,Driver
# Convenient for `go:generate`.
mockgen . Conn,Driver
flags
mockgen
命令用来为给定一个包含要mock的接口的Go源文件,生成mock类源代码。它支持以下标志:
-source
:包含要mock的接口的文件。-destination
:生成的源代码写入的文件。如果不设置此项,代码将打印到标准输出。-package
:用于生成的模拟类源代码的包名。如果不设置此项包名默认在原包名前添加mock_
前缀。-imports
:在生成的源代码中使用的显式导入列表。值为foo=bar/baz形式的逗号分隔的元素列表,其中bar/baz是要导入的包,foo是要在生成的源代码中用于包的标识符。-aux_files
:需要参考以解决的附加文件列表,例如在不同文件中定义的嵌入式接口。指定的值应为foo=bar/baz.go形式的以逗号分隔的元素列表,其中bar/baz.go是源文件,foo是-source
文件使用的文件的包名。-build_flags
:(仅反射模式)一字不差地传递标志给go build-mock_names
:生成的模拟的自定义名称列表。这被指定为一个逗号分隔的元素列表,形式为Repository = MockSensorRepository,Endpoint=MockSensorEndpoint
,其中Repository
是接口名称,mockSensorrepository
是所需的mock名称(mock工厂方法和mock记录器将以mock命名)。如果其中一个接口没有指定自定义名称,则将使用默认命名约定。-self_package
:生成的代码的完整包导入路径。使用此flag的目的是通过尝试包含自己的包来防止生成代码中的循环导入。如果mock的包被设置为它的一个输入(通常是主输入),并且输出是stdio,那么mockgen就无法检测到最终的输出包,这种情况就会发生。设置此标志将告诉 mockgen 排除哪个导入-copyright_file
:用于将版权标头添加到生成的源代码中的版权文件-debug_parser
:仅打印解析器结果-exec_only
:(反射模式) 如果设置,则执行此反射程序-prog_only
:(反射模式)只生成反射程序;将其写入标准输出并退出。-write_package_comment
:如果为true,则写入包文档注释 (godoc)。(默认为true)
四、构建mock
这里就以日常开发中经常用到的数据库操作为例,讲解一下如何使用gomock来mock接口的单元测试。
假设有查询MySQL数据库的业务代码如下,其中DB
是一个自定义的接口类型:
// db.go
// DB 数据接口
type DB interface {
Get(key string)(int, error)
Add(key string, value int) error
}
// GetFromDB 根据key从DB查询数据的函数
func GetFromDB(db DB, key string) int {
if v, err := db.Get(key);err == nil{
return v
}
return -1
}
我们现在要为GetFromDB
函数编写单元测试代码,可是我们又不能在单元测试过程中连接真实的数据库,这个时候就需要mock DB
这个接口来方便进行单元测试。
使用上面提到的 mockgen
工具来为生成相应的mock代码。通过执行下面的命令,我们就能在当前项目下生成一个mocks
文件夹,里面存放了一个db_mock.go
文件。
mockgen -source=db.go -destination=mocks/db_mock.go -package=mocks
db_mock.go
文件中的内容就是mock相关接口的代码了。
我们通常不需要编辑它,只需要在单元测试中按照规定的方式使用它们就可以了。例如,我们编写TestGetFromDB
函数如下:
// db_test.go
package gomock_demo
import (
"fmt"
"golang-unit-test-example/06gomock_demo/mocks"
"testing"
"github.com/golang/mock/gomock"
)
/*
@author RandySun
@create 2022-05-01-15:56
*/
func TestGetFromDB(t *testing.T) {
// 创建gomock控制器, 用来记录后续操作信息
ctrl := gomock.NewController(t)
// 断言期望的方法都被执行
// Go1.14+ 的单测中不在需要手动调用该方法
defer ctrl.Finish()
// 调用mockgen生成代码中的NewMockDB方法
// mockgen -source="db.go" -destination="mocks/db_mock.go" -package="mocks"
// 这里mocks是我们生成代码时指定的package包名称
m := mocks.NewMockDB(ctrl)
// 打桩(stub)
// 当传入Get函数的参数为RandySun时返回1和nil
m.
EXPECT().
Get(gomock.Eq("RandySun")). // 参数
Return(1, nil). // 返回值
Times(1) // 调用次数
// 调用GetFromDB函数时传入上面的mock对象m, v = Return(1, nil)返回的值
if v := GetFromDB(m, "RandySun"); v != 1 {
fmt.Println(v, "55555")
t.Fatal()
}
}
执行:
gomock_demo> go test -v
=== RUN TestGetFromDB
--- PASS: TestGetFromDB (0.00s)
PASS
ok golang-unit-test-example/06gomock_demo 0.247s
五、打桩(stub)
软件测试中的打桩是指用一些代码(桩stub)代替目标代码,通常用来屏蔽或补齐业务逻辑中的关键代码方便进行单元测试。
屏蔽:不想在单元测试用引入数据库连接等重资源
补齐:依赖的上下游函数或方法还未实现
上面代码中就用到了打桩,当传入Get
函数的参数为RandySun
时就返回1, nil
的返回值。
gomock
支持针对参数、返回值、调用次数、调用顺序等进行打桩操作。
参数
参数相关的用法有:
- gomock.Eq(value):表示一个等价于value值的参数 ;
- gomock.Not(value):表示一个非value值的参数 ;
- gomock.Any():表示任意值的参数;
- gomock.Nil():表示空值的参数;
- SetArg(n, value):设置第n(从0开始)个参数的值,通常用于指针参数或切片
具体示例如下:
m.EXPECT().Get(gomock.Not("RandySun")).Return(10, nil)
m.EXPECT().Get(gomock.Any()).Return(20, nil)
m.EXPECT().Get(gomock.Nil()).Return(-1, nil)
这里单独说一下SetArg
的适用场景,假设你有一个需要mock的接口如下:
type YourInterface {
SetValue(arg *int)
}
此时,打桩的时候就可以使用SetArg
来修改参数的值。
m.EXPECT().SetValue(gomock.Any()).SetArg(0, 7) // 将SetValue的第一个参数设置为7
返回值
gomock中跟返回值相关的用法有以下几个:
- Return():返回指定值
- Do(func):执行操作,忽略返回值
- DoAndReturn(func):执行并返回指定值
例如:
m.EXPECT().Get(gomock.Any()).Return(20, nil)
m.EXPECT().Get(gomock.Any()).Do(func(key string) {
t.Logf("input key is %v\n", key)
})
m.EXPECT().Get(gomock.Any()).DoAndReturn(func(key string)(int, error) {
t.Logf("input key is %v\n", key)
return 10, nil
})
调用次数
使用gomock工具mock的方法都会有期望被调用的次数,默认每个mock方法只允许被调用一次。
m.
EXPECT().
Get(gomock.Eq("RandySun")). // 参数
Return(1, nil). // 返回值
Times(1) // 设置Get方法期望被调用次数为1
// 调用GetFromDB函数时传入上面的mock对象m
if v := GetFromDB(m, "RandySun"); v != 1 {
t.Fatal()
}
// 再次调用上方mock的Get方法时不满足调用次数为1的期望
if v := GetFromDB(m, "RandySun"); v != 1 {
t.Fatal()
}
gomock为我们提供了如下方法设置期望被调用的次数。
Times()
断言 Mock 方法被调用的次数。MaxTimes()
最大次数。MinTimes()
最小次数。AnyTimes()
任意次数(包括 0 次)。
调用顺序
gomock还支持使用InOrder
方法指定mock方法的调用顺序:
// 指定顺序
gomock.InOrder(
m.EXPECT().Get("1"),
m.EXPECT().Get("2"),
m.EXPECT().Get("3"),
)
// 按顺序调用
GetFromDB(m, "1")
GetFromDB(m, "2")
GetFromDB(m, "3")
此外知名的Go测试库testify目前也提供类似的mock工具—testify/mock
和mockery
。
六、GoStub
GoStub也是一个单元测试中的打桩工具,它支持为全局变量、函数等打桩。
不过我个人感觉它为函数打桩不太方便,我一般在单元测试中只会使用它来为全局变量打桩。
安装
go get github.com/prashantv/gostub
使用示例
这里使用官方文档中的示例代码演示如何使用gostub为全局变量打桩。
// app.go
var (
configFile = "config.json"
maxNum = 10
)
func GetConfig() ([]byte, error) {
return ioutil.ReadFile(configFile)
}
func ShowNumber()int{
// ...
return maxNum
}
上面代码中定义了两个全局变量和两个使用全局变量的函数,我们现在为这两个函数编写单元测试。
// app_test.go
import (
"github.com/prashantv/gostub"
"testing"
)
func TestGetConfig(t *testing.T) {
// 为全局变量configFile打桩,给它赋值一个指定文件
stubs := gostub.Stub(&configFile, "./test.toml")
defer stubs.Reset() // 测试结束后重置
// 下面是测试的代码
data, err := GetConfig()
if err != nil {
t.Fatal()
}
// 返回的data的内容就是上面/tmp/test.config文件的内容
t.Logf("data:%s\n", data)
}
func TestShowNumber(t *testing.T) {
stubs := gostub.Stub(&maxNum, 20)
defer stubs.Reset()
// 下面是一些测试的代码
res := ShowNumber()
if res != 20 {
t.Fatal()
}
}
执行单元测试,查看结果:
gostub_demo> go test -v
=== RUN TestGetConfig
app_test.go:20: data: blog="https://www.cnblogs.com/randysun/"
--- PASS: TestGetConfig (0.00s)
=== RUN TestShowNumBer
app_test.go:31: res: 20
--- PASS: TestShowNumBer (0.00s)
PASS
ok golang-unit-test-example/07gostub_demo 0.392s
从上面的示例中我们可以看到,在单元测试中使用gostub
可以很方便的对全局变量进行打桩,将其mock成我们预期的值从而进行测试。
七、总结
在日常工作开发中为代码编写单元测试时如何处理代码中的接口类型是十分常见的问题,本文介绍了如何使用gomock
mock相关接口和如何使用gostub
工具对全局变量进行打桩。