最近在工作和业余开源贡献中,和单元测试接触的比较频繁。但是在这两个场景之下写出来的单元测试貌似不太一样,即便是同一个代码场景,今天写出来的单元测试和昨天写的也不是很一样,我感受到了对于单元测试,我没有一个比较统一的规范和一套单元测试实践的方法论。在写了一些单元测试之后我开始想去了解写单元测试的一些最佳实践和技巧。(其实后来我反思的时候觉得,我应该先去学习单元测试相关的最佳实践,现有一个大致的概念,再去实操会好一些。)在这里总结成一篇文章分享给大家,希望读者朋友们有所收获。
单元测试是一个优秀项目必不可少的一部分,在一个频繁变动和多人合作的项目中显得尤为关键。站在写程序的人的角度出发,其实很多时候你并不能百分之百确定你的代码就是一点问题都没有的,在计算机的世界里其实不确定的因素很多,比如我们可能不确定代码中的一些依赖项,在实际代码执行的过程中他会符合我们的预期,我们也不能确定我们写的逻辑是否可以涵盖所有的场景,比如可能会存在写了if没有写else的情况。所以我们需要写自测去自证我们的代码没有问题,当然写自测也并不可以保证代码就完完全全没有问题了,只能说可以做到尽可能的避免问题吧。其次对于一个多人参与的项目来说,开源项目也好,工作中多人协作也好,如果要看懂一段逻辑是干嘛的,或者要了解代码是怎么运作的,最好的切入点往往是看这个项目的单元测试或者参与编写这个项目的单元测试。我个人要学习一个开源项目也是首先从单元测试入手的,单元测试可以告诉我一段逻辑这段代码是干什么的,他的预期是输入是什么,产出是什么,什么场景会报错。
这一章节将会介绍为什么一些代码比较难以测试,以及如何写一个比较好的测试。在这里会结合一些我看过的一些开源项目的代码进行举例讲述。
其实不是所有的代码都是可以测试的,或者说有的代码其实是不容易测试的,有时候为了方便测试,需要把代码重构成容易测试的样子。但是很多时候在写单元测试之前,你都不知道你写的代码其实是不可以测的。这里我举go-mysql的一些代码例子来阐述不可测或者不容易测的因素都有哪些。
go-mysql 是pingcap首席架构师唐刘大佬实现的一个mysql工具库,里面提供了一些实用的工具,比如canal模块可以消费mysql-binlog数据实现mysql数据的复制,client模块是一个简单的mysql驱动,实现与mysql的交互等等,其他功能可以去github上看readme详细介绍。最近由于工作需要看了大量这个库的源码,所以在这里拿一些代码出来举举例子。
在我们实际些代码的时候,实际一部分代码会比较依赖外部的环境,比如我们的一些逻辑可能会需要连接到mysql,或者你会需要一个tcp的连接。比如下面这段代码:
/*
Conn is the base class to handle MySQL protocol.
*/
type Conn struct {
net.Conn
bufPool *BufPool
br *bufio.Reader
reader io.Reader
copyNBuf []byte
header [4]byte
Sequence uint8
}
这个是go-msyql处理网络连接的结构体,我们可以看到的是这个结构体里面包裹的是一个net.Conn接口,并不是某一个具体的实现,这样子提供了很灵活的测试方式,只需要mock一个net.Conn的实现类就可以测试他的相关方法了,如果这里封装的是net.Conn的具体实现比如TCPConn,这样就变得不好测试了,在写单元测试的时候你可能需要给他提供一个TCP的环境,这样子其实比较麻烦了。
第二个例子来自go-mysql canal这个模块,这个模块的主要功能通过消费mysql binlog的形式来复制mysql的数据,那么这里的整体逻辑怎么测试呢,这个模块是伪装成mysql的从节点去复制数据的,那么主节点在哪里呢,这里就要切切实实的mysql环境了。我们可以看看作者是怎么测试的,这里代码太长我就不贴出来了,把GitHub的代码链接贴在这里,感兴趣的读者可以去看点击这里看github代码。作者在CI环境里弄了一个mysql的环境,然后在测试之前通过执行一些sql语句来构建测试的环境,在测试的过程中也是通过执行sql的方式来产生对应的binlog去验证自己的逻辑。
有时候写代码可能就是图个爽快,一把梭哈把所有的逻辑都放在一个函数里面,这样就会导致过多的逻辑堆积在一起,测试的时候分支可能过多,所以为了单元测试看起来比较简洁可能需要我们把这样的逻辑进行拆分,把专门做一件事情的逻辑放在一起,去做对应的测试。然后对整段逻辑做整体测试就好。
为了方便去描述这个一些内容,这里我简单的提供一个这样的函数。这个函数逻辑比较简单,就是输入一个名字,然后返回一个跟你打招呼的信息。
func Greeter(name string) string {
return "hi " + name
}
那么如何写这个函数的测试呢。我理解有两个关键的点,一是单元测试的命名,二是单元测试的内容架构。
命名其实也是有讲究的,我理解单元测试也是给别人看的,所以当我看你写的单元测试的时候,最好在命名上有:测试对象,输入,预期输出。这样可以通过名字知道这个单元测试大致内容是什么。
测试的内容架构主要是这几件事情:
所以综合上面两点,比较好的实践是这样的。
// 比较详细的写法,测试的是什么(Greeter), 入参是什么(elliot), 预期结果是什么(hi elliot)
func Test_Greeter_when_param_is_elliot_get_hi_Elliot(t *testing.T) {
// 准备
name := "elliot"
// 执行
greet := Greeter(name)
// 验证
assert.Equal(t, "hi elliot", greet)
}
// 比较省略的写法,测试的是什么(Greeter), 入参是name, 预期结果是一个打招呼的msg,GreetMsg
func Test_Greeter_name_greetMsg(t *testing.T) {
// 准备
name := "elliot"
// 执行
greet := Greeter(name)
// 验证
assert.Equal(t, "hi elliot", greet)
}
这里要注意一个问题,尽量避免执行和验证的代码写在一起,比如写成这样子:
assert.Equal(t, "hi elliot", Greeter("elliot"))
这样子其实在功能上是一样的,但是会影响代码的可读性。不是特别推荐。
在讲了如何写一个单元测试之后,我们来说说什么的测试才是好的测试。我个人认为一个好的测试应该具备一下三点:
其实讲了一些概念之后对怎么样写好一个测试我们还是没什么印象的,那么可以从一些不好的case去入手,我们知道了那些实践是不好的之后,就会对好的实践有一个大致的认识。
// 可读性比较低,因为读者并不知道这个“hi elliot”是什么
assert.Equal(t, "hi elliot", greet)
// 这样就会好一些
expectedGreetMsg := "hi elliot"
assert.Equal(t, expectedGreetMsg, greet)
在讲完大概比较好的单元测试实践之后,我们可以稍微提升一下。我们不妨假设有这么一个场景,其实是测一段逻辑,但是会有好几个测试用例需要测试,那么我们需要写好几个测试的函数嘛?其实是不用的,这里就涉及到了,参数化测试,什么意思呢?我们直接举例吧。看下面这段代码。
func isLargerThanTen(num int) bool {
return num > 10
}
func TestIsLargerThanTen_All(t *testing.T) {
var tests = []struct {
name string
num int
expected bool
}{
{
name: "test_larger_than_ten",
num: 11,
expected: true,
},
{
name: "test_less_than_ten",
num: 9,
expected: false,
},
{
name: "test_equal_than_ten",
num: 10,
expected: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
res := isLargerThanTen(test.num)
assert.Equal(t, test.expected, res)
})
}
}
这里面测试的是一个判断入参是否大于10的函数,那么我们自然而然的想到三个测试用例,参数大于10的,等于10的,小于10的。但是实际上这三个测试用例都在测试一段逻辑,实际上是不太需要写三个函数的。所以把这三个测试用例和对应的预期结果封装起来,在for循环里面跑这三个测试用例。个人觉得这是一种比较好的测试方法。
在讲完上面的一些测试方法之后,在这里推荐一些在go里面的测试工具。其中最著名的testify就是不得不推荐的了。很多开源项目都在用这个库构建测试用例。说到这里突然想到之前有人给goleveldb提交pr代码写自己的单元测试时引入了这个库,我还“批斗”了他,说修改代码和引入新的库是两码事,请你分开做hhhh,现在想想还蛮不好意思的。回归正题,我们来简单介绍一些testify这个库。
testify这个库主要有三个核心内容,assert, mock, suite。assert就是断言,可以封装了一些判断是否相等,是否会有异常之类的。文章篇幅有限,这里就不对assert的api一一介绍了,感兴趣的朋友们可以看衍生阅读的相关文章。这里我主要介绍mock和suite模块。
在我们要准备测试的时候经常需要准备一些数据,mock模块通过实现接口的方式来伪造数据。从而在测试的时候可以用这个mock的对象作为参数进行传递。废话不多说我们看下怎么简单的实践一下。
首先我们定义一个接口:
//go:generate mockery --name=Man
type Man interface {
GetName() string
IsHandSomeBoy() bool
}
这个接口定义了一个男孩子,一个方法是获取他的名字,第二个方法是看他是不是帅哥。这里我还推荐使用go:generate的方式执行mockery(执行go get -u -v github.com/vektra/mockery/…/安装)命令去生成对应的mock对象(生成的代码会放在当前目录的mocks目录下,当然你也可以在命令上添加参数指定生成路径),这样就不需要我们去实现mock对象的一些方法了。下面我们看下生成的代码是怎么样的。
// Code generated by mockery v2.10.0. DO NOT EDIT.
package mocks
import mock "github.com/stretchr/testify/mock"
// Man is an autogenerated mock type for the Man type
type Man struct {
mock.Mock
}
// GetName provides a mock function with given fields:
func (_m *Man) GetName() string {
ret := _m.Called()
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// IsHandSomeBoy provides a mock function with given fields:
func (_m *Man) IsHandSomeBoy() bool {
ret := _m.Called()
var r0 bool
if rf, ok := ret.Get(0).(func() bool); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
那么我们怎么使用呢?看看下面代码:
func TestMan_All(t *testing.T) {
man := mocks.Man{}
// 可以通过这段话来添加某个方法对应的返回
man.On("GetName").Return("Elliot").On("IsHandSomeBoy").Return(true)
assert.Equal(t, "Elliot", man.GetName())
assert.Equal(t, true, man.IsHandSomeBoy())
}
有时候我们可能需要测的不是一个单独的函数,是一个对象的很多方法,比如想对leveldb的一些主要方法进行测试,比如简单的读写,范围查询,那么如果每个功能的单元测试都写成一个函数,那么可能这里会重复初始化一些东西,比如db。其实这里是可以做到共享一些状态的,比如数据写入之后就可以测试把这个数据读出来,或者范围查询。在这里的话其实用一种比较紧密的方式把他们串联起来会比较好。那么suite套件就应运而生。这里我就不打算在详细介绍了,感兴趣的读者可以移步衍生阅读中的《go每日一库之testify》。我理解这篇文章讲的比较清晰了。但是这里的话我可以提供nutsdb的一个相关测试用例大家参考:https://github.com/nutsdb/nutsdb/blob/master/bucket_meta_test.go 大家感兴趣的话也可以参考这段代码。
这篇文章主要是总结最近我在单元测试上面的一些思考和沉淀,以及对go的测试工具的粗略讲解。在本文中使用到的一些开源项目的源码,主要是分享一些自己的思考,希望对大家有所帮助。