共计 7117 个字符,预计需要花费 18 分钟才能阅读完成。
文章目录
背景
最近在工作和业余开源奉献中,和单元测试接触的比拟频繁。然而在这两个场景之下写进去的单元测试貌似不太一样,即使是同一个代码场景,明天写进去的单元测试和昨天写的也不是很一样,我感触到了对于单元测试,我没有一个比拟对立的标准和一套单元测试实际的方法论。在写了一些单元测试之后我开始想去理解写单元测试的一些最佳实际和技巧。(其实起初我反思的时候感觉,我应该先去学习单元测试相干的最佳实际,现有一个大抵的概念,再去实操会好一些。)在这里总结成一篇文章分享给大家,心愿读者敌人们有所播种。
1. 为什么要写单元测试
单元测试是一个优良我的项目必不可少的一部分,在一个频繁变动和多人单干的我的项目中显得尤为要害。站在写程序的人的角度登程,其实很多时候你并不能百分之百确定你的代码就是一点问题都没有的,在计算机的世界里其实不确定的因素很多,比方咱们可能不确定代码中的一些依赖项,在理论代码执行的过程中他会合乎咱们的预期,咱们也不能确定咱们写的逻辑是否能够涵盖所有的场景,比方可能会存在写了 if 没有些 else 的状况。所以咱们须要写自测去自证咱们的代码没有问题,当然写自测也并不能够保障代码就完完全全没有问题了,只能说能够做到尽可能的防止问题吧。其次对于一个多人参加的我的项目来说,开源我的项目也好,工作中多人合作也好,如果要看懂一段逻辑是干嘛的,或者要理解代码是怎么运作的,最好的切入点往往是看这个我的项目的单元测试或者参加编写这个我的项目的单元测试。我集体要学习一个开源我的项目也是首先从单元测试动手的,单元测试能够通知我一段逻辑这段代码是干什么的,他的预期是输出是什么,产出是什么,什么场景会报错。
2. 如何写好单元测试
这一章节将会介绍为什么一些代码比拟难以测试,以及如何写一个比拟好的测试。在这里会联合一些我看过的一些开源我的项目的代码进行举例讲述。
2.1 什么代码比拟难测试
其实不是所有的代码都是能够测试的,或者说有的代码其实是不容易测试的,有时候为了不便测试,须要把代码重形成容易测试的样子。然而很多时候在写单元测试之前,你都不晓得你写的代码其实是不能够测的。这里我举 go-mysql 的一些代码例子来论述不可测或者不容易测的因素都有哪些。
go-mysql 是 pingcap 首席架构师唐刘大佬实现的一个 mysql 工具库,外面提供了一些实用的工具,比方 canal 模块能够生产 mysql-binlog 数据实现 mysql 数据的复制,client 模块是一个简略的 mysql 驱动,实现与 mysql 的交互等等,其余性能能够去 github 上看 readme 具体介绍。最近因为工作须要看了大量这个库的源码,所以在这里拿一些代码进去举举例子。
2.1.1 代码依赖内部的环境
在咱们理论些代码的时候,理论一部分代码会比拟依赖内部的环境,比方咱们的一些逻辑可能会须要连贯到 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 去验证本人的逻辑。
2.1.2 代码太过冗余
有时候写代码可能就是图个痛快,一把梭哈把所有的逻辑都放在一个函数外面,这样就会导致过多的逻辑沉积在一起,测试的时候分支可能过多,所以为了单元测试看起来比拟简洁可能须要咱们把这样的逻辑进行拆分,把专门做一件事件的逻辑放在一起,去做对应的测试。而后对整段逻辑做整体测试就好。
2.2 如何写好一个单元测试
为了不便去形容这个一些内容,这里我简略的提供一个这样的函数。这个函数逻辑比较简单,就是输出一个名字,而后返回一个跟你打招呼的信息。
func Greeter(name string) string {return "hi" + name}
那么如何写这个函数的测试呢。我了解有两个要害的点,一是单元测试的命名,二是单元测试的内容架构。
2.2.1 单元测试的命名
命名其实也是有考究的,我了解单元测试也是给他人看的,所以当我看你写的单元测试的时候,最好在命名上有: 测试对象,输出,预期输入 。这样能够通过名字晓得这个单元测试大抵内容是什么。
2.2.2 测试内容架构
测试的内容架构次要是这几件事件:
- 测试筹备 。在测试之前可能须要筹备一些数据,mock 一些入参。
- 执行 。执行须要测试的代码。
- 验证 。验证咱们的逻辑对不对,这里次要做的是执行代码之后预期的返回和理论返回之间的一个比对。
所以综合下面两点,比拟好的实际是这样的。
// 比拟具体的写法,测试的是什么 (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"))
这样子其实在性能上是一样的,然而会影响代码的可读性。不是特地举荐。
3. 什么是好的测试
在讲了如何写一个单元测试之后,咱们来说说什么的测试才是好的测试。我集体认为一个好的测试应该具备一下三点:
- 可信赖 。首先咱们写的单元测试的作用是测试某一段逻辑的正确性,如果咱们的写的单元测试都是不值得信赖的,那么又如何保障测试的对象是值得信赖的呢?有时候一些单元测试也有可能时好时坏,比方一个单元测试依赖一个随机数去做一些逻辑,那么自身这个随机数就是不可控的,可能这下执行是好的,下一次执行就过不了了。
- 可保护 。业务逻辑会一直的迭代,那么单元测试也会跟着一直的迭代,如果每次改单元测试都要花很多工夫,那这个单元测试的可维护性就比拟差了。其实把所有逻辑都塞在一个函数里,我集体认为这样子的代码对应的单元测试可维护性是比拟差的,全部堆在一块意味着每次的改变所带来的单元测试的改变都须要兼顾全局的影响。如果尽可能的拆分开来,能够实现单元测试的按需改变。
- 可读性 。最初的也是最重要的 就是单元测试的代码的可读性了,一个无奈让人了解的单元测试其实和没写没什么区别,无奈了解根本也认为着不可信赖和不可保护。我代码都看不懂怎么信赖你呢?所以保障代码的可读性是很重要的。
其实讲了一些概念之后对怎么样写好一个测试咱们还是没什么印象的,那么能够从一些不好的 case 去动手,咱们晓得了那些实际是不好的之后,就会对好的实际有一个大抵的意识。
- 可读性低的测试 ,下面写到的对 greeter 函数的单元测试中,其实这段代码写的不是很好的,可读性比拟低。因为对于读这段代码的人来说,我都不晓得这个“hi elliot”是什么,他为什么会呈现在这里。如果把他略微命名成一个变量的话可读性会高一些。
// 可读性比拟低,因为读者并不知道这个“hi elliot”是什么
assert.Equal(t, "hi elliot", greet)
// 这样就会好一些
expectedGreetMsg := "hi elliot"
assert.Equal(t, expectedGreetMsg, greet)
- 带有逻辑的测试 。作为一个单元测试,应该尽量避免外面带有逻辑,如果有过多的逻辑在外面,那么就调演变成他自身也是须要测试的代码,因为过多的逻辑带来了更多的不可信赖。
- 有错误处理的测试 。在单元测试中不要带有错误处理的逻辑,因为单元测试自身就是用来发现程序中的一些谬误的,如果咱们间接把 panic 给捕捉了,那么也不晓得代码是在哪里错的。另外对于单元测试来说谬误应该也是一种预期的后果。
- 无奈重现的测试 。这个《单元测试的艺术》这本书里提供了一个比拟有意思的例子。在代码中应用了随机数进行测试,每次产生的随机数都不一样,意味着每次测试的数据也就不一样了,这意味着这个测试代码可信赖水平比拟低。
- 单元测试之间尽量隔离 。尽量做到每个单元测试之间的数据都是本人筹备的,尽量不要共用一套货色,因为这样做就意味着一个单元测试的胜利与否与另外一个单元测试开始有了关联,不可控的货色就减少了。举个例子,nutsdb 的单元测试有几个全局变量, 其中大多数单元测试的 db 实例是共用的,如果上一个单元测试把 db 敞开了,或者批改了一些配置重启了 db,对于下一个单元测试来说他是不晓得他人操作了什么,等他执行的时候有可能就会呈现意想不到的谬误。
- 每一个单元测试都尽量独立 。每个单元测试尽量能够独立运行。也不要有先后顺序,不要在一个测试里去调用另外一个测试。
在讲完大略比拟好的单元测试实际之后,咱们能够略微晋升一下。咱们无妨假如有这么一个场景,其实是测一段逻辑,然而会有好几个测试用例须要测试,那么咱们须要写好几个测试的函数嘛?其实是不必的,这里就波及到了, 参数化测试 ,什么意思呢?咱们间接举例吧。看上面这段代码。
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 循环外面跑这三个测试用例。集体感觉这是一种比拟好的测试方法。
3. go 测试工具举荐
在讲完下面的一些测试方法之后,在这里举荐一些在 go 外面的测试工具。其中最驰名的 testify 就是不得不举荐的了。很多开源我的项目都在用这个库构建测试用例。说到这里忽然想到之前有人给 goleveldb 提交 pr 代码写本人的单元测试时引入了这个库,我还“批斗”了他,说批改代码和引入新的库是两码事,请你离开做 hhhh,当初想想还蛮不好意思的。回归正题,咱们来简略介绍一些 testify 这个库。
3.1 testify
testify 这个库次要有三个核心内容,assert,mock,suite。assert 就是断言,能够封装了一些判断是否相等,是否会有异样之类的。文章篇幅无限,这里就不对 assert 的 api 一一介绍了,感兴趣的敌人们能够看衍生浏览的相干文章。这里我次要介绍 mock 和 suite 模块。
3.1.1 mock
在咱们要筹备测试的时候常常须要筹备一些数据,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())
}
3.1.2 suite
有时候咱们可能须要测的不是一个独自的函数,是一个对象的很多办法,比方想对 leveldb 的一些次要办法进行测试,比方简略的读写,范畴查问,那么如果每个性能的单元测试都写成一个函数,那么可能这里会反复初始化一些货色,比方 db。其实这里是能够做到共享一些状态的,比方数据写入之后就能够测试把这个数据读出来,或者范畴查问。在这里的话其实用一种比拟严密的形式把他们串联起来会比拟好。那么 suite 套件就应运而生。这里我就不打算在具体介绍了,感兴趣的读者能够移步衍生浏览中的《go 每日一库之 testify》。我了解这篇文章讲的比拟清晰了。然而这里的话我能够提供 nutsdb 的一个相干测试用例大家参考:https://github.com/nutsdb/nut… 大家感兴趣的话也能够参考这段代码。
4. 总结
这篇文章次要是总结最近我在单元测试下面的一些思考和积淀,以及对 go 的测试工具的粗略解说。在本文中应用到的一些开源我的项目的源码,次要是分享一些本人的思考,心愿对大家有所帮忙。
延长浏览
- Best Practices for Testing in Go:https://fossa.com/blog/golang…
- 《单元测试的艺术》
- go 每日一库之 testify:https://segmentfault.com/a/11…
- 应用 testify 和 mockery 库简化单元测试:https://segmentfault.com/a/11…