返回值為空的情況下的單測書寫

痛失網名發表於2021-10-13

背景

作為開發人員,在程式碼交付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);
        }
    

相關文章