如何编写可测试的代码:两个核心三个思路
👉导读
在需要长期迭代的项目中编写单元测试,已经在各个团队中逐渐成为一种虚伪的共识。虽然嘴上都说好,但身体很诚实。👉目录
1 把大象放进冰箱2 纯函数3 抽离依赖4 对象化5 函数变量化6 总结一下7 最后,尽量避免使用 init8 写到最后在需要长期迭代的项目中编写单元测试,已经在各个团队中逐渐成为一种虚伪的共识。虽然嘴上都说好,但身体很诚实。毕竟编写单元测试需要在实现业务功能以外付出额外的精力和时间,所以很多人把它视为是一种沉重的工作负担。造成这种认知的本质问题主要有两点,除了在意识上没有真正认同单元测试的价值外,更多的还是因为实践中发现编写单元测试太耗时,经常要花费很多时间去设计测试用例,而且为了让被测函数跑起来,需要花费大量时间去为它创建运行环境,初始化变量,mock 对象等等,有时候甚至抠破脑袋也不知道该怎么写测试。因此,本文以 Go 语言为例,讲讲如何设计和编写容易测试的业务代码。
其实,如果有意识地设计数据结构和函数接口,其实我们的代码是很容易进行测试的,不需要任何奇技淫巧。不过实际工作中,大部分同学在设计阶段并没有 For Test 的意识,自然而然就会写出一些很难测试的代码。要明白代码易测试和逻辑结构清晰是两码事,逻辑清晰并不代表代码易测试,即使是经验丰富的程序员如果不注意也会写出难以测试的代码,比如:
func GetUserInfo(uid int64) (*UserInfo, error) {
key := buildUserCacheKey(uid)
val, err := redis.NewClient(USERDB).GetString(key)
if err == nil {
return unmarshalUserInfoFromStr(val)
}
res, err := mysqlPool.GetConn().Query("select * from user where uid=?", uid)
// ...
}
上面这段代码逻辑写得还是很清晰的(不是自夸),先从 Redis 里取缓存,没取到再去 MySQL 取。虽然很容易读懂,但是如果要你给这个函数写单元测试,那你就会很崩溃了。因为函数内部要去 Redis 取数据,在开发环境中根本连不上 Redis 。即使连上了,Redis 里也没数据。MySQL 同理。并且你有没有发现,这些个依赖还根本没法 mock!在给 GetUserInfo 函数编写单测时,我根本没有办法控制 MySQL 和 Redis 对象的行为。如果没有办法控制它们,那确实就没办法编写测试代码。
那接下来我们就进入正题:如何编写易于测试的业务代码。
01
把大象装进冰箱有几个步骤?
打开冰箱门;
把大象塞进去;
关上冰箱门。
当然这只是个笑话,开关门倒是简单,但是把大象塞进去哪有那么简单。然而,如果在写业务代码时有意识地稍微考虑一下可测试性,那么写单元测倒是真的是一件挺容易的事情,主要就两步:
设置好所有入参的值;
判断输出的值是否如预期。
这两个步骤非常直观也很容易理解,但是实际中为啥单测写起来那么复杂呢?
02
纯函数
为了讲明白这个问题,首先我要讲一讲纯函数的概念。如果一个函数满足:
输入相同的入参会得到相同的结果;
无副作用;
无外部依赖。
那么这个函数就是一个纯函数。纯函数的例子有很多,像 Go 标准库里的几乎都是纯函数。我们也可以自己实现一些纯函数,比如:
func Add(a, b int) int {
return a+b
}
func getRedisUserInfoKey(uid int64) {
return fmt.Sprintf("uinfo:%d", uid)
}
func sortByAgeAsc(userList []User) []User {
n := len(userList)
for i:=0; i<n; i++ {
for j := i+1; j<n; j++ {
if userList[i].Age > userList[j].Age {
userList[i], userList[j] = userList[j], userList[i]
}
}
}
return userList
}
func ParseInt(s string) (int64, error) {
// ...
}
纯函数最大的特点就是其结果只受输入控制,当入参确定了,输出结果就确定了。入参和输出结果之间有一种确定性的映射关系(虽然可能很复杂),就像数学中的函数一样。基于这种特性,对于纯函数就非常容易编写测试用例,尤其是基于表格的测试,比如:
var testCases = []struct{
input string
expectOutput int64
expectErr error
}{
{"100",100,nil,},
{"-99999",-99999,nil,},
{"1.2",0,ErrNotInt,},
// ...
}
for _, tc := range testCases {
actual, err := ParseInt(tc.input)
assert_eq(tc.expectOutput, actual)
assert_eq(tc.expectErr, err)
}
基于表格编写测试用例是最好的一种单测编写方式,没有之一。我们对每一组测试,输入是什么,输出应该是什么,如果有错误的话应该返回什么错误,这些都一目了然。并且我们可以很容易地新增更多测试用例,而不需要修改其它部分代码。
但实际业务开发中我们很少编写纯函数,大部分都是非纯函数,比如:
func NHoursLater(n int64) time.Time {
return time.Now().Add(time.Duration(n) * time.Hour)
}
此函数返回距今 n 小时后的时间。虽然接收一个参数 n,但是实际上每次执行结果都是随机的,因为这个函数除了依赖 n 还依赖当前时间。而当前时间的值并不由调用方来控制且一直在变,因此你没法预测当输入 n 之后函数会输出什么。这其实就是一个很典型的隐式依赖——虽然我们输入了参数 A,但是函数内部还隐式地依赖了别的参数。
再看个例子:
func GetUserInfoByID(uid int64) (*UserInfo, error) {
val, err := mysqlPool.GetConn().Query("select * from t_user where id=? limit 1", uid)
if err != nil {
return nil, err
}
return UnmarshalUserInfo(val)
}
这个函数的问题也类似,即使你传了 uid,但是无法确定函数会返回什么值,因为它完全依赖内部的 MySQL 模块的返回。这些都是平时业务代码中非常常见的例子。你可以想一想,如果让你来对上述两个非纯函数编写单测,你应该怎么做呢?
其实如果函数的实现像上面两个例子,那么除了用 monkeyPatch 这种骚操作,基本上没办法做测试。不过既然是骚操作,那么这里就不多说了。我们应该要把 monkeyPatch 视为最后的手段,如果为某个函数写测试时不得不使用 monkeyPatch,那只能说明这段代码写得有问题。monkeyPatch 应该只出现在给老项目补单测当中,我还是更多地讲讲如何编写可测试代码。
其实讲上面的例子,最大的目的就是想告诉大家一个道理:如果要容易地对函数进行测试,就要想办法让函数依赖的变量全部可控。为了做到这些,我总结了一些指导思想:
03
抽离依赖
最简单的办法就是让函数所有的依赖都作为入参传入,对于上面例子我们可以这样改造:
func NHoursLater(n int64, now time.Time) time.Time {
return now.Add(time.Duration(n) * time.Hour)
}
func GetUserInfoByID(uid int64, db *sql.DB) (*UserInfo, error) {
val, err := db.Query("select * from t_user where id=? limit 1", uid)
if err != nil {
return nil, err
}
return UnmarshalUserInfo(val)
}
这样改造之后,虽然在调用时需要额外实例化一些对象,但并不是一个大问题,并且我们的函数更容易测试了。对于 NHoursLater 这个函数,我可以随意设定 now 的值,然后看结果是否和预期一致,测试起来非常容易。但是对于第二个例子就有些问题了,因为传入的参数是 *sql.DB 这样一个指向结构体对象的指针,我想控制它的行为就比较麻烦了。因为 sql.DB 是标准库实现的对象,其方法都在标准库实现,没办法修改。因此这里就应该考虑使用 Go 中的 interface,比如:
type Queryer interface {
Query(string, args ...interface{}) (*sql.Rows, error)
}
func GetUserInfoByID(uid int64, db Queryer) (*UserInfo, error) {
val, err := db.Query("select * from t_user where id=? limit 1", uid)
if err != nil {
return nil, err
}
return UnmarshalUserInfo(val)
}
这里立刻就能够看出使用 interface 的好处!interface 限制对象的行为,但不限制具体对象的实现,所谓的动态派发。因此我们在编写测试代码时,就可以自己简单实现一个 Queryer 来控制它的行为,从而完成测试,比如:
type mockQuery struct {}
func (m *mockQuery) Query(string, args ...interface{}) (*sql.Rows, error) {
return sqlmock.NewRows([]string{"id", "name", "age"}).AddRow(1, "jerry", 5).AddRow(2, "tom", 7)
}
func TestGetUserInfoByID(t *testing.T) {
userInfo, err := GetUserInfoByID(1, new(mockQuery ))
assert_eq(err, nil)
assert_eq(*userInfo, UserInfo{ID: 1, Name:"jerry", Age: 5})
}
然后你就可以通过表格驱动的方式,配合上自己的 mock 对象,为这个函数编写更多的测试用例。
简单总结一下我们可以归纳一个抽离依赖三部曲:
梳理函数依赖;
依赖转为入参;
把具体对象转为接口。
把依赖抽离为入参是一种常用的方式,但是在有些场景它也不完全适用,因为有些函数的依赖实在是太多了,比如:
func NewOrder(user UserInfo, order OrderInfo) error {
// 幂等检测
if err := idempotenceCheck(user, order); err != nil {
return err
}
// 去订单系统创建订单,返回创建成功的订单信息
newInfo, err := orderSystem.NewOrder(user, order)
if err != nil {
return err
}
// 发送订单信息到消息队列的new_order topic中
err = mq.SendToTopic("new_order", newInfo)
if err != nil {
return err
}
// 把订单信息存到redis中方便用户查询
cacheKey := getUserOrderCacheKey(user.ID)
redis.Hset(cacheKey, newInfo.ID, newInfo)
return nil
}
上述是一个简化后的创建订单函数,除了依赖于 userInfo 和 orderInfo,它还依赖某下游系统进行幂等检测,依赖于订单系统创建订单,需要向消息队列推消息,需要把数据缓存到 Redis 等等。如果简单地把依赖转成函数入参,比如:
func NewOrder(user UserInfo, order OrderInfo, idempotent IdemChecker, orderSystem OrderSystemSDK, mq KafKaPusher, redis Redis.Client) error {
// ...
}
上述函数签名就会非常复杂,调用方在调用函数前需要实例化很多对象。虽然测试方便了,但是在业务中调用却极为不便。并且更严重的是,如果后期要在代码中新增一些反欺诈和用户安全过滤等功能,这些功能都依赖于下游的微服务,难道还是每次改函数签名吗?这显然是不能接受的。因此我们要考虑第二种方法。
04
对象化
如果我们实现一个函数,那么函数能够使用的依赖要么通过参数传入,要么就是引用全局变量。如果依赖过多,通过参数传递是不现实的,那似乎就只能使用全局变量了吗?别忘了对象方法:
type Foo struct {
Name string
Age int
}
func (f *Foo) Bar(a,b,c int) string {
// f.Name
// f.Age
}
在对象方法中,虽然只有 a,b,c 3个入参,但实际上还有对象本身(在别的语言里的 this 或 self)可以被引用。而对象本身可以有无限多的成员变量,因此通过实现对象方法而不是函数,我们可以更加容易地添加依赖,比如:
type orderCreator struct {
checker IdemChecker
orderSystem OrderSystemSDK
kafka KafkaPusher
redis Redis.Client
}
func (self *orderCreator) NewOrder(user UserInfo, order OrderInfo) error {
// ...
}
通过把依赖放到对象内部,我们可以很方便地控制我们的依赖,在编写测试代码时自己根据需要编写一个构造函数即可:
func constructOrderCreator() *orderCreator {
return &orderCreator{
checker: newMockChecker(),
// ...
}
}
func TestNewOrder(t *testing.T) {
obj := constructOrderCreator()
obj.NewOrder(user, order)
}
这种方式其实也是抽离依赖的一种,只是把依赖抽离到对象中了而已,没有放到入参里面。它可以支持复杂的依赖关系,不管多少依赖,在结构定义中加项即可。缺点是实例化稍微比较麻烦,所以很少会每个请求的 handler 都实例化一次,通常是共享一个全局的对象,因此只会实例化一次(就避免了它的缺点),或者通过工厂模式来产生该对象。并且在写测试时,由于 Go 不是 RAII 的语言,我们可以偷懒只进行部分实例化。也就是说,如果我知道 obj.FuncA 只用到了 obj.X,那么我实例化 obj 时只实例化 obj.X 即可。
除了上述两种方式,还有一种很常见的方式,就是函数变量化。
05
函数变量化
我们先来看个例子:
import (
"repo/group/proj/log"
)
func add(ctx context.Context, a,b int) int {
c := a+b
log.InfoContextf(ctx, "a+b=%d", c)
return c
}
在业务代码中打日志随处可见,如果被测函数中包含了打日志语句的话,经常会遇到以下问题:
日志句柄没有实例化,引用空指针导致 panic;
日志默认打到文件系统上,产生大量垃圾文件
并且像上面例子中,log.InfoContextf 是 log 包提供的一个静态方法,log 是一个包而不是一个对象,因此我没办法把它作为一个子项放到对象中。针对这种场景,我们就要考虑函数变量化了。所谓函数变量化其实就是用一个变量来保存函数指针,比如:
import (
"domain/group/proj/log"
)
var (
infoContextf = log.InfoContextf
)
func add(ctx context.Context, a,b int) int {
c := a+b
infoContextf(ctx, "a+b=%d", c)
return c
}
我们用 infoContextf 来保存 log.InfoContextf 的函数指针,性能上看起来是多了一次内存寻址,但其实根本无关紧要。但是它带来的好处却是的巨大,因为我们在编写测试用例时就可以这样:
type logHandler func(context.Context, string, ...interface{})
// 用自己的实现替换函数指针
func replaceinfoContextf(f logHandler) func() {
old := infoContextf
infoContextf = f
return func() {
infoContextf = old
}
}
// 自己实现一个log函数,啥都不做
func logDiscard(_ context.Context, _ string, _...interface{}) {
return
}
func TestAdd(t *testing.T) {
// 测试前把infoContextf替换为logDiscard
resume := replaceinfoContextf(logDiscard)
// 测试结束后自动恢复
defer resume()
// do your testing
}
再也不需要担心日志没有初始化了,我们可以自己来 mock 日志处理函数!除了日志以外,其实还有很多这样的静态方法调用,我们都可以用变量来保存这些函数,比如:
// in bussiness file
var (
hostName = os.HostName
getNow = time.Now
openFile = os.Open
// ...
)
func NHoursLater(n int64) time.Time {
return getNow().Add(time.Duration(n)*time.Hour)
}
// in test file
func TestNHoursLater(t *testing.T) {
now := time.Now()
fiveHoursLater := now.Add(time.Duration(5)*time.Hour)
getNow = func() time.Time {
return now
}
assert_eq(NHoursLater(5), fiveHoursLater)
}
避免直接在函数内部调用静态方法,通过这些“函数指针变量”,我们可以在测试时方便地替换为自己的实现,屏蔽掉系统差异、时间差异等各种程序以外的因素,让测试代码每次都能跑在相同的环境下。
函数变量化其实就是我们常说的打桩。
06
总结一下
其实以上提到的一些编码技巧都不涉及到什么高深的设计模式,也不涉及到什么技术深度。它完全就是一些编程套路,但前提是你在编写业务代码得有写单测的意识,才能写出容易测试的业务代码。
总结一下就是简单的两条指导思想:
明确函数依赖(不管显示的和隐式的,它都是客观存在的依赖);
抽离出依赖(想办法让函数内部的依赖都可以从函数外部控制,和依赖注入很像)。
具体抽离方法:
对于依赖较少的函数,可以直接把依赖作为入参传递;
对于依赖较复杂的函数,把它写成某对象的方法,依赖都存储为该对象的成员变量;
函数内部不直接调用静态方法,用变量保存静态方法的函数指针(不要直接调,用变量做代理)。
记住这些要点,其实写出容易测试的业务代码真的很容易。
同时我们可以做一些测试套件的建设,因为大部分需要 mock 的对象都是通用的外部依赖,尤其是 MySQL Redis 等等,因此我们可以实现一些通用的 testsuite,方便我们来设置 mock 对象的行为,而不用每次都写很多代码来实现 mock 对象。比如:
mock mysql: https://github.com/DATA-DOG/go-sqlmock
testify/mock: https://github.com/stretchr/testify/tree/master/mock 编写mock对象的框架(maybe)
这些测试套件的建设越丰富,我们编写测试也会越容易(轮子团队加油啊)。
07
最后,尽量避免使用 init
其实 Go 还有一些额外的因素会影响我们写单测,那就是它的一个特性——init。init 在 Go 中其实是一个争议很大特性,很多人都反对用它,甚至有人向 Go2 提 proposal 想删掉 init(当然这是不现实的)。主要原因就是,如果一个包中有 init 函数,它会在 main 开始执行前就执行(也会在我们的单测函数运行前运行)。
这就带来一个问题,因为这些包的引入都是有副作用的,比如它们会到约定的地方读取配置文件,注册一些全局对象,或者尝试连接服务发现的 agent 来进行服务注册。如果哪一个环节有问题,那么框架层面就会认为初始化失败,很可能直接 panic。但是这其实会影响我们单测的运行。单测运行时不依赖真实环境,但是由于 init 的特性,如果真的某个 init 函数导致 panic,我们很可能都没办法跑单测。
另一个问题是,init 的执行顺序其实是和 import 顺序相关,这里面还有嵌套的逻辑。而且 gofmt 可能会重新调整 import 的顺序,某些时候可能会由于 init 执行顺序不一致而引入一些 bug,并且很难排查。框架如果经过严格测试,用 init 还可以,一般自己编写业务代码不要使用 init,宁愿自己写 InitXXX 然后在 main 函数中手动调用。
08
写到最后
单测思维常驻心中,遵照两个指导思想和三个解决思路,相信你也能非常便捷地写出良好的单元测试,coverage 90%+不是梦,分分钟拿下 epc 小王子的称号!
-End-
原创作者|刘德恩
你是如何编写可测试代码的?对可测试代码又有什么看法?欢迎评论分享。我们将选取1则优质的评论,送出腾讯Q哥公仔1个(见下图)。2月1日中午12点开奖。
分享抽龙年红包封面!!转发本篇文章就能随机获得以下封面 1 个!限量50个,周五中午12点开奖!
📢📢欢迎加入腾讯云开发者社群,享前沿资讯、大咖干货,找兴趣搭子,交同城好友,更有鹅厂招聘机会、限量周边好礼等你来~
(长按图片立即扫码)