背景

作为开发人员,在代码交付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); }