解析 Golang 测试(10)- 什么是好的单测

时间:2022-9-2     作者:smarteng     分类: Go语言


前面 9 讲我们谈论了很多工具层面的问题,什么是 mock,fake,stub,断言工具有哪些,官方提供了哪些支持等等。有了这些,我们写单测时会更加方便。但并不一定能写出好的单测。甚至可以说,二者的关联极小。

试想一下,回到中学时代,你给一个学生非常全面的文具,课本,教科书,他就一定能学习好么?并不。想做对一件事情,除了要在工具层面下功夫,更重要的是思路要清晰,同时能克服自己的惰性,对自己提出更高的要求。

笔者近期在团队内部观察单测落地的情况就深有感触,所以写下这篇文章,这次我们不谈工具,从方法,思路层面聊聊怎么写好单测。

落地之难

最近观察到一个很有意思的现象。组里有统一的 Golang 代码和单测规范,大家平常说起来也是头头是道,goconvey,testify 用的非常熟练。但一看代码,还是简单的 assert.NoError 之后就结束,甚至连返回的业务对象都没有赋值,只留下了一个 _ ,很无奈。

这其实是非常不好的。我之前提过,好的单测 > 没有单测 > 没起到作用的单测。

如果你没写,至少你自己,以及团队内部其他人是知道这一点的,大家可能随后补上。但一旦你写了,很多时候甚至看起来考虑了不少情况,大家本能地就会认为这个单测是没问题的,而实际上它只是判定了最基础的【异常】case,业务逻辑的错误什么都没看,这对项目的质量是极大的伤害。长此以来,单测的作用何在?能给你带来代码的信心么?

说起理论头头是道,落地到代码上总是很难。没时间,业务分支很复杂想偷懒,骗覆盖率,此间的原因有很多。作为 owner 站在全局视角,是需要帮助团队内部的工程师们,解决这些形式问题,思考如何真正让单测起到作用。

好的单测

想写出好的单测,跟工具其实是没有绑定关系的。你可以完全不依赖任何外部 mock,断言工具的情况下,只靠标准库的能力来写出高质量的单测,只是可能要多花一些时间,代码多一些罢了。

我们怎么来评估一个单测的质量呢?有以下几点,我们依次来讨论。

自动化

不要依赖 t.Logf 打印 log 来帮助你评估 SUT 是否符合预期。要让你的测试全自动,我们只需要触发单测的执行即可,做好断言,而不是人工判断,让你的测试代码自动告诉你当前的 SUT 是否正常。

事实上虽然我们开发的时候也会经常手动触发单测运行,但从一个公共仓库,团队协作的层面看,更多时候单测是在一些 CICD 等自动化流程触发的,那个时候不可能让人工来介入评估结果。

不能自动化的单测,等价于没有单测。你自己在本地一开始想怎么搞都ok,这是 dev 阶段的个人行为。但到了最后,一定要保证单测可自动评估结果,并且去掉打印语句。

独立性

单测的执行不要互相影响,一个测试挂了不能连带着其他都挂,不要依赖其他测试里做的一些操作作为前置依赖。每个测试,都需要能自己独立地运行出来。如果有一些公共的前置初始化或后置清理逻辑,可以考虑放到 TestMain 里面。

可重复运行

单测是可以,且必须要做到无副作用的。不能执行一次之后就把数据改坏,无法还原。执行读操作通常是不会有影响的。如果带有写,记得一定要清理数据,把存储(哪怕是 fake的,只要是公用的都需要)还原到一开始的状态。有 create 就需要有 delete,如果 update 了,最后需要 revert,清理逻辑必须配套。搞不定的话,就不要依赖实际的存储,思考怎么从 mock 上解决问题。

业务断言

这一点非常重要,只看 err 在很多时候是一件偷懒的事情,一定要对业务返回值做断言,校验是否和你的设计,你的预期匹配。需要考虑三个方面:

  • 正常路径:不一定只有一条,可能有多个,根据你的业务设计来逐个路径校验;

  • 边界路径:有时候我们的设计文档,case 不一定覆盖那么全,可能几率很低,但事实上一旦你的用户量足够大,任何小概率事情都必然发生,边界的场景尽量也需要覆盖到;

  • 异常路径:如果报错,是否符合预期,处理流程是否符合预期。哪怕是最简单的判 err,很多时候我们也不是简单依赖一个 Error() string 字符串来表达错误,而是很可能封装成一个业务 error,这时候是否合理,也需要判断。

本地快速运行

很多人会忽视这一点,但事实上,不能快速运行的代码通常是有问题的。我们要求代码不能有外部依赖,只依赖当前本机的能力,有外部依赖就 mock 掉。

有的同学改的不彻底,虽然用了 sqlite 或者 miniredis 来做 mock,底层还是会依赖一些服务发现的组件,导致明明不需要连线上或测试环境的存储,还初始化了一堆东西,非常耗时。

这一点一定要做彻底,毫秒级解决问题。让你的单测飞速运行起来,你才有可能,有兴致,在开发阶段就快速运行,快速验证,多次改你的单测代码。

笔者遇到过有一些单测,只是简单的两三个 test 函数,从运行到执行完毕,需要花几十秒,事实上大部分时间都花在了初始化各种组件,对测试本身是没有什么用处的,这无疑是可悲的。

做事做彻底,no half measure,让你的单测提速,如果有一些 package 级别的 init 不合理,或者依赖没有本地化,就去改,去修。不要对现状太容易妥协。

可维护

很多人会忽视这一点,事实上,如果你是个能做到上面几点的同学,一定会发现,你的单测越来越复杂,这几乎是一定的。为什么?

因为你需要构造各种场景,table driven test 中的这个 table 会很大。你也需要针对 error,针对边界值,异常值处理。

你会发现这些静态数据就已经占用很大篇幅了,如果不仔细思考,优化一些配置,你的代码会很难读。比如静态数据是否可以放到单独的测试数据文件里,是否可以搞一个 test util 包,优化一些常见场景的校验?

记住,测试也是代码,也是交付的一部分,没有测试的交付,势必会带来无穷的历史遗留问题。单测也需要认真对待。

它的可读性,分层结构,mock,断言的风格都需要认真思考怎样统一。这一点是因人而异的,但一定要重视。

评估起来很简单,你的单测,在满足我们上面这些要求之后,当交接给其他同学看的时候,他们能否快速理解,能否上手新增别的场景的测试,别人是否愿意看你的单测代码?

覆盖率毫无意义

坦率地讲,覆盖率是一个大家没办法要求,没办法让每个人都做到高标准的背景下的无奈的,量化的,KPI 之举。

没有我们上面的标准,你每个 test 都只判 error,甚至一条断言都没有,只打印个日志,那也叫单测,也能骗过覆盖率检测。但这毫无意义。

当你的系统有了 100% 的覆盖率,仔细一看全是模糊的,做了一半的,甚至不是自动的单测,这个系统出任何问题我们都不会感到惊讶。

还是那句话,世上最可怕的事情叫做【虚假繁荣】。我们不怕穷,不怕很多东西没有,怕的是明明没有,还营造出来大批的假象,仿佛我们有。

这个假象,骗了 leader,骗了组织,甚至骗了一线的很多工程师们,但骗不了 bug,骗不了那些被遗忘的边界case。

It will somehow come back to you, in the near future.

但是,我充分理解,组织不可能一夜之间让一线同学们变成测试高手,我们上面说的原则很难用工具来衡量,而是要求工程师有对自己的要求,有对系统的了解,有时间。

而这些,管理者是很难去看的。所以,开始推覆盖率,既然别的我们不好看到,那就整一个量化指标。覆盖率 70%,80%,90%,这是无奈之举。

但如果正在阅读这篇文章的你希望推进覆盖率,请记住,做到了,也什么都不是。如果你的单测没有足够的有效性,只是表面上有一个配套的测试函数,这个覆盖率,是很有欺骗性的。

可以推,没问题,但一定记住这是无奈之举,它没法帮助你提高信息,只能美化你的汇报内容。作为一个负责任的 leader,一定要懂变通,掌握好这个度。并花精力在如何让团队的单测达到我们上面说的这几条要求,而不是吹嘘我们组覆盖率到 xxx 了。这一点很重要。

小结

今天我们讨论了什么是好的单测,以及落地中常见的问题。坦率地讲,写好并不容易,希望大家都能记住。单测也是正常的代码,可读性,真实覆盖率,独立性,自动化都很重要。做不到的话,请不要提交你的单测代码到主干分支。一个没有单测的代码,好过虚假繁荣的代码。

标签: 测试