单元测试,只是测试吗?

单元测试,只是测试吗?
首先我就来回答一下标题提出的问题:单元测试除了是一种测试手段外,更是一种改善代码设计的工具,容易写单测的代码往往也具有更加良好的设计。

因而是任何自动化测试工具都无法取代的。

当然,这里也不是把自动化测试工具给一棍子打死,自动化测试工具也有自己的使用场景,比如测试遗留代码,做长链路测试等等。

这里需要强调一下 "工具" 属性,工具能放大人的智力或者体力,让干活的时候不会这么累,比如你去种树带把铲子,你肯定不会把铲子当成负担的,因为他是你种树的工具,你写 Java,肯定不会因为 IDEA 启动时间长,就把它当成一种负担,因为 IDEA 也是你写 Java 的一个工具,很多人把写单测当成一种负担,往往就是没有意识到"单测"是一种工具,单纯把他当成一种测试。

一 品味篇

在品味篇,一起看看什么样的代码才是易于单测的。

Mock 工具的使用——毒药还是解药

单元测试,只是测试吗?

你可能立刻就会产生和程序员小 A 类似的疑惑:"无论代码写成什么样,通过 Mockito 和 PowerMock 肯定都是能写出单测来?所以通过单测真的改善代码结构吗?"。

实际上,大量使用 Mock 工具的单测相当于买椟还珠,只具备测试的能力而无法帮助代码设计。

单元测试,只是测试吗?

以一段非常简单的程序为例,假设这是一个商店系统,里面有一个买面包的方法,里面会调用银行提供的信用卡服务 creditCardService 来扣除传入的信用卡的钱。这段程序如果使用 Mockito 的话,估计你很快就能写出测试了,只需要把 creditCardService 给 Mock 掉,然后验证它传入的参数就可以了。

如果总是像上面这样思考的话,单测对于你改善代码设计就没什么帮助了。我们在给代码写单测的时候不应该上来就思考用什么样的工具来测试代码,而是应该思考如何重构代码,才能让代码变得更加容易测试。

还是上面这段代码,我们换个角度,思考下如何重构代码,才能让这段逻辑不需要 mock 就能测试?

单元测试,只是测试吗?

单元测试,只是测试吗?

其实非常简单的一个办法是,返回一个计划,而不是立即就执行外部调用,比如这里我们可以抽象出一个 Payment 实体,表示从银行卡里划了多少钱,外部拿到 Payment 实体后再决定是立即把钱划掉,还是稍后把钱统一划掉。此时这一段逻辑不需要 Mock 就可以测试了,只要校验方法返回的 Payment 对象里面的属性是否正确即可。

到这里,你可能又有疑问了,“费了这么大事重构代码仅仅是为了好写单测,值得吗?”,如果你有这个疑惑的话,那你可能还是把单测仅仅当成测试了,我之所以要把代码重构的好写单测,是因为好写单测的代码还有其他诸多好处。

易于单测的代码仅仅是易于单测吗?

更多的性能优化机会

就上面重构的代码为例吧,因为业务层返回的都是 Payment 对象,我可以这些 Payment 聚合起来,最后统一执行,比如下图的这段代码,我就可以把 Payment 按照银行卡分组统一扣钱,这样就可以减少 rpc 调用的次数,以后如果有需要的话,甚至可以直接将 Payment 作为消息发出去,到另一个系统执行,业务层根本无需关心 Payment 最后是怎么执行,只需要在付款的时候生成一个 Payment 就可以了。

单元测试,只是测试吗?

更加健壮的核心代码

单元测试,只是测试吗?

另一个更大的好处是,好写单测的系统往往比不好写单测的系统更加健壮,如果一个系统大部分代码都可以写无 Mock 单测,那么它看起来就像左图一样,外部调用只是薄薄的一层,可以随意更换。

如果你的系统大部分代码都一定要 Mock 才能测试的话,或者根本无法测试的话,就像右图一样,说明你的业务根本就没有自己的核心逻辑,而是和各种外部调用缠绕在一起。

另外需要说明的是,图中红色的部分才是单测真正能够起作用的场景,因为它是比较稳定的业务逻辑,而且红色部分的单测也比较好些,只需要传几个参数进去,然后校验一下返回值就行了。灰色的外部调用部分理论上不写单测也无所谓,因为外部调用是不稳定的,即使你跟对方约定好了出入参数,他依旧有可能返回不符合约定的参数,或者直接就发生了网络错误,这一部分是集成测试发挥的场景。为什么在我们的系统里,大家都觉得单测没用,其实我也觉得单测对我们现在的系统没什么用,因为我们现在系统的主体代码就像右图一样,大部分都是灰色的外部调用,单测能够发挥作用的领域少之又少,即使写了覆盖率 80% 的测试用例,又能测出来啥?

这里要再补充一下,我上面所说的 “稳定” 的含义,我说红色部分的“业务核心代码”稳定并不是说业务一成不变,业务肯定是一直在变的,而是说它的逻辑不会收到外部系统错误的影响,不像灰色部分,外部系统一抖动可能就会出问题,因为灰色部分不适合单测。

Mock 工具的定位

刚刚喷了这么久 Mock 工具,那 Mock 工具真正的定位究竟是什么呢?

  • Mockito 是用来测试少量的不得不进行外部调用的代码。
  • PowerMock 是用来测试设计得不好的遗留代码的。

在 PowerMock 的文档中已经给出了警告,滥用它带来的坏处或许比好处更多,所以当我们写单测的时候不应该上来就想着用这些 Mock 工具,而是应该想想如何重构代码才能避开这些工具的使用。

PowerMock 官方文档的警告:

Putting it(PowerMock) in the hands of junior developers may cause more harm than good.

另外,我们再聊聊单测自动化生成工具,我们刚好也有澄沨在做,无论是哪种单测生成工具,你会发现工具生成的单测到处都是 Mockito 和 PowerMock,显然不符合单测的定位,但是这种工具也是有意义的,当系统里到处都是不好写单测的遗留代码时,用这个工具生成一下也能帮助我们覆盖一小部分测试,对于我们系统目前的情况还是很有必要的。

再来一个重构的例子

写有外部调用的静态方法:

单元测试,只是测试吗?

最后的结果:

单元测试,只是测试吗?

为了加深大家印象,这里再举个一个例子。比如下面这个方法,我在静态方法中调用先通过对 Business 的对象的各种处理,拿到了 rpc 调用的地址和版本号,然后使用这个地址和版本号加载一个初始化好的 hsf(阿里内部使用的 rpc 框架)泛化调用对象返回,这个方法的单测显然十分难写,因为 init 会发生网络调用,导致测试失败。这个时候我们要反思一下单测不好写的原因,是因为我们违背了一条编码的基本原则——“不能在静态方法中写外部调用”,如果你就是想在静态方法中进行外部调用,那应该怎么办呢?还是像之前的例子一样,返回一个计划,让外部调用,首先保持代码无副作用的部分不动,这一部分本来就没有外部调用,放在静态方法里执行也什么事情,然后把外部调用部分封到一个 Operator 里面(比如这里就是 RpcLoader)返回给上一层,上一层自己选择立即调用还是稍后调用。

这么做除了好写单测,还有什么好处呢?最显而易见的一点就是代码变得可复用了,更重要的一点是防腐,你会发现 hsf 影响范围被局限在 RpcLoader 里面,以前哪怕它的 API 出现什么变化,或者要换别的框架,都是件非常容易的事情。

为什么单测能够验证代码结构的合理性

单元测试,只是测试吗?

前面我提到的这些关于代码结构的概念听起来是不是非常耳熟,在别的领域也经常听到,比如面向对象中的“高内聚,低耦合”,DDD 中所提到的“核心域”,“防腐层”,函数式编程所倡导的“隔离副作用”,你会发现,好的编程范式倡导的东西都是类似的。

上面这三种评价代码的方式其实都是比较“主观”的,什么样的代码才能叫“高内聚”,在每个人看来可能都不一样。但是对于是否易于写单测,大家的标准基本是一样的,难写单测的系统给谁都很难写。而好写单测的代码一般都满足编程范式所倡导的原则,所以写单测的难易程度可以作为一个非常客观的代码质量评价指标。

如果有人跟你说他这段代码设计得非常好,但是就是不好写单元测试,千万不要相信他。

另外再提一下设计模式,如果只是照着书抄抄代码,设计模式是非常简单的,关键是要用对场景,一不小心就会只学到了“形”,而没有学到“神”,“形神兼备”的设计模式往往会让代码变得更加容易测试,如果用了设计模式发现系统变得更难测试了,那设计模式十有八九用得不对。

单元测试,只是测试吗?

如果有个程序员跟你说我程序的性能达到了多少 QPS,你肯定会立马拿起测试工具就去测,看到底能不能到达这个 QPS。但是如果有程序员画了框框图说他的代码分成了 A B C 模块,要怎么验证他的代码真的分成了这几个模块呢?很简单,你看看每一个模块能否脱离其他模块单独测试就可以了,如果单独测试非常困难,那就说明模块并没有真的分开,而是或多或少耦合在了一起。

易于单测的等级

现在我们可以总结易于单测的几等级了。和别的领域不太一样,别的领域你高级的工具用得越多,可能越厉害,但是在单测这个领域,使用越多的高级工具,反而是更加糟糕的测试。

另外,对这些规则也不要死脑筋,这些只适合业务含义比较丰富的代码,如果你就是在写一些封装外部调用的代码,这部分代码我觉得不写单测也是可行的。

  • 第一级,易于单测:大部分代码不需要 Mock 就可以测试,少量的外部调用代码需要 Mockito。
  • 第二级,能够单测:超过一半的代码需要 Mock 才能测试,但是这些测试也不是特别难写。
  • 第三级,难以单测:大量 Mock,甚至大量使用了 PowerMock。
  • 第四级,无法单测:模块被设计的及其复杂,连开发者自己都无法理解,更无法写单测。

二 实践篇

在上一篇学习了关于单测的正确观念后,这一篇再来聊一聊关于单测的最佳实践。

单元测试的运行速度重要吗?

很多人会觉得单测反正也不是系统中的代码,运行的快慢无所谓,然后写出很多其慢无比的单测,以至于系统全量跑一次单测要几十分钟。这样的话就完全偏离了单测的定位,单测的目的就是为了方便快速迭代,改了两行代码就可以在本地用 30 秒到几分钟的时间全量跑一次单测来确定影响范围,而不是每次都要通读系统源码才能知道改动的影响范围,这样新人很快就可以大胆改代码了,而不是先花几个月通读系统源码,或者先踩好几个坑,才能上手干活。那些全量跑单测要几十分钟的系统,他的开发者根本就不会在本地全量运行单测,每次都在 aone 上跑半天才知道单测不过,这样的单测就形同虚设了。

违背这个原则的典型反例,就是在单测中启动 Spring。

数据驱动测试(Data Driven Test)

单元测试,只是测试吗?

不好的单元测试常常只用一组正常测试数据进行测试,实际上我们应该使用多组数据,包括正常和异常数据,输入模块,看返回值是否符合预期。使用多组测试数据是否就意味着多写很多代码呢?并不是,我们只要注意将测试用例的逻辑与数据分离就可以,测试代码依次读取测试数据,校验其是否符合预期。这样的逻辑与数据分离的测试一般称做 “数据驱动测试”,常见的单元测试框架都会提供这种支持。

"数据驱动测试" 的概念还是太抽象了,这里我们看两段代码,左图未分离数据与用例,右图则做了分离,能够看出很明显的不同,右图是基于 Spock 单元测试框架来写的,不熟悉的人看上去可能比较奇怪,可以把 where 标签下的代码看成一张表格,每一行都是一组测试数据,Spock 框架会将其依次代入 testAdd 方法参数进行测试。

单元测试,只是测试吗?

单元测试,只是测试吗?

大家所熟悉的 junit 框架也是可以做的,但是需要写一个额外的内部类,加上 @RunWith(Parameterized.class),写一个 data 静态方法,然后返回需要测试的数据组,然后 junit 就会依次将数据填入这个类的属性中,运行这个类中的全部测试用例。

单元测试,只是测试吗?

单元测试,只是测试吗?

如何测试私有方法

大家写单测时常有的一个困惑就是私有方法怎么测试?虽然理论上私有方法不需要写单测,但是有些私有方法逻辑比较复杂,还是值得单独写测试的,目前公认比较好的实践就是将修饰符从 private 改成 protected, 这也是很多开源项目给单测留口子的方法。如果你的项目刚好有引入 guava 的话,可以再给方法加上一个 @VisibleForTesting 的注解,表示仅仅是出于单元测试需要修改的修饰符。

一个典型的例子:

单元测试,只是测试吗?

三 TDD 与 BDD

最后一篇来讲一两个大家可能经常听说过的理念,TDD 和 BDD。个人觉得这两个理念都比较极端,实际中很难应用,启发意义大于其实用意义,所以放在最后,希望能带来一些启发。

TDD

单元测试,只是测试吗?

TDD 强调让写代码的过程形成一个循环,第一步是为你要做的功能写一个单元测试,跑一下发现没有通过(毕竟你还没有实现代码),即图中的 TEST FAILS,俗称“红灯”,之后编写能够通过全部测试的“最小代码”,之所以强调“最小代码”,就是为了防止过度优化,现实中我们经常会因为代码过度优化,或者过度设计,导致很多遗留问题,在这个阶段,只管用最快最脏的代码实现就好了,不用管太多设计问题。这个阶段俗称“绿灯”。

最重要的就是下面的“重构”(REFACTOR)阶段了,前面的代码虽然可能很脏,但是至少是正确,也有足够的测试来保障逻辑的正确,这个时候就可以大刀阔斧地重构代码了,保证代码继续保持最优。

这启发我们两点:

  • 单测必须能够快速运行,因为单测是经常要在本地全量运行的,只有运行足够快,才能在 TDD 的循环中快速迭代。
  • 好的代码并不是一次性就设计出来的,而是持续重构出来,而单测是持续重构的前提。

BDD

我常常抱怨产品经理在提需求时没有想清楚,比如下图,如果让产品经理也可以写出可执行的测试用例的话,情况想必会好很多。BDD 就是这么一个想法。

单元测试,只是测试吗?

不知道大家有没有在有的项目里见过 .story 文件,它本质上就是一种集成测试脚本,只不过是用自然语言描述,它包含叙述,场景和步骤三部分,比如上图就是一个书店管理应用的 .story 文件,文件中叙述(Narrative) 和 场景(Scenario) 只是帮助思考的,本身并包含在测试用例的逻辑中,测试用例主要由 Given, When 和 Then 开头的语句组成,含义如下:

单元测试,只是测试吗?

story 文件自己当然是无法执行的,需要框架提供支持,JBehave 就是这么一种框架(右图),能够定义各种 Given,When,Then 语句的实现,下图的代码本质上就是个基于 Selenide 的自动化界面点击测试,它支撑 story 文件的执行。我们以这个 story 文件为依据,就可以像 TDD 循环一样,先测试不通过(红灯),然后用最小的代码让测试通过(绿灯),最后重构代码。只不过这个循环可能会耗时好好几天,乃至几个星期。而 TDD 一个循环可能只需要几个小时,所以说 BDD 是集成测试版的 TDD。

单元测试,只是测试吗?

单元测试,只是测试吗?

敏捷

我们往往会觉得 TDD 和 BDD 会严重拖慢迭代速度,值得讽刺的是,TDD 和 BDD 恰恰是敏捷开发实践的重要组成部分:

单元测试,只是测试吗?

我们学习敏捷开发的时候,常常只学习到它的 “快”,而忽略了敏捷开发所提出的质量保证方法。敏捷开发所谓的“快”,是指在代码质量充分保证下的“快”,而不是做完功能就直接上线。

四 如何学习写单测

学习单测的关键还是多实践,多看看别人好的单测怎么写。比如可以给一些公认代码优秀的开源项目提交代码。

五 总结

  • 单测能够帮助我们验证代码设计的合理性。
  • 含有核心业务的代码应该首先思考如何让主体业务逻辑可以写无 Mock 单测。
  • 用例数据尽量和测试逻辑分离。

参考资料

[1]Test-Driven Java Development
https://www.oreilly.com/library/view/test-driven-java-development/9781783987429/
[2]Wiki Agile software development
https://en.wikipedia.org/wiki/Agile_software_development
[3]PowerMock
https://powermock.github.io/
[4]JBehave
https://jbehave.org/
[5]Spock
http://spockframework.org/
[6]JUnit
https://junit.org/junit4/
[7]Learning to Love TDD
https://medium.com/swlh/learning-to-love-tdd-f8eb60739a69

上一篇:开源 | 如何实现一个iOS AOP框架?


下一篇:你相亲成功的几率有多高?机器学习硬核预测