背景
作為開發人員,在程式碼交付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來確保邏輯是正確的。即
- uuid不存在,需要確保對資料進行儲存操作,且儲存的值符合預期。
- 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); }