整理:TesterHome
作者:Kai-Sheng(台湾)


众所周知,写单元测试有非常多的好处,但有些主管会问,为什么写测试会让工程师额外花这么多时间?除了本身对单元测试技术不熟悉以外,追根究底是因为产品代码的可测试性太低,导致工程师写测试时很难将精力投入在对的地方,甚至导致放弃写测试。要写出优秀的单元测试有一定的难度与门槛,关键就在于如何提高代码的可测试性。本文介绍提高 Java 单元测试可测试性的经验。

一、什么是代码的可测试性?

代码的可测试性的定义有多种说法,比较著名的微软测试架构师 Dave Catlett 提出的 SOCK Model。虽然各家定义不同,但概念几乎都围绕在同一件事,就是指一个软件在一定的测试环境下,能够被测试的难易程度,或是投入测试成本的多少。

我们不难察觉到程序的可测试性与设计品质是高度相关的,例如低内聚高耦合、代码异味(code smell)的程序通常都难以测试,因此若能提高可测试性,让工程师们能撰写更多有用、高价值的 test case,就容易在开发过程中快速发现并消除 bug。

可测试性高的程序通常具备几个关键特点:

二、代码可测试性的重要在哪?

软件可测试性高,通常就意味着代码的品质也高,做出来的产品才会好用。

软件可测试性高,就容易测试左移。测试左移本质上是要尽早发现、预防问题。若能越早发现问题,解决问题的成本就越便宜,我们就能更快速交付产品、更快速得到客户的反馈,更符合敏捷开发的精神。

若软件可测试性低,工程师就要花很多时间写测试,通常在这种情况所写的单元测试也会是一团乱,不仅测试效果不佳,也不容易测到关键、容易 test fail,导致测试品质低落,例如在测试中 mock 过多依赖,会让测试变得很复杂,而测试程序也是需要注重品质的。若写测试像是一门赔本生意,就会让工程师不愿意写测试,或者拖到很晚才开始写测试,这些都违背了测试左移的原则以及单元测试的初衷。

三、如何提高代码的可测试性

单一职责意味着每个 class 应该有一个且只有一个职责(或被改变的理由),也意味着内聚力较高,这些都使得单元测试更容易。

若一个 class / method 同时运作了好几个功能,这会大大提升测试的难度,因为功能多,依赖就会变多,而且需要验证的结果变多,导致测试变得复杂。但实践 SRP 并不如想像中的简单,关键在于怎么控制颗粒度,若切太细会创造出冗余的 class;切得太大就变得不太单纯。所以要怎么适当分配职责,还是得依据实际情况而定。

以刚才某银行系统的例子,提款功能应属于 WithdrawService 的职责,发出内部警示功能应属于 NotifyService 的职责,两者各司其职、权责分明、彼此独立。

这是一种可以大幅提升可测试性的重要手段,降低了程序之间的耦合度,也避免 new 关键字与重要的业务逻辑混杂在一起。借由外部注入相依性程序,提升了待测程序的可控制性,我们就可以轻松的建立测试替身并注入到待测程序中。

一般而言,有 3 种 dependency injection pattern,而我建议使用通过 constructor 注入依赖的模式。

解决方式之一就是团队要时常 code review,而且团队成员需要熟悉重构手法。如果不重构,除了难以测试外,久而久之就会变成技术债。

Constructor 应该只专注于初始化,而不会有任何逻辑。若 constructor 不仅初始化、if-else,还呼叫 API、查询 DB,这就使得初始化成本提高,难以隔绝外部依赖,可测试性就降低了。

此外,在单元测试中想要改写或是 mock constructor 是不容易的,虽然现在有些测试框架可以替换 constructor 的行为,但通常不建议使用。
一个好的 constructor 应该是没有任何逻辑,只做依赖注入与状态初始化:

public BankService(WithdrawService withdrawService, NotifyService notifyServic, ...) {
  this.withdrawService = withdrawService;
  this.notifyService = notifyService;
  ...}

要在测试中替换一个 static method 是困难的。此外,滥用 Singleton Pattern 容易产生难以维护的 global state。例如:

DbManager.getConnection().doSomething();Calendar.getInstance();

虽然 Singleton / static 很方便,但它无形之中也带来了提高耦合度的问题,这两者都是造成不好测的原因,它有可能让我们难以立即发现问题,毕竟单元测试的本质就是探讨如何隔离外部依赖。有时候要完全避免使用 static 方法可能挺难的,如果可以,那就尽量减少使用频率。

不过,如果是 Strings.isBlank(), Math.abs() 这种简单、内部没有共享状态、没有与外部环境相依的 static method,我认为并不会造成难以测试的问题。

但若是真的不得已,Mockito 3.4 版也提供了 Mockito.mockStatic,让我们可以在单元测试中替换 static 的行为,代价就是测试程序会变得比较复杂、执行测试的时间也会提高。

TDD 是一种先从使用者角度写测试,再回头撰写产品代码的开发手法。因为 TDD 让开发者换位思考,从使用者的角度出发,换个位子就换了脑袋,就更容易了解到该怎么设计才能让 class / API 更易用。为了先写出测试,开发者就必须先去思考如何进行测试,他不仅了解需求,还要逐步拆解需求成一个个单纯、小的 test case。若熟练 TDD 技术,就能大幅提高软件可测试性。

结论

本文解释了代码的可测试性及其重要性,并介绍了许多实操上可以提高可测试性的方法。
若工程师觉得单元测试很难写,原因通常不是不会写测试,而是产品程序的可测试性太低。
若整个团队的观念、基本功如果没有到位,也不懂如何提高程序的可测试性,则要在组织内推动单元测试是很困难的。
提高产品程序的可测试性,较容易写出优秀的测试程序,接着才能享受自动化测试带来的甜美果实。


↙↙↙阅读原文可查看相关链接,并与作者交流