业务逻辑层(service层)单元测试的实践

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方法中的公用方法就是后期抽取出来的。

相关推荐