本文包括以下内容:
- 如何解决数据依赖,编写健壮的单元测试
- 如何处理异常分支和边界场景
前言
一次完整的开发,代码编写其实只占据了很小的一部分,而更多时间,则被花费在了调试上面。在开发过程中,我们经常需要自己编写一套单元测试用例,自测代码的正确性。
单元测试的意义
一套完整、有效、健壮的单元测试,往往能够帮助我们:
- 检测代码正确性,暴露代码中存在的Bug
- 快速定位Bug
- 暴露边界与异常场景
- 快速review代码
- 提高代码质量,减少QA时间
编写单元测试常见的问题
我们为什么要编写单元测试,这里就不说了,在写单元测试的过程中,我们会经常碰到这样的问题:
- 数据依赖
- 忽略了异常分支与边界场景
- 逻辑分支太多,无法覆盖所有的分支
解决方法
数据依赖
其实编写单元测试,基本避免不了数据依赖的场景,而数据依赖,基本来源于两个方面:
- 外部数据
- 数据库数据
而一个健壮的单元测试,原则上不应该有数据依赖,或者说,不依赖于现有的数据。
举个例子:
1 | public class Test extends AbstractTransactionalJUnit4SpringContextTests { |
对外部数据的依赖
在有外部数据依赖的程序中,想要完整覆盖这部分测试逻辑,势必要运行调用外部应用接口获取返回值,但是如果真实进行接口访问,又与程序之间产生耦合,与设计思想不符。
而设想当外部依赖还未开发完成,亦或者外部依赖的程序有错误,获得了与预期不符的返回值,那么势必对本身代码逻辑产生影响,需要我们花费大量的时间进行排查,影响开发效率。
如何解决依赖:
- 外部数据Mock
- 中间件Mock
如何实现
在上例中,当进行测试覆盖activityHttpExportService.getActivityProductInfo()
方法的时候,产生了对外部服务productService.getProductInfo()
的依赖,采用Mockito进行Mock数据,当运行至 productService.getProductInfo() 方法的时候,并不会真是调用外部接口,而是将Mock的数据进行返回。然后程序会继续执行下去,检验本身程序的逻辑代码。1
2
3
4when(productService.getProductInfo(any())).thenAnswer(invocationOnMock -> {
List<ProductInfo> productInfos = new ArrayList<>();
return productInfos;
});
对数据库的依赖
如果数据涉及到对数据库的CRUD操作,那么应该真实进行CRUD操作么?还是直接用Mock返回操作结果?
其实我觉得是应该真实操作数据库的,因为对数据库的CRUD,也是当前单元的逻辑操作的一部分,同样也会出现Bug。但是如果对真实数据CRUD,又产生了对数据库数据进行破坏的可能。
如何解决:
- 初始化时插入数据,事务自动rollback
如何实现
上例中Test
类继承自AbstractTransactionalJUnit4SpringContextTests
类,我们首先来看下AbstractTransactionalJUnit4SpringContextTests
:1
2
3
4
5({TransactionalTestExecutionListener.class, SqlScriptsTestExecutionListener.class})
public abstract class AbstractTransactionalJUnit4SpringContextTests extends AbstractJUnit4SpringContextTests {
// ...
}
AbstractTransactionalJUnit4SpringContextTests
继承自 AbstractJUnit4SpringContextTests
,并且注册了TransactionalTestExecutionListener.class
,SqlScriptsTestExecutionListener.class
两个监听器,并开启事务,默认执行完毕自动Rollback。
所以我们可以在单元测试开始前,向数据库中插入记录,如例中的initOrderActivity()
方法,然后根据插入的数据去做CRUD操作,整个程序虽然依赖了数据库,但是并未依赖数据,并检验了对数据库的逻辑操作。
异常与边界场景
其实在编写测试代码的时候,我们应该让它先失败,断言其异常期望值,因为当覆盖正确的测试逻辑之后,异常逻辑往往会被忽略,获得到期望的异常值。
先检验异常逻辑的好处:
- 异常 ≠ 失败,断言异常可以证明测试机制正常运行
- 检测错误期望值,会让对整合逻辑的认知更加清晰
- 暴露存在的界场景,补充完善代码逻辑
在开发初期,往往很少暴露出边界场景,但是在单元测试的过程中,对于异常的思考往往能触发对边界的思考。
比如这样的问题
购买商品时,购买数加已购买数不能大于总限制数
1
2
3
4
5if(bug > 0 && bug + own <= LIMIT){
System.out.println("...");
}else{
System.out.println("失败!");
}
当输入负数时,肯定是失败的,那么就会想何时会成功?边界场景有哪些呢?
* 0
* 正最大
* 负最大
那么输入int最大时会怎样?假设 LIMIT = 1000;own = 100;
2147483647 + 1000会越界变成负数,这种情况是不是就满足情况了呢?
其他
对于暴露出来的bug,可以写一个单元测试来暴露这个bug,当review代码事也可以快速review该bug
单元测试可以排除很大一部分bug,但是不一定能发现所有的bug,在写单元测试的时候,应当尽可能去覆盖所有的分支,测试用例的粒度不是越低越好,但是应当覆盖100%的逻辑
单元测试是以模块为单位的,对于其他单元测试已经覆盖的重复的实现逻辑,可以直接Mock返回这部分的返回值