众所皆知,写单元测试有非常多好处,但有些主管会问,为什么写测试会让工程师额外花这么多时间?除了因为缺乏单元测试技术知识外,根本原因是产品程式码的可测试性太低,导致工程师在撰写测试时难以将精力放在正确的地方,甚至放弃撰写测试。要写出优秀的单元测试有一定的难度和门槛,关键在于如何提高程式码的可测试性。
什么是程式码的可测试性?
程式码的可测试性的定义有多种说法,比较着名的是微软的测试架构师 Dave Catlett 提出的 SOCK Model。虽然各家的定义有所不同,但概念大致相同,即:软体在给定的测试环境下,能够轻易进行测试的难易程度,或者说需要投入的测试成本多寡。
我们可以明显地看出,程式码的可测试性与设计品质高度相关。例如,低内聚高耦合、充满 code smell 的程式通常难以测试。因此,提高程式码的可测试性能让工程师撰写更有用、更有价值的测试案例,有助于在开发过程中快速发现并修正错误。
可测试性高的程式通常具备以下几个关键特质:
设计简洁
如果待测程式专注于执行单一任务,它需要被测试的内容和情境也相对简单。此外,可读性和表达能力也会更好。因此,我们应该追求 简洁、简单、不複杂(遵循 KISS 原则) 的程式设计。相反的,複杂且複杂度过高的程式难以测试。例如,过长的方法往往过于複杂,我建议将方法保持短小,行数限制在30行以内。
容易初始化
这意味着在单元测试中能够轻鬆初始化待测程式,也就是能够轻鬆地创建它的实例。这种情况下,待测程式的相依物件较少,或者能够轻鬆地隔离外部相依性。如果初始化困难,这可能表示设计结构较差。容易初始化是撰写单元测试的第一步,如果连这一点都难以实现,那么就无法享受撰写单元测试所带来的好处。
易于控制输入资料
这意味着我们在单元测试中能够轻鬆模拟不同的测试情境。举个例子,某银行系统在客户提款超过1亿元时会对银行内发出警告通知。在单元测试中,我们可以控制客户的提款金额,以模拟客户大额提款的情境,而不必事先在银行帐户準备一笔鉅款。在 Java 的测试中,通常会使用 mocking 技术来模拟各种情境,以提高程式的可控程度。如果程式能够被有效控制,就能够进行重複执行测试,并将其纳入 CI(持续整合)流程中,实现自动化测试的目标。
易于验证输出结果
程式对于任何操作都应该产生预期的输出。输出的形式通常是可预期的回传值、内部状态或外部行为。无论以何种方式表达,都应该具有可验证性,也就是有迹可循,这样才容易进行验证。如果能够轻鬆验证程式结果是否符合预期,就能降低测试的难度,例如仅使用 assertEqual()
、verify()
等简单的验证方法就能得出测试结果。如果程式的输出结果难以验证,就表示它不容易进行测试。
程式码可测试性的重要性
软体的可测试性高,通常意味着程式码的品质也高,这样製作出来的产品才会好用。
软体的可测试性高,有助于早期发现和预防问题。如果能越早发现问题,解决问题的成本就越低,我们就能更快地交付产品给客户,并快速获得客户的反馈,符合敏捷开发的原则。
如果软体的可测试性低,工程师就需要花费更多时间写测试,而这些测试可能会杂乱无章,效果不佳。如果撰写测试变成一种损失,工程师就不会愿意写测试,或者会拖延到很晚才开始写,这违反了测试左移的原则和单元测试的初衷。
如何提高程式码的可测试性
透过以下几种方法,可以提高程式码的可测试性,使得软体的品质更高,并且更容易维护。
Single Responsibility Principle (SRP)
单一职责意味着每个 class 应该有一个且只有一个职责(或被改变的理由),也意味着内聚力较高,这些都使得单元测试更容易。
若一个 class / method 同时实作了好几个功能,这会大大提升测试的难度,因为功能多,依赖就会变多,而且需要验证的结果变多,导致测试变得複杂。但实践 SRP 并不如想像中的简单,关键在于怎么控制颗粒度,若切太细会创造出冗余的 class;切得太大就变得不太单纯。所以要怎么适当的分配职责,还是得依实际情况而定。
以刚才某银行系统的例子,提款功能应属于 WithdrawService
的职责,发出内部警示功能应属于 NotifyService
的职责,两者各司其职、权责分明、彼此独立。
依赖注入 (Dependency Injection)
定义: 由外部提供待测物件所需的依赖,而待测物件不必自己建立它们。
这是一种可以大幅提升可测试性的重要手段,降低了物件之间的耦合度,也避免 new
关键字与重要的业务逻辑混杂在一起。藉由外部注入相依性物件,提升了待测程式的可控制性,我们就可以轻鬆的建立测试替身并注入到待测程式中。
一般而言,有3种 dependency injection pattern,而我建议使用 constructor 注入依赖模式。延伸阅读: 分析 Spring 的依赖注入模式
移除 Bad Smell
容易影响可测试性的 bad smell 有: Long Parameter List, Divergent Change, Long Method, Large Class...
解决方式之一就是团队要时常 code review,而且团队成员需要熟悉重构手法。如果不重构,除了难以测试外,久而久之就会变成技术债。
YAGNI 原则
工程师应该在面临确凿的需求时,才实作相应的功能。例如某团队成员在 method 中增加了 现在用不到、未来有可能用到 的 if 分支,请问我们现在该不该测它呢?
延伸阅读: 软体设计原则 YAGNI (You aren't gonna need it!)
Constructor 中不包含任何逻辑
Constructor 应该只专注于初始化,而不会有任何逻辑。若 constructor 不仅初始化、if-else,还呼叫 API、查询 DB,这就使得初始化成本提高,难以隔绝外部依赖,可测试性就降低了。
此外,在单元测试中想要改写或是 mock constructor 是不容易的,虽然现在有些测试框架可以替换 constructor 的行为,但通常不建议使用。延伸阅读: 不建议使用 PowerMock 的理由
一个好的 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 技术,就能大幅提高软体可测试性。
更重要的是,使用 TDD ,能让开发者更专注于产品的使用行为,而非程式的实作细节 (因为根本就还没有细节),因此更容易设计出强健的单元测试。一旦有强健的单元测试,产品随着时间不断演进,开发者将会更勇于重构,进而重构出更好的设计,好的设计都是不断重构而来的
结论
本文解释了程式码的可测试性及其重要性,并介绍了许多实务上可以提高可测试性的方法。若工程师觉得单元测试很难写,原因通常不是不会写测试,而是产品程式的可测试性太低。若整个团队的观念、基本功如果没有到位,也不懂如何提高程式的可测试性,则要在组织内推动单元测试是很困难的。提高产品程式的可测试性,较容易写出优秀的测试程式,接着才能享受自动化测试带来的甜美果实。References
本文转录自我的部落格 https://kaisheng714.github.io/articles/testability