查看原文
其他

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

刘德恩 腾讯云开发者 2024-02-02



👉导读

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

👉目录

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



把大象放进冰箱


把大象装进冰箱有几个步骤?

  1. 打开冰箱门;

  2. 把大象塞进去;

  3. 关上冰箱门。


当然这只是个笑话,开关门倒是简单,但是把大象塞进去哪有那么简单。然而,如果在写业务代码时有意识地稍微考虑一下可测试性,那么写单元测倒是真的是一件挺容易的事情,主要就两步:

  • 设置好所有入参的值;

  • 判断输出的值是否如预期。


这两个步骤非常直观也很容易理解,但是实际中为啥单测写起来那么复杂呢?




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 小王子的称号!


-End-

原创作者|刘德恩


  


你是如何编写可测试代码的?对可测试代码又有什么看法?欢迎评论分享。我们将选取1则优质的评论,送出腾讯Q哥公仔1个(见下图)。2月1日中午12点开奖。


分享抽龙年红包封面!!转发本篇文章就能随机获得以下封面 1 个!限量50个,周五中午12点开奖!

参与方式:
1、分享本篇文章到朋友圈,并截图。
2、到腾讯云开发者公众号后台回复“0125”,经核验截图后,即可随机抽取以下 1 款红包封面!



📢📢欢迎加入腾讯云开发者社群,享前沿资讯、大咖干货,找兴趣搭子,交同城好友,更有鹅厂招聘机会、限量周边好礼等你来~


(长按图片立即扫码)







继续滑动看下一个

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

刘德恩 腾讯云开发者
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存