返回值为空的情况下的单测书写

背景

作为开发人员,在代码交付QA前,为了保证交付质量和代码正确性,一般对代码进行单元测试。单测一般由Mock和断言两部分组成,大部分情况下,我们会针对要测试类的成员对象方法调用的返回值进行Mock,然后通过断言去判断方法的逻辑是否符合预期。但是一些情况下,我们会发现一些代码的返回值是Void这样的话我们便无法根据返回值进行断言操作,此外还有一些方法可能含有中途返回的Case即在某些情况下直接返回了,不执行接下来的逻辑,这样的也无法直接通过断言工具去判断方法逻辑的准确性。这时候,我们就需要用到Mock框架的一些功能来进行校验,本文以Mockito为例,来展示如何对这些场景进行单元测试。

原理

一个方法有三个组成部分,入参、逻辑以及返回值,单测便可由这三个部分入手。而入参是决定执行逻辑的,所以一般情况下我们可以针对逻辑和单测进行单元测试。大部分情况下,逻辑由Mock工具掌管,而返回值则依靠断言工具管理。在没有返回值的情况下,通过断言验证的方法走不通,那么就可以从逻辑的角度入手通过Mock工具来验证逻辑是否执行正确。由于在进行单元测试的情况下,我们一般会对底层调用用Mock对象屏蔽,而通过Mock框架比如Mockito进行Mock时,在方法运行后,Mock对象的交互情况是有记录的,所以我们可以通过这些Mock对象的调用信息来判断代码逻辑的正确性。

对于Mockito我们可以从Verify的底层实现方法org.mockito.internal.MockitoCore#verify入手,Mockito提供的verifyNoInteractions等方法的基础实现皆是该方法。具体代码如下:

	public <T> T verify(T mock, VerificationMode mode) {
        if (mock == null) {
            throw nullPassedToVerify();
        }
        MockingDetails mockingDetails = mockingDetails(mock);
        if (!mockingDetails.isMock()) {
            throw notAMockPassedToVerify(mock.getClass());
        }
        assertNotStubOnlyMock(mock);
        MockHandler handler = mockingDetails.getMockHandler();
        mock = (T) VerificationStartedNotifier.notifyVerificationStarted(
            handler.getMockSettings().getVerificationStartedListeners(), mockingDetails);


        MockingProgress mockingProgress = mockingProgress();
        VerificationMode actualMode = mockingProgress.maybeVerifyLazily(mode);
        mockingProgress.verificationStarted(new MockAwareVerificationMode(mock, actualMode, 			mockingProgress.verificationListeners()));
        return mock;
    }

从以上定义我们可以看出verify接口是对Mock对象的VerificationMode校验模式进行校验。而VerificationMode是一个接口其方法如下:

public interface VerificationMode {
    /**
     * 这个是主要实现方法,verifycationData包含了Mock对象的调用信息,可根据调用信息来实现自己的校验方法
     */
    void verify(VerificationData data);


    VerificationMode description(String description);
}

Mockito自带了一些该接口的实现,我们可以通过VerificationModeFactory这个类找到他们,大部分是关于调用信息的,如调用次数等。参考这些接口的实现,自己也能实现一些校验模式。

实践

比如针对如下这段代码一个常见的幂等处理方法,业务背景不仔细介绍了,大概流程是对于数据的uuid已经消费过的的情况跳过不执行逻辑,没有消费过的则要继续执行保存逻辑。这段方法有两个显著特点,一是返回值为void,二是存在中途跳出逻辑的情况,这种情况下,针对这段代码,我们需要写两个单测case来确保逻辑是正确的。即

  1. uuid不存在,需要确保对数据进行保存操作,且保存的值符合预期。
  2. uuid已经存,接口幂等不做保存处理,仅打印日志。
@Override
@Transactional(rollbackFor = Throwable.class)
public void saveOrder(List<Order> orders) {
    Map<String, List<Order>> orderMap = orders.stream().collect(Collectors.groupingBy(Order::getUuid));
        for (String uuid : orderMap.keySet()) {
            if (exists(uuid, orderMap.get(uuid))) {
                log.error("接收单据uuid重复,{}", uuid);
                // 重复跳过,不抛异常
                continue;
            }
            orderDao.insertList(convert(orderMap.get(uuid)));
            List<OrderDetail> orderDetails = orderMap.get(uuid)
                .stream()
                .map(OrderDetail::getOrderDetails)
                .flatMap(Collection::stream)
                .collect(Collectors.toList());
            orderDetailDao.insertList(convertDetails(orderDetails));
        }
    }

对于这种void的返回值,并且也没有抛异常的出现,我们无法对返回值进行断言。而且关键是由于流程有跳过的可能,使用断言框架是无法验证这种流程的。但由于我们这个逻辑中的对象是有Mock对象的即OrderDao和OrderDetailDao,所以我们可以利用Mockito的verify校验功能对单测的Mock对象的交互情况做一个断言处理,而这个就依赖于Mockito的verify功能。

  • 下面代码表示是针对case1即不存在原uuid,这样我们需要确保有交互并且交互数据和预期一致,这里使用verify+ArgumentCaptors的对Mock对象的入参进行抓取,然后使用再使用断言工具判断入参是否符合预期。其实个人认为用verify+ArgumentMathers的方法更正确,因为这里是对逻辑校验单纯使用Mock框架将更明显验证这一点,但为了更好看还是使用了Mock+断言的方式验证方法。

    	@Test
        @DisplayName("保存生产单,不存在原uuid")
        void testSaveOrderNotExist() {
            Order order = new Order();
            order.setOrderNo("son1");
            order.setUuid("son1");
            order.setOrderDetails(Collections.singletonList(new OrderDetail()));
    
            OrderPo orderPo = new OrderPo();
            orderPo.setOrderNo("son1");
            orderPo.setUuid("son1");
    
            when(orderDao.insertList(Collections.singletonList(orderPo))).thenReturn(1);
            when(orderDetailDao.insertList(anyList())).thenReturn(1);
    
            Uuid bizUuid = new Uuid();
            bizUuid.setBusinessNo("son1");
            bizUuid.setUuid("son1");
            bizUuid.setOperateType(TaskAssignConstant.INIT_ORDER_OPERATE_TYPE);
            // 这里的mock返回值影响exist方法的返回值1代表未存在
            when(taskAssignUuidDao.insertIgnore(bizUuid)).thenReturn(1);
            
            orderRepositoryImpl.saveOrder(Collections.singletonList(order));
    		/*
             * 这里使用Mockito的verify方法通过ArgumentCaptor对mock对象orderDao的入参进行抓取,
             * 然后通过断言判断该Mock对象的交互参数是否符合预期,使用ArgumentCaptor可以抓取参数通过断言判断。
             * 也可直接对入参进行构造,将使用对象的equals方法进行判断,也可使用ArgumentMathers构造一个匹配参数方法验证。
             */
            ArgumentCaptor<List<OrderPo>> argumentCaptor = ArgumentCaptor.forClass(List.class);
            verify(orderDao).insertList(argumentCaptor.capture());
            OrderPo orderPo1 = argumentCaptor.getValue().get(0);
            Assertions.assertEquals("son1", orderPo1.getOrderNo());
            Assertions.assertEquals("son1", orderPo1.getUuid());
        }
    
  • 下图针对case2,即存在原uuid,由于原代码存在uuid直接continue相当于跳过了下面的流程,所以需要使用verfiy校验mock的对象在这个case执行时没有交互。

    	@Test
        @DisplayName("保存生产单,存在原uuid")
        void testSaveorderExist() {
            order order = new order();
            order.setorderNo("son1");
            order.setUuid("son1");
            order.setWarehouseNo("6_6_618");
    
            orderPo orderPo = new orderPo();
            orderPo.setorderNo("son1");
            orderPo.setUuid("son1");
            orderPo.setWarehouseNo("6_6_618");
    
            when(orderDao.insertList(Collections.singletonList(orderPo))).thenReturn(1);
            when(orderDetailDao.insertList(any())).thenReturn(0);
    
            Uuid bizUuid = new Uuid();
            bizUuid.setWarehouseNo("6_6_618");
            bizUuid.setBusinessNo("son1");
            bizUuid.setUuid("son1");
            bizUuid.setOperateType(TaskAssignConstant.INIT_ORDER_OPERATE_TYPE);
            when(taskAssignUuidDao.insertIgnore(bizUuid)).thenReturn(0);
            orderRepositoryImpl.saveorder(Collections.singletonList(order));
            // 使用verifyNoInteractions 校验mock对象在uuid已存在的情况下应该没有交互
            verifyNoInteractions(orderDao);
            verifyNoInteractions(orderDetailDao);
        }