数人云团队在最近 5 个月的 golang 项目实践中所积累的单元测试的一些经验,团队项目的覆盖率从最初的无到现在的接近 90%,想着我们遇到的问题大家可能也会遇到,所以在这里把实践写出来,期待大家的反馈和建议。
首先我们来了解一下 go 语言单元测试的基础知识。
go 语言的单元测试采用内置的测试框架,通过引入 testing 包以及 go test 来提供测试功能。
在源代码包目录内,所有以_test.go 为后缀名的源文件被 go test 认定为测试文件,这些文件不包含在 go build 的代码构建中,而是单独通过 go test 来编译,执行。
通常对于测试用例,go test 有着以下规约:
每个测试函数必须导入 testing 包。测试函数有如下的命名:
func TestName(t *testing.T) {
// ...
}
测试函数的名字必须以 Test 开头,可选的后缀名必须以大写字母开头:
func TestSin(t testing.T) { / ... */ }
func TestCos(t testing.T) { / ... */ }
func TestLog(t testing.T) { / ... */ }
将测试文件和源码放在相同目录下,并将名字命名为{source_filename}_test.go
假设被测试文件 example.go,那么在 example.go 相同目录下建立一个 example_test.go 的文件去测试 example.go 文件里的方法。
当运行 go test 命令时,go test 会遍历所有的 *_test.go 中符合上述命名规则的函数,然后生成一个临时的 main 包用于调用相应的测试函数,然后构建并运行、报告测试结果,最后清理测试中生成的临时文件。
了解了基础知识,我们举个例子来直观了解在 go 项目中编写单元测试的全过程,下图是一个项目 src/utils 包下源码的结构:
对于每个包下的每一个文件都有相应的 test 文件。对于 slice.go 这个文件,源码是这样的:
StringInSlice 这个函数去检查一个字符串是否在一个字符串列表中,输入一个 string,返回一个布尔值。
slice_test.go 中对 StringInSlice 的测试用例是这样的:
然后运行测试,运行方式有多种:
# -v是显示出详细的测试结果, -cover 显示出执行的测试用例的测试覆盖率。
go test -v -cover=true ./src/utils/slice_test.go ./src/utils/slice.go
执行结果:
当测试整个 utils 包时,使用命令:
go test -v -cover=true ./src/utils/...
当测试单个测试用例时,使用命令:
#./src/utils为包utils的路径
go test -v -cover=true ./src/utils -run TestSuccessStringInSlice
在 go 语言中表格驱动测试非常常见。表格驱动的测试用例是在表格中预先定义好输入,期望的输出,和测试失败的描述信息,
然后循环表格调用被测试的方法,根据输入判断输出是否与期望输出一致,不一致时则测试失败, 返回错误的描述信息。
这种方法易于覆盖各种测试分支 ,测试逻辑代码没有冗余,开发人员只需要向表格添加新的测试数据即可。
对于适用于表格驱动测试的源码,我们采用开源工具gotests来自动生成测试用例。拿 src/utils/slice.go 为例,开发环境安装 gotests,
然后运行 gotests -all -w slice.go, slice_test.go 会自动创建在当前目录下,并自动生成测试代码:
开发人员只需要将不同的测试数据按照 tests 定义的结构写在//TODO:Add test cases 下面,测试用例就完成了。
mock 是单元测试中常用的一种测试手法,mock 对象被定义,并能够替换掉真实的对象被测试的函数所调用。
而 mock 对象可以被开发人员很灵活的指定传入参数,调用次数,返回值和执行动作,来满足测试的各种情景假设。
那什么情况下需要使用 mock 呢?一般来说分这几种情况:
为了保证测试的轻量以及开发人员对测试数据的掌控,采用 mock 来斩断被测试代码中的依赖不失为一种好方法。
每种编程语言根据语言特点其所采用的 mock 实现有所不同。
在 go 语言中,mock 一般通过两种方法来实现,一种是依赖注入,一种是通过 interface,下面我们分别通过例子来说明这两种技术实践。
依赖注入为一个类或者函数 A,用到内部对象 B,B 在 A 的外部创建,当运行 A 调用 B 时,通过某种方式将外部创建的 B 的实例赋给 A 内的 B。
这样当 A 调用 B 时,B 就会按照外部定义的方式去运行。下面是一个例子:
在测试 CheckQuota 时,我们看到其函数体内有一个依赖 notifyUser, notifyUser 是用来向用户发送 email 信息,
在测试时,我们当然不希望发送真实的邮件, 因此,需要创建一个伪邮件发送函数替代真实的邮件发送函数。
上图中 TestCheckQuotaNotifiesUser 中定义的匿名函数就是一个伪邮件发送函数,用户自定义伪邮件函数的行为,然后替换真实的邮件发送函数。
在这里需要特别注意的是,在 notifyUser 被伪邮件函数赋值前,需要将原来的值存下来,测试用例执行完之后再赋回去, 否则 notifyUser 的行为将会全局改变。
关于示例代码的详情请参考 go 语言圣经白盒测试部分。
除了依赖注入,另一种 mock 实现是通过 go 语言的 interface,被 mock 的对象需要继承 interface,并在 interface 中定义好被 mock 对象的方法。
mock 对象通过实现 interface 的所有方法来表明自己实现了这个 interface,这样 mock 对象的值就可以替换被 mock 对象的值。
对于 mock 对象我们可以自己定义实现,也可以通过工具实现。开源软件 gomock3可以根据指定的 interface 自动生成 mock 对象,
并对 mock 对象自定义行为和返回结果,检查被调用次数,是一款非常好用的工具。
下面通过一个简单的示例来描述如何使用 gomock 工具。
首先从 github 上获取 gomock 的相关源码包,并将其放在项目的 vendor 目录中。
go get github.com/golang/mock/gomock
go get github.com/golang/mock/mockgen
将需要 mock 的方法放在 interface 中,使用 mockgen 命令指定接口实现 mock 接口,命令为:
mockgen -source {source_file}.go -destination {dest_file}.go
之后就可以初始化并使用 dest_file.go 里生成的 mock 接口来自定义被 mock 示例调用的方法的行为。
看图中的代码,appControllerInterface 被 gomock 实现,生成 mock 对象 mockControllerImpl 来替代 mockAppImpl.
mockControllerImpl 对 Applications 方法的定义是不管参数是何值,当 Applications 被调用时会返回 nil, nil,并且此方法只能被调用 1 次。
当 mockAppImpl 的 Applications 方法被调用时,gomock 会根据预先定义的行为给出返回值,做出判断。
对于 gomock 的详细使用情况,可参考源码设计gomock
这里有个关键词:云平台, 大家会疑惑: 云平台的测试和其他产品的测试有什么不同吗?
云平台的产品有这样的特点,其底层依赖的基础服务会比较多,且难以 mock。
比如本公司的开源产品 crane 和 swan, crane是基于 docker swarm 实现的容器管理工具,其底层紧密的依赖 docker 容器。
而swan是基于 mesos 集群的调度器,其底层会紧密依赖 mesos,同样由于基于 docker 容器技术,也会紧密依赖 docker 容器来实现调度。
对于调用底层 docker 接口的代码,在源码结构设计上,可以将这一部分代码封装成自定义的 dockerclient, dockerclient 继承 interface,
这样 dockerclient 可以通过 mock 被自定义的 dockerclient 替代,从而斩断依赖, 测试上层逻辑代码。
那么如何测试 dockerclient 呢, dockerclient 的代码直接调用了 docker endpoint 的 api,而 docker 对象并没有办法 mock,我们采用的方法是创建 mock server。
通过定义 request url, request method 来自定义返回的 response status code 和 body。
为了提高编写测试用例的效率,团队写了一个通用的mock-server工具来供大家使用。
mock-server 的优点是支持多种形式的 request body 和 response body 的数据定义, 支持的形式有 string, interface, json,file, io.Reader,
这样开发人员在定义 body 的数据时,可以选择自己熟悉便利的方式来定义。
例如 crane 中定义 docker endpoint 服务来测试 ListContainersID 方法的代码如下:
由此可看到,mock-server 在解决 reset api 的依赖中是非常便利的。
在团队认识到单元测试是控制产品质量和促进良好代码结构的重要手段后,我们开始把单元测试代码覆盖率作为代码提交的第一道审查防线。
开发提交的 pr 通过 github webhook 触发 jenkins CI job,运行 go test,查看新的更改是否能通过单元测试,并且能获得当前每个文件的单元测试覆盖率。
当然在 github 中也可以通过 travis CI 来实现这样的控制。我们可以把运行测试和获取单元测试覆盖率的命令在 Makefile 中实现, 然后根据需要搭建 CI 环境。
在 makefile 中,需要注意的是 go test 是按 package 计算测试覆盖率的,如果想获得整个项目的覆盖率,首先需要列出项目内的 packages,然后遍历 packages,通过聚合统计文件 coverage.out,最后算出整个项目的覆盖率。
以上,就是数人云做云平台下 Go 语言单元测试实践:)