(关键词:逻辑层(service)和数据访问层(Dao) Unitils、Spring测试框架、UnitilsJUnit4)
1 事件Service层单元测试
为了将项目与Jenkins结合做到更好的持续集成,我们需要单元测试覆盖到逻辑层(service)和数据访问层(Dao)。
(注:本项目数据访问层为Mapper层,以下Dao与Mapper互译。)
1.1 Service层开展单元测试的困境:
1. 业务逻辑复杂,分支繁多。不仅要构造正常的情况,还要测试异常的分支,这比Dao仅仅是几条sql就复杂多了。复杂的逻辑加上很多异常无法构造,一些关键的异常分支无法覆盖。
2. 数据库垂直切分的设计,Service层不得以操作了多个数据库,而连接多个数据库导致测试极慢,另外还因为涉及到跨数据库事务的难题,这个时候使用DBUnit来准备每个数据库的数据的方法已经不能适应了,整个数据库的环境是不稳定的。
3. Service层的Spring配置文件复杂,不仅包括了数据库的配置,还有其他框架配置(本项目中的dubbo)等等。启动测试就需要这些环境的配合,稍微一个不小心就会出现配置错误,整个测试失败。测试受环境影响,容易集成失败。
1.2 解决思路
我们不应该让Service层的单元测试依赖太多的东西,单元测试要体现“单元”的概念,不依赖数据库,不依赖其非必须的框架。根据这个原则,我们使用Mock对象,把service层用到的Dao等对象都一一的mock并插入到Service对象中。然后使用Unitils框架做模拟录制Dao(Mapper)的访问行为。这样就可以把Service的测试完全隔离开。经过处理后,Service的覆盖率和处理速度都得到了显著提升。
2 方案设计:
2.1 测试框架选型:Unitils、Spring测试框架、UnitilsJUnit4
2.1.1 Mock模拟对象——为交互而生:
单个的Junit4单元测试框架往往适用于以下场景的测试:单个函数,一个class,或者几个功能相关class的测试,对于纯函数测试,接口级别的测试尤其适用。例如Incident项目中 utils包中的日期、字符串等工具类的测试。
但是,对于以下的复杂场景:
①被测对象依赖复杂,甚至无法简单new出这个对象
②对于一些failure场景的测试
③被测对象中涉及多线程合作
④被测对象通过消息与外界交互的场景
…
单纯依赖单测框架是无法实现单元测试的,而从某种意义上来说,这些场景反而是测试中的重点。
以分布式系统的测试为例,class 与 function级别的单元测试对整个系统的帮助不大,当然,这种单元测试对单个程序的质量有帮助;分布式系统测试的要点是测试业务间的交互。Mock方法的引入通常能帮助我们解决以上场景中遇到的难题。
Mock通常是指,在测试一个对象A时,我们构造一些假的对象来模拟与A之间的交互,而这些Mock对象的行为是我们事先设定且符合预期。通过这些Mock对象来测试A在正常逻辑,异常逻辑或压力情况下工作是否正常。
引入Mock最大的优势在于:Mock的行为固定,它确保当你访问该Mock的某个方法时总是能够获得一个没有任何逻辑的直接就返回的预期结果。
Mock Object的使用通常会带来以下一些好处:
①隔绝其他模块出错引起本模块的测试错误。
②隔绝其他模块的开发状态只要定义好接口不用管他们开发有没有完成。
③一些速度较慢的操作,可以用Mock Object代替,快速返回。
④对于分布式系统的测试通过Mock Object可以将一些分布式测试转化为本地的测试。
2.1.2 Spring测试框架:
选用Spring测试框架理由如下:
测试环境运行在@RunWith(SpringJUnit4ClassRunner.class)下;
为了测试用例JavaBean的装配方便安全,我们项目中大量使用Spring注解;
业务范围内几乎所有的操作都是在事物环境下进行的,mock虽然为模拟对象,为了更加真实的模拟实际业务操作,我们也为测试用例配置Spring的事物管理,incident-Unitilstest.xml:
2.2 测试数据源:Json类型
Service层测试数据准备很麻烦,需要为每个Dao的返回对象做假数据。一般的String还好,返回JavaBean的就麻烦,而特别悲催是那种返回一个list的JavaBean接口,JavaBean还嵌套其他Bean,要一个个对象、属性的填塞。不行的是Dao的query函数往往都是返回这种List对象的,这样导致测试代码比开发工作量还大,而且很难维护。
于是我们希望和Dbunit一样,将数据的准备通过资源文件来完成,不用在测试代码中构造。JavaBean和Json之间互转的效率高,而且方便。所以我们将Dao的返回转换为Json字符串打印保存下来,存放为txt文件。然后在Service的测试中,在通过Unitils的IO能力,将文件内容读出为字符串,再转换为List/Bean的对象,放到Mock的Dao返回中。为了测试,我们准备了一个incidentBean的文件:incidentServiceTest2.txt
{
"incidentId": "1",
"incidentTypeId": 1,
"subTypeId": 1,
"levelId": "1",
"businessTypeId": "1",
"customerId": "1",
"feedbackType": 0,
"feedbackNumber": "13600000000",
"dcId": "1",
"simpleDescribe": "测试",
"detailedDescribe": null,
"status": null,
"sourceId": null,
"resourceId": null,
"creatorId": null,
"currentHandlerId": null,
"createTime": "2017-03-07 09:51:39",
"updateTime": "2017-03-07 09:51:39",
"completeTime": null
}
注:测试源数据文件路径可以在测试中通过org.unitils.io.annotation.FileContent;中多种方式注解方式指定。本案例数据源文件放在默认的在测试用例相同的package下。
3 Unitils-mock测试案例详解
下面从是incident-service服务模块service中incidentService类提取的queryIncidentById源码:
首先分析其方法的调用逻辑如下:
这一步很重要,因为我们要mock的对象就是直接对数据库进行操作的Mapper,也是测试中隔离数据库操作的关键。如果随意把incidentDao设置为mock对象,那么回放mock行为时测试一定不通过,有返回结果的回放行为将报空指针异常。另外也是充分测试交互行为的需要。
测试用例编写如下:
@RunWith(SpringJUnit4ClassRunner.class) ①
@ContextConfiguration(locations={"incident-Unitilstest.xml"}) ②
public class IncidentServiceTest2 extends UnitilsJUnit4 ①
{
private IncidentMapper incidentMapper; ③
Incident incident; ④
@Resource(name = "incidentDao")
private IncidentDao incidentDao; ⑤
@Resource(name = "incidentService")
private IncidentService incidentService; ⑤
@FileContent("IncidentServiceTest2.txt")
private String incidentData; ⑥
@Before
public void init()
{
incidentData = IOUnitils.readFileContent(String.class, this); ⑦
incident = JSON.parseObject(incidentData, Incident.class); ⑧
incidentMapper = mock(IncidentMapper.class); ⑨
}
@Test
public void testQueryIncidentById()
{
/*Incident incident = new Incident(); ⑩
incident.setIncidentId("001");
incident.setSimpleDescribe("测试");
incident.setStatus(0);
incident.setCreateTime(new Timestamp(System.currentTimeMillis()));
incident.setCompleteTime(null);
incident.setFeedbackNumber("13600000000");*/ ⑩
//录制mock的行为
doReturn(incident).when(incidentMapper).queryIncidentById("1");⑪
//通过Spring测试框架提供的工具类为目标对象私有属性赋值 ⑫
ReflectionTestUtils.setField(incidentDao,"incidentMapper",incidentMapper);
ReflectionTestUtils.setField(incidentService,"incidentDao",incidentDao); ⑫
//启用incidentService中queryIncidentById方法,回放行为
Incident incidentresult = incidentService.queryIncidentById("1"); ⑬
assertNotNull(incidentresult); ⑭
assertThat(incidentresult.getFeedbackNumber(),
equalTo("13600000000")); ⑮
//验证交互行为
verify(incidentMapper, times(1)).queryIncidentById("1");⑯
}
}
① @RunWith(SpringJUnit4ClassRunner.class)定义使用的测试框架SpringJUnit4;并且让测试类继承UnitilsJUnit4,便如执行时使用Junit4运行测试用例。这样做方便后面做集成测试需要,因为许多项目构建工具maven、gradle都完美支持Junit4插件。
② @ContextConfiguration加载配置事物的Spring文件:incident-Unitilstest.xml
③ 定义incidentMapper,作为后面mock的对象
④ 定义incident实体对象,作为后面json数据解析玩后装配的对象
⑤ 从Spring容器中加载incidentDao、incidentService的实例
⑥ @FileContent("IncidentServiceTest2.txt"),定义数据源文件路径,一遍unitils-io读取到指定的数据
⑦ IOUnitils.readFileContent(String.class, this),读取源数据
⑧ 将读取的源数据装配到incident对象中。
⑨ 创建incidentMapper模拟对象
⑩ 不使用json转化JavaBean类型时需要创建对象在一一为其属性设值(该代码块已被注释)
⑪ 录制mock的行为,也就是模拟incidentMapper去数据库中操作findIncidentById
⑫ 通过Spring测试框架提供的工具类ReflectionTestUtils.setField()为目标类私有属性赋值。ReflectionTestUtils.setField(incidentDao,"incidentMapper",incidentMapper)释义:incidentDao为目标类;“incidentMapper”为incidentDao目标类的一个私有属性。将incidentMapper赋值给incidentDao中的“incidentMapper”。
注意:按照逻辑顺序赋值
⑬ incidentService调用queryIncidentById方法,回放mock行为
⑭ 有结果的回放行为时,incidentService调用queryIncidentById时有真实的返还结果incidentresult。
⑮ 断言从回放结果incidentresult中取得的feedbackNumber属性与json源数据的一致
⑯ verify(incidentMapper, times(1)).queryIncidentById("1")验证交互行为有发生且仅发生1次。
Eclipse上运行该测试用例结果如下:
修改发生交互行为次数为2次时:
verify(incidentMapper, times(2)).queryIncidentById("1")再次运行测试,结果如下:
测试不通过,原因是交互行为实际只发生1次。