该死的单元测试,写起来到底有多痛?

Java技术迷

共 5406字,需浏览 11分钟

 ·

2022-07-25 14:41

继上篇我离职的文章发布之后,很多小伙伴就“盯”上了我在前公司产出的单元测试,留言和私信让我出一篇单测到底该怎么写的文章。

于是,我就来啦!

话不多说,发车!

到底什么是单元测试

这个问题看似非常简单,单元测试嘛,不就是咱们开发自己写些测试类,来测试自己写的代码逻辑对不对。

这句话没有问题,但是不够准确。

首先我们要明白,这个测试二字前面还有两个字:单元

它要求我们的测试粒度,小

具体来说就是一个 Test 仅测试一个方法,对这句话的认识非常重要。

市面上常见的错误单测是怎样的呢:

把整个项目启动,开始玩真的调用,入参是数据库里面真的值,所有的操作都落库,一个 Test 从 controller 到 service 再到 dao,一条龙打通。

这种不叫单元测试,这叫集成测试

如果你现在写的是这样的“单测”,你就会发现,写个测试类不仅要依赖数据库,还要依赖缓存,依赖公司别的团队的服务,亦或是一些三方开放平台的 Http 服务。

当我们的测试类需要依赖太多太多外部因素的时候,只要有一个地方出现问题,你的测试就是 fail 的。

并且入参和出参不能“任你摆布”,你还得想着如何控制别的团队的服务返回你想要的数据。

比如我想测试当依赖的服务 A 返回 sucess 时,我的代码逻辑的正确性,还得想测试服务 A 返回 fail 的逻辑,还想测试它返回 null 的逻辑。

再包括数据库或者缓存的一些返回值的定制,这非常的困难,已经开始劝退人了。

然后把整个项目启动,这通常需要花费数分钟甚至数十分钟的时间,写两个单测一下午过去了,时间都花在调试的启动上了。

所以才会有那么多程序员觉得,单测好难写啊,又耗时,还动不动就 fail,写个 P。

所以回过头来看,到底什么是单测?

在 Java 中,单元测试的对象是类中的某个方法,一个 Test 只需要关心这个方法的逻辑正确性,仅仅测试这个方法的逻辑,不应该也不需要关注外部的逻辑。

举个例子,当你写 service 的单测时候,你压根就不应该测试 dao 或者外部服务返回的对不对,这是属于它们的逻辑,跟我 service 没有关系。

可能听着感觉不强烈,我拿代码举个例:

假设我们要测试trainingYes这个方法,可以看到方法内部依赖yesDaoOneOneZeroProvicer,一个是数据库,一个是 RPC 服务。

这时候我们的思维应该是:不管传入的 id 在数据库中对应的 yes 数据到底如何,我想让yesDao返回 null 的时候它就要返回 null ,想让它不为 null 就不为 null。

OneOneZeroProvicer也是一样,我想随意操控让它返回  false 或者 true。

因为数据库和外部服务的逻辑跟我当前的这个 service 方法没关系,我只需要拿到我应该拿到的值来测试我的方法内部的所有逻辑分支即可

只有这样,我们才能容易的测试到我们所写的代码逻辑。

你想想看,如果你要是测着trainingYes还得管着到底哪个 id 能拿到值啊,然后这个  yesDao#getYesById内部逻辑有没有状态过滤啊,这个 id 对应的数据有被废弃吗,需要关心这个那个,这就非常累了。

再或者你想关心OneOneZeroProvicer#call怎样才能返回 true,怎样才能返回 false,这就更难了,因为这是别的团队的服务,你连这个服务的代码权限都没,一个一个去问别人?

万一没这样的数据呢,还得去造?

总而言之,单元测试仅需要关注自己方法内部的逻辑,不需要关注依赖方。

看到这,很多同学就搞不懂了,那该怎么搞?我的代码就是依赖它们的服务了啊。

这就涉及到 mock 了

mock 指的是伪造一个假的依赖服务,替换真正的服务,在上面的例子中,需要伪造yesDaoOneOneZeroProvicer,我们操控它得到我们想要的返回值,满足我们自身对trainingYes的测试需求。

我拿yesDao举例一下,如下所示,我 mock 了一个假的 dao:

然后在单测时通过反射或者 set 注入的方式把 MockYesDao 注入到测试的 YesService 中, 这样一来,是不是就能控制逻辑了?

当我传入的 id 是 1 的时候,百分百拿到一个不是 null 的 yes 对象,当传入其他值的时候,肯定拿到的是  null,这样就非常容易控制我要测试的逻辑。

当然,上面仅仅只是举例说明 mock 的含义的具体作用方式,实际上真正单测的时候没有人会手动写 mock 服务,基本上用的都是 mock 框架

比如我用的就是 mockito,这个我们后面再提。

至此,你应该对如何写单测有点感觉了,我简单总结下上面说的几个小点:

  1. 单测不应该启动整个项目(包括 Spring 容器),没有这个必要,耗时长
  2. 单测不应该关心依赖的服务,包括 Dao、provider等其它服务,需要通过 mock 来解耦
  3. 一个测试方法只测当前要测试的一个类中的一个方法

其实就是分而治之的思想,本身在写代码的时候你已经为了降低复杂度和解耦,把代码分成了一个一个模块,一个个方法,而单元测试的目的,本就是验证这些你拆分的方法自身逻辑的正确性。

为什么单测这么难写

在对单测有点感觉之后,我们再来盘一盘为什么单测这么难写。

核心原因在于,我们本身写的代码不够解耦

看到这有人不服了,什么?单测难写还怪我本身写的代码不好,难写是因为本身的业务逻辑复杂!

好吧,这里需要强调一下,逻辑简单的类,其实没必要写单测,一般只是领导要求纯粹的追求覆盖率的时候,才会把这种简单的类补上去。

举个很简单的例子:studentService.getStudentById(Long id),我相信你都能脑补里面的逻辑,你要说你就想为这样的方法写单测,这当然可以,但是收益不大。

单测收益最高的就是针对那些复杂的场景,比方说在开发周期比较紧急的时候,核心的、容易出错的逻辑才是更应该去重视的地方(要是开发周期空闲,你要补哪都行)

回到单测难写的问题上,用专业术语来讲,就是你写的代码可测试性不高,导致难以编写对应的单测类。

怎样的代码是可测试性不高呢?我举个非常简单的例子:

假设你要给garbageMethod写个单测,是不是有点难?

里面用到了静态方法,又 new 了个service。

这静态方法我想让返回值等于 111,我只能去研究里面的逻辑。有人可能想不就是一个方法的逻辑吗,就看看呗。

那就看看:

可能你会说,这两分钟我就看明白了,但是这才一个,要是好多都需要看呢?

你为了测试当前的方法,且花了一堆时间去理解别的不需要测试的类的逻辑,这做法本身就不符合逻辑。

然后那个 noSevice 是 new 的,这如何控制它的返回值啊?我想 mock 这个类也替换不了啊!

所以,这样的代码就是可测试性低的代码,不好 mock (当然,mock 框架支持静态方法的 mock,不过new noSevice 不好弄,当然一般人都有不会这样写的,我只是为了举例)

还有各种类之间有继承关系的,这种测试难度都比较大。

就是上面的种种原因,导致我们的单测难以编写。

所以如果我们在设计接口的时候,先编写单测,我们写出来的代码其实可测试性就很高了,因为你完全晓得这样的写法会使得你单测很难进行下去,自然而然你写的代码就会往解耦的方向发展(比如上面的 noService 肯定会注入)。

我来列举下具体哪几种代码写法使得我们单测难以编写:

  1. 静态方法(不好mock替换注入,不过现在mock框架已支持)
  2. 内部直接 new ,强依赖,无法 mock 替换注入
  3. 继承类,测试当前类的方法逻辑,还需要关心父类逻辑和mock父类的服务(所以我们常说组合优于继承)
  4. 全局变量,这个应该好理解,好方法都公用,你改了值之后,会影响别的测试类,特别是并发执行测试类时,就傻了
  5. 时间等一些未决行为,代码里面有 new Date,逻辑是近 15 天可行,然后超过 15 天就跑不通了(当然可以通过动态计算时间)

这里我要强调下,我不是说上面的这几种代码不能写,这是不现实的,我只是列举说明这几种可能会使得你的单测不好写,当然第 2 点就是不能写的

写个单测例子

说了那么多,不如实战一下,我就拿  trainingYes来举例说明,这里引入 mockito 测试框架。

可以看到,通过注解 mock 了需要 mock 的 dao 和 provider ,然后将其注入到我们要测试的yesService中。

接下来就是具体的逻辑,根据场景我一共写了 4 个方法来测试:

里面的when(xxxx).thenReturn(xxx),就是我们指定的 mock 逻辑,这就是指哪打哪,随心所欲。

我们跑一下,你看就很快,59 ms,也不需要 Spring 框架。

就是通过这样的 mock 手段,忽略了依赖的服务的逻辑,使得我们要它怎样就怎样,便于我们单测类的编写。

至于具体的 mockito 的使用方式,这篇就不做展开了,网上看看应该简单的。

然后上面提到的静态方法的模拟,也简单的,我截个网上的例子:

上面的逻辑就是模拟静态方法 StaticUtils.name ,跟普通对象不同的是它用完之后需要 close 一下,所以用了 try-with-resource,当然也可以手动 close,原理也不做展开,有兴趣的小伙伴可以自己去了解下。

看到这,想必你对单测应该已经挺有感觉了吧?

道阻且长

知道了单测如何写和为什么难写之后,其实我们的思路已经清晰了,但是往往现实还是残酷的。

以前的老代码,巨多,领导要求补,难!

一个 service 依赖十几个服务,mock 都 mock 傻了,难!

项目太紧急了,从长远来看,单测的收益会使得整体开发和后期维护的时间短,但是领导就是要求下周一上线,难!

我个人认为一些稳定的代码,除非现在真的没事做了,完全没必要去补单测,完全可以在改动对应的点的时候再去补,然后新写的方法都要求上单测,这是非常合理的。

如果写业务的时候,同步写单测,会促进你的思考,缕清思路,写出的代码因为可测试性高,自然而然就比较漂亮和解耦。

还有一点也很重要,其实我们写单测的时候,不应该过多的关注内部的逻辑,举个非常简单的加法例子,我们单测只关心 add(1,1) 的结果是 2,我管你里面是的实现到底是位运算还是啥运算?

因为只有当我们的单测没有过度的关心内部实现时,之后方法的具体实现变更(从普通的 +,变成了位运算),我们的单测才不需要进行对应的修改。

但实际上这种情况对我们业务不太适用。

举个例子YesService之前依赖yesDao,现在这个 Dao 被剥离了,变成了另一个  RPC 服务,对应的我们之前所有的测试用例还是需要更改的,这是没办法的事情。

不过为什么我还要提一下这点呢?

比如你的测试方法里面有个xxxService.save逻辑,这个方法没有返回值,后面的逻辑也不依赖它,那么就不要想着在单测是时候写verify(xxxService.save(..));来验证这个方法是否被调用。

这样验证是否被调用其实意义不是很大,并且之后如果 xxxService 被移除了,单测就抛错了,因为里面没有调用xxxService.save,你还需要把这个单测给修复了。

这就是我所说的,写单测的时候,不要过度关注方法内部实现(有些需要mock的没办法)。

最后

好了,说了这么多,相信你对单测应该有所了解了吧?

最重要的还是对单测有个正确的认识,然后掌握 mock 的技巧,写新方法的时候,尝试设计完接口后,先写下单测,慢慢的你就会有感觉了,在写单测时,你自然而然的会考虑到诸多边界值的处理,你写的代码质量也会提高,渐渐地就会感受到单测的好处。

很多公司单测之所以推行不下去,就是因为没有一个很好的宣讲,或者说对单测的系统介绍。

我相信大家都是在一年中的某个月份,领导在会上突然来了一句话:我们接下来要写单测!下个月覆盖率要达到50%!

然后大家就吭哧吭哧开始写了,写么又是抄网上的一些例子,把整个项目一起,就进行集成测试了,然后写着写着,有人把数据库改了,跑的好好地单测就挂了。

要么就是写死数据,这个月单测是行的,下个月就挂了。

也没有人告诉你这单元测试写的不对,咱不是说写在 test 包里面的代码就叫单元测试。

一开始气势汹汹,后面虎头蛇尾,这就是绝大公司执行单测的真实写照。

领导很心痛,为什么就推不下去,大家都这么不积极,这么没有主人翁精神吗?

下属头痛加手痛,这tm啥玩意啊,是人写的吗?

就这样,每年的某个时刻,你的领导都会突发开始抓单测,然后持续几周或一个月,热情逐渐消退,最后无人问津,领导也假装不知道

如此往复,年复一年。

我们每天过的日子,好像也是如此?
  

1、相比高人气的Rust、Go,为何 Java、C 在工具层面进展缓慢?

2、让程序员早点下班的《技术写作指南》

3、互联网人为什么学不会摆烂

4、为什么国外JetBrains做 IDE 就可以养活自己,国内不行?区别在哪?

5、微软欲闭源VS Code的C#扩展惹众怒

6、上能写代码,下要“揍”黑客,还有什么不是程序员的“锅”?

点在看

浏览 9
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报