《分布式对象存储》作者手把手教你写 GO 语言单元测试

阅读数:106 2019 年 10 月 9 日 23:13

《分布式对象存储》作者手把手教你写 GO 语言单元测试

如何写 GO 语言单元测试

GO 语言内建了单元测试(Unit Test)框架。这是为了从语言层面规范写 UT 的方式。GO 语言的命名规则会将以 _test.go 结尾的 go 文件视作单元测试代码。

当我们用 go build 构建可执行程序时,这些 _test.go 文件被排除在构建范围之外。而当我们用 go test 来进行单元测试时,这些 _test.go 文件则会参与构建,且还会提供一个默认的 TestMain 函数作为 UT 的起始入口。

接下来,就让我们通过一个例子来看看如何写 GO 语言的单元测试。首先让我们来看这样一段代码:

复制代码
package db
//db 包实现了一个 DB 结构体用来封装对某个数据库的访问
import (
"someDB"
//someDB 提供了对实际数据库的
//insert/get/delete 等函数
)
...
type DB struct {
//DB 结构体的内部细节忽略
...
}
//DB 结构体提供了 Put/Get/Delete
// 三个方法,具体实现略
func (d *DB)Put(key, value string) error {
...
someDB.insert(...)
...
}
func (d *DB)Get(key string) (string, error) {
...
return someDB.get(...)
}
func (d *DB)Delete(key string) error {
...
someDB.delete(...)
...
}

从上面的代码可以看到我们在 db.go 中实现了一个 DB 结构体用来抽象对某个数据库的访问。现在,要为 DB 写 UT,我们通常会将测试代码放在 db_test.go 中。(虽然 GO 语言本身并不要求文件名的一一对应,但是这种约定俗成的命名规则能带给我们更好的可读性。)

复制代码
package db
//UT 的用例必须和代码在同一个包内
import (
"testing"
//testing 包提供了测试函数
// 必须用到的数据结构
...
)
// 我们会为 DB 结构体的每一个
// 方法都写一个测试函数
// 这里先列出各测试函数的签名
// 具体实现后面会给出
func TestPut(t *testing.T) {
...
}
func TestGet(t *testing.T) {
...
}
func TestDelete(t *testing.T) {
...
}

为了让 GO 语言的测试框架能够自动发现我们所有的测试用例,测试函数的签名也要遵循其特定的规则:

  • 函数名必须以 Test 开头,后面通常是待测方法的函数名
  • 参数必须是 *testing.T,它提供了 Error,Fatal 等方法用来报错和终止测试的运行

这些测试用例会在测试框架下并发地执行,并发度由 go test 时的 -parallel 参数指定。

具体的 UT 实现

  • TestPut
复制代码
func TestPut(t *testing.T) {
// 为了进行测试,我们首先要创建
// 一个 DB 结构体的实例,具体参数略
d := NewDB(...)
// 我们调用待测方法 Put
// 将一些数据写入数据库
err := d.Put("testputkey", "value")
// 必须检查返回的错误,确保返回 nil
if err != nil {
// 用 Error 来打印错误信息
t.Error(err)
}
// 接下来我们用 someDB 的 get 接口
// 来获取这些数据,这里注意尽量
// 避免用待测的 DB.Get 方法
// 原因见下
value, _ := someDB.get(...)
// 校验数据
if value != "value" {
t.Error("some msg")
}
}

在获取数据的时候不建议使用另一个待测方法 Get,这样可以避免测试污染。

所谓测试污染是指由非待测函数导致的失败,比如 TestPut 的待测函数是 DB.Put,如果我们使用 DB.Get 方法来获取数据,那么 DB.Get 如果出错就会导致测试用例失败,而此时我们需要额外的信息来判断究竟是 Put 出了问题还是 Get 出了问题。而 someDB.get 方法在 someDB 包里已经经过了测试,通常被认为是可信的。

我们会在后面的测试用例中看到类似的处理。

  • TestGet
复制代码
func TestGet(t *testing.T) {
d := NewDB(...)
// 首先测试 Get 不存在的 key
// 尽可能让参数名字自解释
_, err := d.Get("testgetnonexist")
if err != ErrNotFound {
t.Error("some msg")
}
// 用 someDB 的 insert 接口
// 来写入一些测试数据
err = someDB.insert(...)
if err != nil {
t.Fatal("some msg")
}
// 然后调用待测方法 Get 读取这些数据
value, err := d.Get("testgetkey")
if err != nil {
t.Error("some msg")
}
// 校验数据
if value != "value" {
t.Error("some msg")
}
}

Fatal 和 Error 的区别在于 Fatal 在报错后会立即终止当前用例继续运行,如果 insert 失败,则后续的 Get 也没有意义,所以用 Fatal 终止。

  • TestDelete
复制代码
func TestDelete(t *testing.T) {
d := NewDB(...)
// 首先用 someDB 的 insert 接口
// 来写入一些测试数据
err := someDB.insert(...)
if err != nil {
t.Fatal("some msg")
}
// 然后调用待测方法 Delete
// 删除这些数据
err = d.Delete("testdeletekey")
if err != nil {
t.Error("some msg")
}
// 用 someDB 的 Get 接口
// 来验证数据的删除
_, err := someDB.get(...)
if err != ErrNotFound {
t.Error("some msg")
}
}

运行测试的常见命令

  • 运行 go test 命令即可在编译并执行当前目录下的所有测试用例
  • 如果需要执行当前目录以及所有子目录中的测试用例,则运行命令 go test ./…
  • 如果需要执行某个测试用例,比如单单执行 TestGet 用例则运行 go test -run TestGet
  • 运行 go test -help 可查看详细的参数列表,比如之前提到的 -parallel 参数等

如何写好 GO 语言单元测试

我们在第一部分已经见过了基本的单元测试框架,会写自己的单元测试了。可是要想写出好的单元测试还不是那么简单,有很多要素需要注意。

用断言来代替原生的报错函数

让我们看这样一个例子:

复制代码
if XXX {
t.Error("msg")
}
if AAA != BBB {
t.Error("msg2")
}

GO 语言提供的 Error 太不友好了,判断的 if 需要写在前头。这对于我们这些写 UT 行数还要超过功能代码的 GO 语言程序员来说,增加的代码量是非常恐怖的。

使用断言可以让我们省略这个判断的 if 语句,增强代码的可读性。GO 语言本身没有提供 assert 包,不过有很多开源的选择。比如使用 https://github.com/stretchr/testify,上面的例子可以简化为:

复制代码
assert.True(t, XXX, "msg")
assert.Equal(t, AAA, BBB, "msg2")

除了 True 和 Equal 之外当然还有很多其它断言,这就需要我们自己看代码或文档去发现了

避免随机结果

让我们看这样一个例子:

复制代码
a := rand.Intn(100)
b := rand.Intn(10)
result := div(a, b)
assert.Equal(t, a/b, result)

UT 的结果应当是决定性(decisive)的,当我们使用了随机的输入值来进行 UT 时,我们让自己的测试用例变得不可控。当一切正常时,我们还不会意识到这样的坏处,然而当糟糕的事情发生时,随机的结果让我们难以 debug。

比如,上例在大多数时候都能正常运行,唯有当 b 随机到 0 时会 crash。在上例,比较正确的做法是:

复制代码
result := div(6, 3)
assert.Equal(t, 2, result)

避免无意义重复

让我们看这样一个例子:

复制代码
n := 10000
for i:=0; i<n; i++ {
doSomeThing()
assertSomeThing()
}

在设计 UT 时,我们要问问自己,重复执行 doSomeThing 多次会带来不同的结果吗,如果总是同样的结果,那么 doSomeThing 只做一次就足够了。如果确实会出现不同的结果,那简单重复 10000 次不仅浪费了有限的 CPU 等资源,也比不上精心设计的不同断言能给我们带来的更多好处。

在上例,比较正确的做法是:

复制代码
doSomeThing()
assertSomeThing()
doSomeThing()
// 断言我们在第二次 doSomeThing 时
// 发生了不同的故事
assertSomeThingElse()

尽量避免断言时间的结果

让我们看这样一个例子:

复制代码
start := time.Now()
doSomeThing()
assert.WithinDuration(t, time.Now(), start, time.Second)

即便我们很笃定 doSomeThing() 一定确定以及肯定能在 1 秒内完成,这个测试用例依然有很大可能在某个性能很差的容器上跑失败。除非我们就是在测试 Sleep 之类跟时间有关的函数,否则对时间的断言通常总是能被转化为跟时间无关的断言。一定要断言时间的话,断言超时比断言及时更不容易出错。

比如上面的例子,我们没办法断言它一定在 1 秒内完成,但是大概能断言它在 10 微秒内完不成。

尽量避免依赖外部服务

即使我们十分确信某个公有云服务是在线的,在 UT 中依赖它也不是一个好主意。毕竟我们的 UT 不仅会跑在自己的开发机上,也会跑在一些沙盒容器里,我们可无法知道这些沙盒容器一定能访问到这个公有云服务。如果访问受限,那么测试用例就会失败。

要让我们的测试用例在任何情况下都能成功运行,写一个 mock 服务会是更好的选择。不过有些外部服务是必须依赖且无法 mock 的,比如测试数据库驱动时必须依赖具体的数据库服务,对于这样的情况,我们需要在开始 UT 之前设置好相应的环境。此时也有一些需要注意的地方,见下节。

优雅地实行前置和后置任务

为了设置环境或者为了避免测试数据污染,有时候有必要进行一定的前置和后置任务,比如在所有的测试开始的前后清空某个测试数据库中的内容等。

这样的任务如果在每个测试用例中都重复执行,那不仅是的代码冗余,也是资源的浪费。我们可以让 TestMain 来帮我们执行这些前置和后置任务:

复制代码
func TestMain(m *testing.M) {
doSomeSetup()
r := m.Run()
doSomeClear()
os.Exit(r)
}

TestMain 函数是 GO 测试框架的入口点,运行 m.Run 会执行测试。TestMain 函数不是必须的,除非确实有必要在 m.Run 的前后执行一些任务,我们完全可以不实现这个函数。

测试用例之间相互隔离

TestA,TestB 这样的命名规则已经帮我们在一定程度上隔离了测试用例,但这样还不够。如果我们的测试会访问到外部的文件系统或数据库,那么最好确保不同的测试用例之间用到的文件名,数据库名,数据表名等资源的隔离。

用测试函数的名字来做前缀或后缀会是一个不错的方案,比如:

复制代码
func TestA(t *testing.T) {
f, err := os.Open("somefilefortesta")
...
}
func TestB(t *testing.T) {
f, err := os.Open("somefilefortestb")
...
}

这样隔离的原因是所有的测试用例会并发执行,我们不希望我们的用例由于试图在同一时间访问同一个文件而互相影响。

面向接口编程

这是典型的测试倒逼功能代码。

功能代码本身也许完全不需要面向接口编程,一个具体的结构体就足够完成任务。可是当我们去实现相应的单元测试时,有时候会发现构造这样一个具体的结构体会十分复杂。

这种情况下,我们会考虑在实际代码中使用接口(interface),并在单元测试中用一个 mock 组件来实现这个接口。考虑如下代码:

复制代码
type someStruct struct {
ComplexInnerStruct
}

我们要为这个 someStruct 写 UT,就不得不先构造出一个 ComplexInnerStruct。而这个 ComplexInnerStruct 可能依赖了几十个外部服务,构造这样一个结构体会是一件十分麻烦的事情。

此时我们可以这样做,首先我们修改实际的代码,让 someStruct 依赖某个接口而不是某个具体的结构体:

复制代码
type someStruct struct {
someInterface
}
type someInterface interface {
// 只适配那些被用到的方法
someMethod()
}

接下来我们的 UT 就可以用一个 mock 结构体来代替那个 ComplexInnerStruct:

复制代码
type mockStruct struct {}
func (m *mockStruct) someMethod() {
...
}
s := &someStruct{
someInterface: &mockStruct{},
}

这样,我们就帮自己省去了在 UT 中创建一个 ComplexInnerStruct 的繁杂工作。

结语

在工作中,我们一般都会将 UT 加入编译 job 作为代码提交流程的一部分。有时我们会发现自己或其他同事写的 UT 换个环境就冒出一些难以调查的随机失败。重启编译 job 并向程序员之神祈祷有时候确实可以让一些随机失败不再重现,但这只是掩盖了失败背后真正的问题。

作为一个有钻研精神的程序员,我们不妨仔细调查错误的可能成因,改良代码和 UT 的写法,让自己的生活更美好。

本文转载自公众号七牛云(ID:qiniutek)。

原文链接:

https://mp.weixin.qq.com/s/JiF_sSj0rHk4sOsDoJ2Gpw

评论

发布