其实之前的工作中强调过很多次自己做测试的重要性,例如讲单元测试的:【C#编程最佳实践 一】单元测试实践,讲单元测试规范的【阿里巴巴Java编程规范学习 四】Java质量安全规约,讲接口测试的:【C#编程最佳实践 十三】接口测试实践,这里旧事重提就不再详细展开了,回顾下单元测试的基本概念,重点来看如何提升代码的可测试性。
单元测试
依次从WHAT,WHY,HOW,HOW去了解。
什么是单元测试
单元测试由RD自己来编写,用来测试自己写的代码的正确性。它与集成测试的区别是测试粒度
- 集成测试的测试对象是整个系统或者某个功能模块,比如测试用户注册、登录功能是否正常,是一种端到端(end to end)的测试。
- 单元测试的测试对象是类或者函数,用来测试一个类和函数是否都按照预期的逻辑执行。这是代码层级的测试
单元测试相对于集成测试(Integration Testing)来说,测试的粒度更小一些。
写单元测试的好处
单元测试除了能有效地为重构保驾护航之外,也是保证代码质量最有效的两个手段之一(另一个是 Code Review),写单元测试有如下好处:
- 写单元测试能有效地发现代码中的 bug,通过写单元测试可以节省很多 fix 低级 bug 的时间,能够有时间去做其他更有意义的事情。
- 写单元测试能发现代码设计上的问题,代码的可测试性是评判代码质量的一个重要标准。对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很吃力,需要依靠单元测试框架里很高级的特性才能完成,那往往就意味着代码设计得不够合理,比如,没有使用依赖注入、大量使用静态函数、全局变量、代码高度耦合等
- 单元测试是对集成测试的有力补充,程序运行的 bug 往往出现在一些边界条件、异常情况下,比如,除数未判空、网络超时。而大部分异常情况都比较难在测试环境中模拟
- 写单元测试的过程本身就是代码重构的过程,持续重构应该作为开发的一部分来执行,写单元测试实际上就是落地执行持续重构的一个有效途径。设计和实现代码的时候,我们很难把所有的问题都想清楚编写单元测试就相当于对代码的一次自我 Code Review,在这个过程中,可以发现一些设计上的问题(比如代码设计的不可测试)以及代码编写方面的问题(比如一些边界条件处理不当)等,然后针对性的进行重构
- 阅读单元测试能帮助我们快速熟悉代码,文档结合单元测试,我们不需要深入的阅读代码,便能知道代码实现了什么功能,有哪些特殊情况需要考虑,有哪些边界条件需要处理。不需要深入的阅读代码,便能知道代码实现了什么功能,有哪些特殊情况需要考虑,有哪些边界条件需要处理。
- 单元测试是 TDD 可落地执行的改进方案,测试驱动开发(Test-Driven Development,简称 TDD)是一个经常被提及但很少被执行的开发模式。它的核心指导思想就是测试用例先于代码编写,但很难落地,不如先写代码,紧接着写单元测试,最后根据单元测试反馈出来问题,再回过头去重构代码,变相落实TDD,测试驱动重构与FIX
其实单元测试就是对代码设计和功能逻辑的一次反思,粗粒度的CR,结合CR,推动和保障持续重构。
如何编写单元测试
物理上可以借助Java 中比较出名的单元测试框架有 Junit、TestNG、Spring Test 等。这些框架提供了通用的执行流程(比如执行测试用例的 TestCaseRunner)和工具类库(比如各种 Assert 判断函数),而主观经验上要有这样的意识
- 编写单元测试尽管繁琐,但并不是太耗时,不同测试用例之间的代码差别可能并不是很大,简单 copy-paste 改改就行
- 可以稍微放低对单元测试代码质量的要求,命名稍微有些不规范,代码稍微有些重复,也都是没有问题的,但代码规范意识要时刻有,要达到想降低要求都降不了的水平
- 覆盖率作为衡量单元测试质量的重要但不是唯一标准,更重要的是要看测试用例是否覆盖了所有可能的情况
- 单元测试不要依赖被测试函数的具体实现逻辑,它只关心被测函数实现了什么功能。切不可为了追求覆盖率,逐行阅读代码
- 通过单元测试框架无法测试,多半是因为代码的可测试性不好,需要思考下代码的设计
单元测试就是一个透明测试,我们只关注功能而无需关注实现细节,关注的功能要关注是否覆盖了所有可能场景
单元测试为何难落地执行
一方面,写单元测试本身比较繁琐,技术挑战不大,很多程序员不愿意去写;另一方面,研发比较偏向“快、糙、猛”,容易因为开发进度紧,导致单元测试的执行虎头蛇尾。最后,关键问题还是团队没有建立对单元测试正确的认识,觉得可有可无,单靠督促很难执行得很好
代码可测试性
简而言之,代码的可测试性,就是针对代码编写单元测试的难易程度。对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很费劲,需要依靠单元测试框架中很高级的特性,那往往就意味着代码设计得不够合理,代码的可测试性不好.
提高代码可测试性:依赖注入和二次封装
如何让代码可测试性更好呢?最常用的方式就是依赖注入了,通过DI实现反转,将对象的创建交给业务调用方,这样就可以随意控制输出的结果,从而达到mock数据的目的,最好搭配多态,让mock类之间基于父类注入,例如将Mock数据注入到类中进行测试。例如
public class MockWalletRpcServiceOne extends WalletRpcService { public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amount) { return "123bac"; } } public class MockWalletRpcServiceTwo extends WalletRpcService { public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amount) { return null; } }
通过依赖注入注入到主类
public class Transaction { //... // 添加一个成员变量及其set方法 private WalletRpcService walletRpcService; public void setWalletRpcService(WalletRpcService walletRpcService) { this.walletRpcService = walletRpcService; } // ... public boolean execute() { // ... // 删除下面这一行代码 // WalletRpcService walletRpcService = new WalletRpcService(); // ... } }
使用时直接注入Mock类
public void testExecute() { Long buyerId = 123L; Long sellerId = 234L; Long productId = 345L; Long orderId = 456L; Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId); // 使用mock对象来替代真正的RPC服务 transaction.setWalletRpcService(new MockWalletRpcServiceOne()): boolean executedResult = transaction.execute(); assertTrue(executedResult); assertEquals(STATUS.EXECUTED, transaction.getStatus()); }
对于一些需要外部依赖的远程服务,因为我们不能修改远程服务代码,所以需要组合mock、二次封装、依赖注入等方式解决,也就是类不直接依赖于远程类,而是依赖于封装在远程类之上的本地方法,测试的时候mock封装类即可。例如在远程调用的RedisDistributedLock之上封装一层而不是直接调用
public class TransactionLock { public boolean lock(String id) { return RedisDistributedLock.getSingletonIntance().lockTransction(id); } public void unlock() { RedisDistributedLock.getSingletonIntance().unlockTransction(id); } }
主类与依赖的封装类通过依赖注入来组织
public class Transaction { //... private TransactionLock lock; public void setTransactionLock(TransactionLock lock) { this.lock = lock; } public boolean execute() { //... try { isLocked = lock.lock(); //... } finally { if (isLocked) { lock.unlock(); } } //... } }
我们在测试时就可以直接将Mock的封装类注入主类测试
// 二次封装远程调用类,然后将mock的封装类DI注入到主类中进行测试 public void testExecute() { Long buyerId = 123L; Long sellerId = 234L; Long productId = 345L; Long orderId = 456L; TransactionLock mockLock = new TransactionLock() { public boolean lock(String id) { return true; } public void unlock() {} }; Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId); transaction.setWalletRpcService(new MockWalletRpcServiceOne()); transaction.setTransactionLock(mockLock); boolean executedResult = transaction.execute(); assertTrue(executedResult); assertEquals(STATUS.EXECUTED, transaction.getStatus()); }
哪些代码可测试性不好
下面整理一些影响代码可测试性的问题
1 未决行为
所谓的未决行为逻辑就是,代码的输出是随机或者说不确定的,比如,跟时间、随机数有关的代码,例如:
public class Demo { public long caculateDelayDays(Date dueTime) { long currentTimestamp = System.currentTimeMillis(); if (dueTime.getTime() >= currentTimestamp) { return 0; } long delayTime = currentTimestamp - dueTime.getTime(); long delayDays = delayTime / 86400; return delayDays; } }
这是一段计算延期时间的代码,当前时间一直在变,而这种变化不是通过参数传递进来的,所以随着时间的推移,单元测试的输出结果会有不同,所以为了提高代码可测试性,可以把currentTimestamp 这个参数当成局部变量传进来:
public class Demo { public long caculateDelayDays(Date dueTime, Date currentTime) { if (dueTime.getTime() >= currentTime..getTime()) { return 0; } long delayTime = currentTime..getTime() - dueTime.getTime(); long delayDays = delayTime / 86400; return delayDays; } }
2 全局变量
全局变量是一种面向过程的编程风格,有种种弊端。滥用全局变量也让编写单元测试变得困难,同一个全局变量被多个单元测试用例访问并设置值,会让单元测试结果不准确,例如
public class RangeLimiter { private static AtomicInteger position = new AtomicInteger(0); public static final int MAX_LIMIT = 5; public static final int MIN_LIMIT = -5; public boolean move(int delta) { int currentPos = position.addAndGet(delta); boolean betweenRange = (currentPos <= MAX_LIMIT) && (currentPos >= MIN_LIMIT); return betweenRange; } } public class RangeLimiterTest { public void testMove_betweenRange() { RangeLimiter rangeLimiter = new RangeLimiter(); assertTrue(rangeLimiter.move(1)); assertTrue(rangeLimiter.move(3)); assertTrue(rangeLimiter.move(-5)); } public void testMove_exceedRange() { RangeLimiter rangeLimiter = new RangeLimiter(); assertFalse(rangeLimiter.move(6)); } }
position 是一个静态全局变量,第一个测试用例执行完成之后,position 的值变成了 -1;再执行第二个测试用例的时候,position 变成了 5,move() 函数返回 true,assertFalse 语句判定失败。所以,第二个测试用例运行失败
3 静态方法
静态方法跟全局变量一样,也是一种面向过程的编程思维。在代码中调用静态方法,有时候会导致代码不易测试。主要原因是静态方法也很难 mock,因为静态方法没有多态的特性,没办法使用mock方法。
4 复杂继承
相比组合关系,继承关系的代码结构更加耦合、不灵活,更加不易扩展、不易维护。实际上,继承关系也更加难测试。这也印证了代码的可测试性跟代码质量的相关性。
如果父类需要 mock 某个依赖对象才能进行单元测试,那所有的子类、子类的子类……在编写单元测试的时候,都要 mock 这个依赖对象。对于层次很深(在继承关系类图中表现为纵向深度)、结构复杂(在继承关系类图中表现为横向广度)的继承关系,越底层的子类要 mock 的对象可能就会越多,这样就会导致,底层子类在写单元测试的时候,要一个一个 mock 很多依赖对象,而且还需要查看父类代码,去了解该如何 mock 这些依赖对象。
利用组合而非继承来组织类之间的关系,类之间的结构层次比较扁平,在编写单元测试的时候,只需要 mock 类所组合依赖的对象即可。
5 高耦合代码
如果一个类职责很重,需要依赖十几个外部对象才能完成工作,代码高度耦合,那在编写单元测试的时候,可能需要 mock 这十几个依赖的对象。不管是从代码设计的角度来说,还是从编写单元测试的角度来说,这都是不合理的
总结一下
单元测试好像写的没有之前多了,更多的是写相对更粗粒度的接口测试,一个主要原因就是觉得写单元测试琐碎,写接口测试能通就证明主流程OK,就能提测了,事实上在比较紧凑的迭代节奏里这是常态,幸而测试同学的集成测试比较给力,没出什么问题,但其实也不能老依赖于接口测试和集成测试,单元测试也有必要写的,更多的是通过写单测CR下自己的代码吧,践行持续重构的理念。对于代码的可测试性而言,其实我们日常基于Spring去开发,本身大多数场景都是依赖注入,所以体会可能没有那么深,至于对于远程服务的依赖,使用二次封装是个不错的方法,但是代码侵入性还是有一些的,其实也能通过一些测试工具例如AnyMock解决。重要的还是要有单测和提高代码可测试性的意识吧…