首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

如何编写可测试的代码:两个核心三个思路

导读

在需要长期迭代的项目中编写单元测试,已经在各个团队中逐渐成为一种虚伪的共识。虽然嘴上都说好,但身体很诚实。

目录

1 把大象放进冰箱

2?纯函数

3?抽离依赖

4?对象化

5?函数变量化

6?总结一下

7?最后,尽量避免使用 init

8?写到最后

在需要长期迭代的项目中编写单元测试,已经在各个团队中逐渐成为一种虚伪的共识。虽然嘴上都说好,但身体很诚实。毕竟编写单元测试需要在实现业务功能以外付出额外的精力和时间,所以很多人把它视为是一种沉重的工作负担。造成这种认知的本质问题主要有两点,除了在意识上没有真正认同单元测试的价值外,更多的还是因为实践中发现编写单元测试太耗时,经常要花费很多时间去设计测试用例,而且为了让被测函数跑起来,需要花费大量时间去为它创建运行环境,初始化变量,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 filevar ( 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 filefunc 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 小王子的称号!

  • 发表于:
  • 原文链接https://page.om.qq.com/page/OeT9GuCjTdAVk66Qsu6Kgjiw0
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券
http://www.vxiaotou.com