测试基础 如何提高代码的可测试性 (Testability)

TesterHome小助手 · 2022年11月25日 · 3913 次阅读

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


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

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

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

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

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

  • 设计单纯
    若待测程序只专注做一件事,则它需要被测试的事情、情境也相对单纯,且可读性、表达力也会较佳。因此将程序设计得越单纯、简单、没有不必要的复杂(KISS 原则)越好。反之,越是复杂、不够单纯的程序就越难测试。复杂的程序例如过于冗长的 method,我的建议是 method 不可以过长,行数应限制在 30 行以内才算合格。

  • 容易初始化
    即在单元测试中可以很容易的初始化待测程序,换句话说,能轻易在测试中 new 出来。这种情况下,通常待测程序的依赖不多,或是能很轻易隔绝外部依赖。若难以初始化,则表示它可能是个设计较差的结构。容易初始化是撰写单元测试的第一步,如果连这点都很难做到,那就得不到写单元测试带来的好处了。

  • 输入参数可控
    这意味着我们在单元测试中能够轻易模拟出不同的测试场景。举个例子:某银行系统在客户提款超过 1 亿元时会对银行内发出警告通知。这时我们可以在单元测试中控制顾客的提款金额,以便模拟顾客大额提款的情境,而不必真的事先在银行账户准备一笔巨款。在 Java 的测试中,实操上会通过 mock 技术来模拟各种场景以提高程序的可控制程度。如果程序输入参数可控,就能被不断重复执行测试,接着加入 CI(Continuous Integration)的流程中,就能达到自动化测试的境界。

  • 输出结果容易被验证
    程序对于任何一项操作都要能产生预期的输出,而输出的表现形式通常是可预期的回传值、内部状态、外在行为,不管是以什么方式表现,至少都要有迹可循,才容易被验证。若能容易的验证程序结果是否符合预期,就能降低测试的难度,例如只用 assertEqual(), verify() 等简单验证方法就能得出测试结果。如果程序的输出结果不容易被验证,就代表不容易被测试。

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

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

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

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

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

  • Single Responsibility Principle (SRP)

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

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

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

  • 依赖注入 (Dependency Injection) 定义: 由外部提供待测程序所需的依赖,而待测程序不必自己建立它们。

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

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

  • 移除 Bad Smell 容易影响可测试性的 bad smell 有:Long Parameter List, Divergent Change, Long Method, Large Class…

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

  • YAGNI 原则
    工程师应该在面临确切的需求时,才去做相应的功能。例如某团队成员在 method 中增加了现在用不到、未来有可能用到的 if 分支,请问我们现在该不该测它呢?

  • Constructor 中不包含任何逻辑

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

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

public BankService(WithdrawService withdrawService, NotifyService notifyServic, ...) {
  this.withdrawService = withdrawService;
  this.notifyService = notifyService;
  ...}
  • 减少使用 Singleton / static

要在测试中替换一个 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 的行为,代价就是测试程序会变得比较复杂、执行测试的时间也会提高。

  • Test-Driven Development (TDD)

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

结论

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

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册