Service层单元测试实践
为了更好的持续集成,我们需要单元测试覆盖到逻辑层(Service)和数据访问层(Dao)。
1. Service层开展单元测试的困境
Dao层我们可以使用Unitils、Spring、Dbunit结合,Dbunit方便开发人员准备数据,Spring配置文件也为单元测试专门做了优化,使用了测试数据源,事务的问题也解决。
但是Service层的问题就复杂很多,遇到的问题主要如下
1、业务逻辑复杂,分支繁多。不仅要构造正常的情况,还要测试异常的分支,这比Dao仅仅是几条sql就复杂多了。复杂的逻辑加上很多异常无法构造,一些关键的异常分支无法覆盖。
2、数据库垂直切分的设计,Service层不得以操作了多个数据库,而连接多个数据库导致测试极慢,另外还因为涉及到跨数据库事务的难题,这个时候使用DBUnit来准备每个数据库的数据的方法已经不能适应了,整个数据库的环境是不稳定的。
3、Service层的Spring配置文件复杂,不仅包括了数据库的配置,还有JMS队列、缓存等等。启动测试就需要这些环境的配合,稍微一个不小心就会出现配置错误,整个测试失败。测试受环境影响,容易集成失败。
2. 解决方案
经过大量的实践,我们认为不应该是让Service层的单元测试依赖太多的东西,,单元测试要体现“单元”的概念,不依赖数据库、不依赖Spring上下文。
根据这个原则,我们考虑使用使用Mock对象,把Service层用到的Dao等对象都一一mock并插入到Service对象中。然后通过Unitils模拟Dao的返回值,或者抛出异常。这样就可以把Service的测试完全隔离开。经过处理后,Service的覆盖率和处理速度都得到了提升。
下面根据一个实际的例子讲解如何开展Service层的单元测试。
订单业务逻辑是这样一个场景:
用户在网站上下了一个订单,后台处理订单,OrderService对象提供了一个processOrder的方法给外部调用,首先根据订单Id获取订单的信息,根据订单中关联的accountId获得用户的帐户相关信息,然后判断帐户中的余额是否大于当前订单的金额,如果是,则在用户帐户上扣取订单相应的金额,然后返回成功。如果否,则直接返回失败。
OrderService的代码如下
public class OrderService {
OrderDao orderDao;
AccountDao accountDao;
/**
* 处理订单,在用户的帐户中扣取订单的金额
*
* @param orderId
* @return
*/
@Transactional
public boolean processOrder(int orderId) {
// 获取订单详情
Order order = orderDao.getOrder(orderId);
Assert.notNull(order, "orderId is valid");
// 获取帐户信息
Account account = accountDao.getAccount(order.getAccountId());
Assert.notNull(account, "accountId is valid");
// 判断当前用户帐户余额是否大于订单的金额
if (account.getBalance() > order.getOrderAmount()) {
// 更新用户的帐户余额,减去订单的金额
accountDao.updateAccount(order.getAccountId(), account.getBalance() - order.getOrderAmount());
// 将订单改为已处理状态
orderDao.updateOrder(orderId, (byte) 1);
// 返回成功
return true;
} else {
// 如果余额不够,返回订单处理失败
return false;
}
}
}
一、为了测试,需要在Maven的POM文件中增加如下的配置
<dependency>
<groupId>org.unitils</groupId>
<artifactId>unitils-mock</artifactId>
<version>${unitils.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.unitils</groupId>
<artifactId>unitils-inject</artifactId>
<version>${unitils.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.unitils</groupId>
<artifactId>unitils-io</artifactId>
<version>${unitils.version}</version>
<scope>test</scope>
</dependency>
unitils.version目前最新的为3.3版本
二、Unitils的环境配置
Unitils的启动,需要一个配置文件unitils.properties,这个文件默认需要放到classpath下。不过Service层不需要数据的设置,所以使用默认的配置即可, 不需要unitils.properties。
三、测试数据的准备
和Dao层有Dbunit导出测试数据不一样,Service层测试数据准备很麻烦,需要为每个Dao的返回对象做假数据。一般的String还好,返回JavaBean的就麻烦,而特别悲催是那种返回一个list的JavaBean接口,JavaBean还嵌套其他Bean,要一个个对象、属性的填塞。不行的是Dao的query函数往往都是返回这种List对象的,这样导致测试代码比开发工作量还大,而且很难维护,很多开发人员有抵触情绪。
于是我们希望和Dbunit一样,将数据的准备通过资源文件来完成,不用在测试代码中构造。在评估之后,发现JavaBean和Json之间互转的效率高,而且方便。所以我们将Dao的返回转换为Json字符串打印保存下来,存放为js文件。然后在Service的测试中,在通过Unitils的IO能力,将文件内容读出为字符串,再转换为List/Bean的对象,放到Mock的Dao返回中。这样工作就轻松了很多。
为了测试,我们准备了两个JavaBean的文件
ACCOUNT.js
{"accountId":"S31993k","balance":100}
ORDER.js
{"accountId":"S31993k","orderAmount":65,"orderId":2345,"orderStatus":0}
测试的文件默认放在单元测试用例相同的package下。即类似src/test/resources/com/xxx/service的目录等
四、单元测试用例的编写
测试代码同样要继承UnitilsJunit3的基类,
public class OrderServiceTest extends UnitilsJUnit3 {
// 被测试的Service对象
@TestedObject
OrderService orderService = new OrderService();
// 自动按照类型注入到被测试对象中
@InjectIntoByType
Mock<OrderDao> orderDaoMock;
// 自动按照类型注入到被测试对象中
@InjectIntoByType
Mock<AccountDao> accountDaoMock;
// 准备AccountDao返回的模拟对象数据
@FileContent("ACCOUNT.js")
private String accountJs;
// 准备OrderDao返回的模拟数据
@FileContent("ORDER.js")
private String orderJs;
//各个测试用例共享的测试数据
Account account;
Order order;
@Override
public void setUp() {
account = JSON.parseObject(accountJs, Account.class);
order = JSON.parseObject(orderJs, Order.class);
}
/**
* 测试正常流程
*/
public void testProcessOrder1() {
orderDaoMock.returns(order).getOrder(2345);
orderDaoMock.returns(1).updateOrder(2345, (byte) 1);
accountDaoMock.returns(account).getAccount("S31993k");
accountDaoMock.returns(1).updateAccount("S31993k", 35);
assertEquals(true, orderService.processOrder(2345));
}
/**
* 测试订单金额大于用户余额的情况
*/
public void testNotEnoughBalancen() {
// 可以对返回的数据微调,这样就不需要额外的数据文件了
account.setBalance(10);
order.setOrderAmount(100);
orderDaoMock.returns(order).getOrder(2345);
orderDaoMock.returns(1).updateOrder(2345, (byte) 1);
accountDaoMock.returns(account).getAccount("S31993k");
// accountDaoMock.returns(1).updateAccount("S31993k", 35);
assertEquals(false, orderService.processOrder(2345));
}
/**
* 测试订单号存在的情况
*/
public void testOrderNotExist() {
try {
orderService.processOrder(5544);
fail("This should not happended");
} catch (IllegalArgumentException e) {
assertTrue(true);
}
}
/**
* 测试订单关联的帐户不存在的情况
*/
public void testAccountNotExist() {
order.setAccountId("FakeNumber");
orderDaoMock.returns(order).getOrder(2345);
try {
orderService.processOrder(2345);
fail("This should not happended");
} catch (IllegalArgumentException e) {
assertTrue(true);
}
}
}
这里指的是OrderService是被测试的对象,使用@TestObject来指定。
@TestedObject
OrderService orderService = new OrderService();
请注意,这里Service是我们代码中直接new出来的,而不是Spring中拼装的。
@InjectIntoByType
Mock<OrderDao> orderDaoMock;
// 自动按照类型注入到被测试对象中
@InjectIntoByType
Mock<AccountDao> accountDaoMock;
因为涉及了帐户和订单表的操作,所以这里有两个Dao,我们通过Unitils的Mock对象模拟出来,然后使用@InjectIntoByType的标签,让Unitils自动按照类型插入到被测试对象中。
@FileContent("ACCOUNT.js")
private String accountJs;
// 准备OrderDao返回的模拟数据
@FileContent("ORDER.js")
private String orderJs;
@FileContent是Unitils-io包中提供的一个工具,他可以方便的读取资源文件到测试类中的字符串类变量中。我们可以利用它把Json字符串读出来。@FileContent默认加载当前测试类所在package下的资源文件,如果有特殊需求可以修改unitils.properties的属性。这里建议使用默认的规则,方便资源文件的规整。
@Override
public void setUp() {
account = JSON.parseObject(accountJs, Account.class);
order = JSON.parseObject(orderJs, Order.class);
}
因为每个测试方法都需要account和order对象的实例。所以我们将其抽取到setUp方法中,可以给各个测试方法公用。这里是使用了Alibaba的FastJson作为解析Json的工具。这个工具可以根据自己的项目决定。
下面的测试用例是测试一个正常的情况
/**
* 测试正常流程
*/
public void testProcessOrder1() {
orderDaoMock.returns(order).getOrder(2345);
orderDaoMock.returns(1).updateOrder(2345, (byte) 1);
accountDaoMock.returns(account).getAccount("S31993k");
accountDaoMock.returns(1).updateAccount("S31993k", 35);
assertEquals(true, orderService.processOrder(2345));
}
使用
orderDaoMock.returns(order).getOrder(2345);
模拟Dao的返回,其含义就是让orderDao在接收到参数为‘2345’的时候,返回的对象是预制的order对象。模拟后,使用断言确定返回是否正确。
为了提高分支的覆盖率,我们在后面分别制造了订单金额大于余额的情况,和帐户、订单不存在的情况作为异常的测试。代码都很简单,不再一一赘述。
3. 经验总结
一、 Service的数据准备还是手工进行的,以后可以考虑写一些套件,自动录制Dao的输出,然后在Service的测试中回放出来。
二、 Mock对象不仅可以模拟返回值,也可以按照要求抛出异常等,可以参考Unitils的说明。
三、 测试代码也需要当做是正式代码一样呵护,经常性的进行重构,避免代码冗余。比如setUp方法中的公用方法就是后期抽取出来的。