那些年,我們寫過的無效單元測試

前言

那些年,爲了學分,我們學會了 面向過程編程
那些年,爲了就業,我們學會了 面向對象編程
那些年,爲了生活,我們學會了 面向工資編程
那些年,爲了升職加薪,我們學會了 面向領導編程
那些年,爲了完成指標,我們學會了 面向指標編程
……
那些年,我們學會了 敷衍編程
那些年,我們 編程只是爲了 敷衍

現在,領導要響應集團提高代碼質量的號召,需要提升單元測試的代碼覆蓋率。當然,我們不能讓領導失望,那就加班加點地補充單元測試用例,努力提高單元測試的代碼覆蓋率。至於單元測試用例的有效性,我們大抵是不用關心的,因爲我們只是面向指標編程

我曾經閱讀過一個Java服務項目,單元測試的代碼覆蓋率非常高,但是通篇沒有一個依賴方法驗證(Mockito.verify)、滿紙僅存幾個數據對象斷言(Assert.assertNotNull)。我說,這些都是無效的單元測試用例,根本起不到測試代碼BUG和迴歸驗證代碼的作用。後來,在一個月黑風高的夜裏,一個新增的方法調用,引起了一場血雨腥風。

編寫單元測試用例的目的,並不是爲了追求單元測試代碼覆蓋率,而是爲了利用單元測試驗證迴歸代碼——試圖找出代碼中潛藏着的BUG。所以,我們應該具備工匠精神、懷着一顆敬畏心,編寫出有效的單元測試用例。在這篇文章裏,作者通過日常的單元測試實踐,系統地總結出一套避免編寫無效單元測試用例的方法和原則。

1. 單元測試簡介

1.1. 單元測試概念

在維基百科中是這樣描述的:

在計算機編程中,單元測試又稱爲模塊測試,是針對程序模塊來進行正確性檢驗的測試工作。程序單元是應用的最小可測試部件。在過程化編程中,一個單元就是單個程序、函數、過程等;對於面向對象編程,最小單元就是方法,包括基類、抽象類、或者派生類中的方法。

1.2. 單元測試案例

首先,通過一個簡單的服務代碼案例,讓我們認識一下集成測試和單元測試。

1.2.1. 服務代碼案例

這裏,以用戶服務(UserService)的分頁查詢用戶(queryUser)爲例說明。

@Service
public class UserService {
    /** 定義依賴對象 */
    /** 用戶DAO */
    @Autowired
    private UserDAO userDAO;

    /**
     * 查詢用戶
     * 
     * @param companyId 公司標識
     * @param startIndex 開始序號
     * @param pageSize 分頁大小
     * @return 用戶分頁數據
     */
    public PageDataVO<UserVO> queryUser(Long companyId, Long startIndex, Integer pageSize) {
        // 查詢用戶數據
        // 查詢用戶數據: 總共數量
        Long totalSize = userDAO.countByCompany(companyId);
        // 查詢接口數據: 數據列表
        List<UserVO> dataList = null;
        if (NumberHelper.isPositive(totalSize)) {
            dataList = userDAO.queryByCompany(companyId, startIndex, pageSize);
        }

        // 返回分頁數據
        return new PageDataVO<>(totalSize, dataList);
    }
}

1.2.2. 集成測試用例

很多人認爲,凡是用到JUnit測試框架的測試用例都是單元測試用例,於是就寫出了下面的集成測試用例。

@Slf4j
@RunWith(PandoraBootRunner.class)
@DelegateTo(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = {ExampleApplication.class})
public class UserServiceTest {
    /** 用戶服務 */
    @Autowired
    private UserService userService;

    /**
     * 測試: 查詢用戶
     */
    @Test
    public void testQueryUser() {
        Long companyId = 123L;
        Long startIndex = 90L;
        Integer pageSize = 10;
        PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
        log.info("testQueryUser: pageData={}", JSON.toJSONString(pageData));
    }
}

集成測試用例主要有以下特點:

  1. 依賴外部環境和數據;
  2. 需要啓動應用並初始化測試對象;
  3. 直接使用@Autowired注入測試對象;
  4. 有時候無法驗證不確定的返回值,只能靠打印日誌來人工覈對。

1.2.3. 單元測試用例

採用JUnit+Mockito編寫的單元測試用例如下:

@Slf4j
@RunWith(MockitoJUnitRunner.class)
public class UserServiceTest {
    /** 定義靜態常量 */
    /** 資源路徑 */
    private static final String RESOURCE_PATH = "testUserService/";

    /** 模擬依賴對象 */
    /** 用戶DAO */
    @Mock
    private UserDAO userDAO;

    /** 定義測試對象 */
    /** 用戶服務 */
    @InjectMocks
    private UserService userService;

    /**
     * 測試: 查詢用戶-無數據
     */
    @Test
    public void testQueryUserWithoutData() {
        // 模擬依賴方法
        // 模擬依賴方法: userDAO.countByCompany
        Long companyId = 123L;
        Long startIndex = 90L;
        Integer pageSize = 10;
        Mockito.doReturn(0L).when(userDAO).countByCompany(companyId);

        // 調用測試方法
        String path = RESOURCE_PATH + "testQueryUserWithoutData/";
        PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
        String text = ResourceHelper.getResourceAsString(getClass(), path + "pageData.json");
        Assert.assertEquals("分頁數據不一致", text, JSON.toJSONString(pageData));

        // 驗證依賴方法
        // 驗證依賴方法: userDAO.countByCompany
        Mockito.verify(userDAO).countByCompany(companyId);

        // 驗證依賴對象
        Mockito.verifyNoMoreInteractions(userDAO);
    }

    /**
     * 測試: 查詢用戶-有數據
     */
    @Test
    public void testQueryUserWithData() {
        // 模擬依賴方法
        String path = RESOURCE_PATH + "testQueryUserWithData/";
        // 模擬依賴方法: userDAO.countByCompany
        Long companyId = 123L;
        Mockito.doReturn(91L).when(userDAO).countByCompany(companyId);
        // 模擬依賴方法: userDAO.queryByCompany
        Long startIndex = 90L;
        Integer pageSize = 10;
        String text = ResourceHelper.getResourceAsString(getClass(), path + "dataList.json");
        List<UserVO> dataList = JSON.parseArray(text, UserVO.class);
        Mockito.doReturn(dataList).when(userDAO).queryByCompany(companyId, startIndex, pageSize);

        // 調用測試方法
        PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
        text = ResourceHelper.getResourceAsString(getClass(), path + "pageData.json");
        Assert.assertEquals("分頁數據不一致", text, JSON.toJSONString(pageData));

        // 驗證依賴方法
        // 驗證依賴方法: userDAO.countByCompany
        Mockito.verify(userDAO).countByCompany(companyId);
        // 驗證依賴方法: userDAO.queryByCompany
        Mockito.verify(userDAO).queryByCompany(companyId, startIndex, pageSize);

        // 驗證依賴對象
        Mockito.verifyNoMoreInteractions(userDAO);
    }
}

單元測試用例主要有以下特點:

  1. 不依賴外部環境和數據;
  2. 不需要啓動應用和初始化對象;
  3. 需要用@Mock來初始化依賴對象,用@InjectMocks來初始化測試對象;
  4. 需要自己模擬依賴方法,指定什麼參數返回什麼值或異常;
  5. 因爲測試方法返回值確定,可以直接用Assert相關方法進行斷言;
  6. 可以驗證依賴方法的調用次數和參數值,還可以驗證依賴對象的方法調用是否驗證完畢。

2.3. 單元測試原則

爲什麼集成測試不算單元測試呢?我們可以從單元測試原則上來判斷。在業界,常見的單元測試原則有AIR原則和FIRST原則。

2.3.1. AIR原則

AIR原則內容如下:

  1. A-Automatic(自動的)單元測試應該是全自動執行的,並且非交互式的。測試用例通常是被定期執行的,執行過程必須完全自動化纔有意義。輸出結果需要人工檢查的測試不是一個好的單元測試。單元測試中不準使用System.out來進行人肉驗證,必須使用assert來驗證。
  2. I-Independent(獨立的)單元測試應該保持的獨立性。爲了保證單元測試穩定可靠且便於維護,單元測試用例之間決不能互相調用,也不能對外部資源有所依賴。
  3. R-Repeatable(可重複的)單元測試是可以重複執行的,不能受到外界環境的影響。單元測試通常會被放入持續集成中,每次有代碼提交時單元測試都會被執行。

2.3.2. FIRST原則

FIRST原則內容如下:

  1. F-Fast(快速的)單元測試應該是可以快速運行的,在各種測試方法中,單元測試的運行速度是最快的,大型項目的單元測試通常應該在幾分鐘內運行完畢。
  2. I-Independent(獨立的)單元測試應該是可以獨立運行的,單元測試用例互相之間無依賴,且對外部資源也無任何依賴。
  3. R-Repeatable(可重複的)單元測試應該可以穩定重複的運行,並且每次運行的結果都是穩定可靠的。
  4. S-SelfValidating(自我驗證的)單元測試應該是用例自動進行驗證的,不能依賴人工驗證。
  5. T-Timely(及時的)單元測試必須及時進行編寫,更新和維護,以保證用例可以隨着業務代碼的變化動態的保障質量。

2.3.3. ASCII原則

阿里的夕華先生也提出了一條ASCII原則

  1. A-Automatic(自動的)單元測試應該是全自動執行的,並且非交互式的。
  2. S-SelfValidating(自我驗證的)單元測試中必須使用斷言方式來進行正確性驗證,而不能根據輸出進行人肉驗證。
  3. C-Consistent(一致的)單元測試的參數和結果是確定且一致的。
  4. I-Independent(獨立的)單元測試之間不能互相調用,也不能依賴執行的先後次序。
  5. I-Isolated(隔離的)單元測試需要是隔離的,不要依賴外部資源。

2.3.4. 對比集測和單測

根據上節中的單元測試原則,我們可以對比集成測試和單元測試的滿足情況如下:

原則名稱 原則項目 集成測試 單元測試
AIR原則 Automatic(自動的) 不一定支持 支持
  Independent(獨立的) 不一定支持 支持
  Repeatable(可重複的) 不一定支持 支持
FIRST原則 Fast(快速的) 不一定支持 支持
  Independent(獨立的) 不一定支持 支持
  Repeatable(可重複的) 不一定支持 支持
  SelfValidating(自我驗證的) 不一定支持 支持
  Timely(及時的) - -
ASCII原則 Automatic(自動的) 不一定支持 支持
  SelfValidating(自我驗證的) 不一定支持 支持
  Consistent(一致的) 不一定支持 支持
  Independent(獨立的) 不一定支持 支持
  Isolated(隔離的) 不一定支持 支持

通過上面表格的對比,可以得出以下結論:

  1. 集成測試基本上不一定滿足所有單元測試原則;
  2. 單元測試基本上一定都滿足所有單元測試原則。

所以,根據這些單元測試原則,可以看出集成測試具有很大的不確定性,不能也不可能完全代替單元測試。另外,集成測試始終是集成測試,即便用於代替單元測試也還是集成測試,比如:利用H2內存數據庫測試DAO方法。

3. 無效單元測試

要想識別無效單元測試,就必須站在對方的角度思考——如何在保障單元測試覆蓋率的前提下,能夠更少地編寫單元測試代碼。那麼,就必須從單元測試編寫流程入手,看哪一階段哪一方法可以偷工減料。

3.1. 單元測試覆蓋率

在維基百科中是這樣描述的:

代碼覆蓋(Code Coverage)是軟件測試中的一種度量,描述程序中源代碼被測試的比例和程度,所得比例稱爲代碼覆蓋率。

常用的單元測試覆蓋率指標有:

  1. 行覆蓋(Line Coverage):用於度量被測代碼中每一行執行語句是否都被測試到了。
  2. 分支覆蓋(Branch Coverage):用於度量被測代碼中每一個代碼分支是否都被測試到了。
  3. 條件覆蓋(Condition Coverage):用於度量被測代碼的條件中每一個子表達式(true和false)是否都被測試到了。
  4. 路徑覆蓋(Path Coverage):用於度量被測代碼中的每一個代碼分支組合是否都被測試到了。

除此之外,還有方法覆蓋(Method Coverage)、類覆蓋(Class Coverage)等單元測試覆蓋率指標。

下面,用一個簡單方法來分析各個單元測試覆蓋率指標:

public static byte combine(boolean b0, boolean b1) {
    byte b = 0;
    if (b0) {
        b |= 0b01;
    }
    if (b1) {
        b |= 0b10;
    }
    return b;
}
覆蓋指標 測試用例 覆蓋率 備註信息
行覆蓋(Line Coverage) combine(true, true) 100% 每一行執行語句都被執行到
分支覆蓋(Branch Coverage) combine(false, false)combine(true, true) 100% 每一個代碼分支都被執行到
條件覆蓋(Condition Coverage) combine(false, true)combine(true, false) 100% 每一個條件子表達式都被執行到
路徑覆蓋(Path Coverage) combine(false, false)combine(false, true)combine(true, false)combine(true, true) 100% 每一個代碼分支組合都被執行到

單元測試覆蓋率,只能代表被測代碼的類、方法、執行語句、代碼分支、條件子表達式等是否被執行,但是並不能代表這些代碼是否被正確地執行並返回了正確的結果。所以,只看單元測試覆蓋率,而不看單元測試有效性,是沒有任何意義的。

3.2. 單元測試編寫流程

首先,介紹一下作者總結的單元測試編寫流程:

3.2.1. 定義對象階段

定義對象階段主要包括:定義被測對象、模擬依賴對象(類成員)、注入依賴對象(類成員)。

 

3.2.2. 模擬方法階段

模擬方法階段主要包括:模擬依賴對象(參數、返回值和異常)、模擬依賴方法。

 

3.2.3. 調用方法階段

調用方法階段主要包括:模擬依賴對象(參數)、調用被測方法、驗證參數對象(返回值和異常)。

 

3.2.4. 驗證方法階段

驗證方法階段主要包括:驗證依賴方法、驗證數據對象(參數)、驗證依賴對象 。

 

3.3. 是否可以偷工減料

針對單元測試編寫流程的階段和方法,在不影響單元測試覆蓋率的情況,我們是否可以進行一些偷工減料。

測試階段 測試方法 可否偷減 主要原因
1.定義對象階段 ①定義測試對象 不可以 不定義測試對象,根本無法進行測試
  ②定義依賴對象(類成員) 不可以 不定義依賴對象(類成員),測試時會拋出空指針或無法進入期望分支
  ③注入依賴對象(類成員) 不可以 不注入依賴對象(類成員),測試會拋出空指針異常或無法進入期望分支
2.模擬方法階段 ②模擬依賴對象(參數、返回值和異常) 不可以 不模擬依賴對象(參數、返回值和異常),無法進入期望分支
  ④模擬依賴方法 不可以 不模擬模擬依賴方法,無法進入期望分支
3.調用方法階段 ②模擬依賴對象(參數) 不可以 不模擬依賴對象(參數),無法進入期望分支
  ⑤調用測試方法 不可以 不執行調用測試方法,根本無法進行測試
  ⑦驗證數據對象(返回值和異常) 可以 不驗證驗證數據對象(返回值和異常),對單元測試覆蓋率無影響
4.驗證方法階段 ⑥驗證依賴方法 可以 不驗證依賴方法,對單元測試覆蓋率無影響
  ⑦驗證數據對象(參數) 可以 不驗證數據對象(參數),對單元測試覆蓋率無影響
  ⑧驗證依賴對象 可以 不驗證驗證依賴對象,對單元測試覆蓋率無影響

3.4. 最終可以得出結論

通過上表格,可以得出結論,偷工減料主要集中在驗證階段

  1. 調用方法階段
    1. 驗證數據對象(返回值和異常)

 

  1. 驗證方法階段
    1. 驗證依賴方法
    2. 驗證數據對象(參數)
    3. 驗證依賴對象

 

通過一些合併和拆分,後續將從以下三部分展開:

  1. 驗證數據對象(包括屬性、參數和返回值);
  2. 驗證拋出異常;
  3. 驗證依賴方法(包括依賴方法和依賴對象)。

4. 驗證數據對象

在單元測試中,驗證數據對象是爲了驗證是否傳入了期望的參數值、返回了期望的返回值、設置了期望的屬性值。

4.1. 數據對象來源方式

在單元測試中,需要驗證的數據對象主要有以下幾種來源。

4.1.1. 來源於被測方法的返回值

數據對象來源於調用被測方法的返回值,例如:

PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);

4.1.2. 來源於依賴方法的參數捕獲

數據對象來源於驗證依賴方法的參數捕獲,例如:

ArgumentCaptor<UserDO> userCreateCaptor = ArgumentCaptor.forClass(UserDO.class);
Mockito.verify(userDAO).create(userCreateCaptor.capture());
UserDO userCreate = userCreateCaptor.getValue();

4.1.3. 來源於被測對象的屬性值

數據對象來源於獲取被測對象的屬性值,例如:

userService.loadRoleMap();
Map<Long, String> roleMap = Whitebox.getInternalState(userService, "roleMap");

4.1.4. 來源於請求參數的屬性值

數據對象來源於獲取請求參數的屬性值,例如:

OrderContext orderContext = new OrderContext();
orderContext.setOrderId(12345L);
orderService.supplyProducts(orderContext);
List<ProductDO> productList = orderContext.getProductList();

當然,數據對象還有其它來源方式,這裏就不再一一舉例了。

4.2. 數據對象驗證方式

在調用被測方法時,需要對返回值和異常進行驗證;在驗證方法調用時,也需要對捕獲的參數值進行驗證。

4.2.1. 驗證數據對象空值

JUnit提供Assert.assertNull和Assert.assertNotNull方法來驗證數據對象空值。

// 1. 驗證數據對象爲空
Assert.assertNull("用戶標識必須爲空", userId);

// 2. 驗證數據對象非空
Assert.assertNotNull("用戶標識不能爲空", userId);

4.2.2. 驗證數據對象布爾值

JUnit提供Assert.assertTrue和Assert.assertFalse方法來驗證數據對象布爾值的真假。

// 1. 驗證數據對象爲真
Assert.assertTrue("返回值必須爲真", NumberHelper.isPositive(1));

// 2. 驗證數據對象爲假
Assert.assertFalse("返回值必須爲假", NumberHelper.isPositive(-1));

4.2.3. 驗證數據對象引用

JUnit提供Assert.assertSame和Assert.assertNotSame方法來驗證數據對象引用是否一致。

// 1. 驗證數據對象一致
Assert.assertSame("用戶必須一致", expectedUser, actualUser);

// 2. 驗證數據對象不一致
Assert.assertNotSame("用戶不能一致", expectedUser, actualUser);

4.2.4. 驗證數據對象取值

JUnit提供Assert.assertEquals、Assert.assertNotEquals、Assert.assertArrayEquals方法組,可以用來驗證數據對象值是否相等。

// 1. 驗證簡單數據對象
Assert.assertNotEquals("用戶名稱不一致", "admin", userName);
Assert.assertEquals("賬戶金額不一致", 10000.0D, accountAmount, 1E-6D);

// 2. 驗證簡單集合對象
Assert.assertArrayEquals("用戶標識列表不一致", new Long[] {1L, 2L, 3L}, userIds);
Assert.assertEquals("用戶標識列表不一致", Arrays.asList(1L, 2L, 3L), userIdList);

// 3. 驗證複雜數據對象
Assert.assertEquals("用戶標識不一致", Long.valueOf(1L), user.getId());
Assert.assertEquals("用戶名稱不一致", "admin", user.getName());
...

// 4. 驗證複雜集合對象
Assert.assertEquals("用戶列表長度不一致", expectedUserList.size(), actualUserList.size());
UserDO[] expectedUsers = expectedUserList.toArray(new UserDO[0]);
UserDO[] actualUsers = actualUserList.toArray(new UserDO[0]);
for (int i = 0; i < actualUsers.length; i++) { 
     Assert.assertEquals(String.format("用戶 (%s) 標識不一致", i), expectedUsers[i].getId(), actualUsers[i].getId()); 
     Assert.assertEquals(String.format("用戶 (%s) 名稱不一致", i), expectedUsers[i].getName(), actualUsers[i].getName());
     ...
};

// 5. 通過序列化驗證數據對象
String text = ResourceHelper.getResourceAsString(getClass(), "userList.json");
Assert.assertEquals("用戶列表不一致", text, JSON.toJSONString(userList));;

// 6. 驗證數據對象私有屬性字段
Assert.assertEquals("基礎包不一致", "com.alibaba.example", Whitebox.getInternalState(configurer, "basePackage"));

當然,數據對象還有其它驗證方法,這裏就不再一一舉例了。

4.3. 驗證數據對象問題

這裏,以分頁查詢公司用戶爲例,來說明驗證數據對象時所存在的問題。

代碼案例:

public PageDataVO<UserVO> queryUser(Long companyId, Long startIndex, Integer pageSize) {
    // 查詢用戶數據
    // 查詢用戶數據: 總共數量
    Long totalSize = userDAO.countByCompany(companyId);
    // 查詢接口數據: 數據列表
    List<UserVO> dataList = null;
    if (NumberHelper.isPositive(totalSize)) {
        List<UserDO> userList = userDAO.queryByCompany(companyId, startIndex, pageSize);
        dataList = userList.stream().map(UserService::convertUser)
            .collect(Collectors.toList());
    }

    // 返回分頁數據
    return new PageDataVO<>(totalSize, dataList);
}
private static UserVO convertUser(UserDO userDO) {
    UserVO userVO = new UserVO();
    userVO.setId(userDO.getId());
    userVO.setName(userDO.getName());
    userVO.setDesc(userDO.getDesc());
    ...
    return userVO;
}

4.3.1. 不驗證數據對象

反面案例:

很多人爲了偷懶,對數據對象不進行任何驗證。

// 調用測試方法
userService.queryUser(companyId, startIndex, pageSize);

存在問題:

無法驗證數據對象是否正確,比如被測代碼進行了以下修改:

// 返回分頁數據
return null;

4.3.2. 驗證數據對象非空

反面案例:

既然不驗證數據對象有問題,那麼我就簡單地驗證一下數據對象非空。

// 調用測試方法
PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
Assert.assertNotNull("分頁數據不爲空", pageData);

存在問題:

無法驗證數據對象是否正確,比如被測代碼進行了以下修改:

// 返回分頁數據
return new PageDataVO<>();

4.3.3. 驗證數據對象部分屬性

反面案例:

既然簡單地驗證數據對象非空不行,那麼我就驗證數據對象的部分屬性。

// 調用測試方法
PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
Assert.assertEquals("數據總量不爲空", totalSize, pageData.getTotalSize());

存在問題:

無法驗證數據對象是否正確,比如被測代碼進行了以下修改:

// 返回分頁數據
return new PageDataVO<>(totalSize, null);

4.3.4. 驗證數據對象全部屬性

反面案例:

驗證數據對象部分屬性也不行,那我驗證數據對象所有屬性總行了吧。

// 調用測試方法
PageDataVO<UserVO> pageData = userService.queryUser(companyId);
Assert.assertEquals("數據總量不爲空", totalSize, pageData.getTotalSize());
Assert.assertEquals("數據列表不爲空", dataList, pageData.getDataList());

存在問題:

上面的代碼看起來很完美,驗證了PageDataVO中兩個屬性值totalSize和dataList。但是,如果有一天在PageDataVO中添加了startIndex和pageSize,就無法驗證這兩個新屬性是否賦值正確。代碼如下:

// 返回分頁數據
return new PageDataVO<>(startIndex, pageSize, totalSize, dataList);

備註:本方法僅適用於屬性字段不可變的數據對象

4.3.5. 完美地驗證數據對象

對於數據對象屬性字段新增,有沒有完美的驗證方案?有的!答案就是利用JSON序列化,然後比較JSON文本內容。如果數據對象新增了屬性字段,必然會提示JSON字符串不一致。

完美案例:

// 調用測試方法
PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
text = ResourceHelper.getResourceAsString(getClass(), path + "pageData.json");
Assert.assertEquals("分頁數據不一致", text, JSON.toJSONString(pageData));

備註:本方法僅適用於屬性字段可變的數據對象。

4.4. 模擬數據對象準則

由於沒有模擬數據對象章節,這裏在驗證數據對象章節中插入了模擬數據對象準則。

4.4.1. 除觸發條件分支外,模擬對象所有屬性值不能爲空

在上一節中,我們展示瞭如何完美地驗證數據對象。但是,這種方法真正完美嗎?答案是否定。

比如:我們把userDAO.queryByCompany方法返回的uesrList的所有UserDO對象的屬性值name和desc賦值爲空,再把convertUser方法的name和desc賦值做一下交換,上面的單元測試用例是無法驗證出來的。

private static UserVO convertUser(UserDO userDO) {
    UserVO userVO = new UserVO();
    userVO.setId(userDO.getId());
    userVO.setName(userDO.getDesc());
    userVO.setDesc(userDO.getName());
    ...
    return userVO;
}

所以,在單元測試中,除觸發條件分支外,模擬對象所有屬性值不能爲空。

4.4.2. 新增數據類屬性字段時,必須模擬數據對象的屬性值

在上面的案例中,如果UserDO和UserVO新增了屬性字段age(用戶年齡),且新增了賦值語句如下:

userVO.setAge(userDO.getAge());

如果還是用原有的數據對象執行單元測試,我們會發現單元測試用例執行通過。這是因爲,由於屬性字段age爲空,賦值不賦值沒有任何差別。所以,新增屬性類屬性字段是,必須模擬數據對象的屬性值。

注意:如果用JSON字符串對比,且設置輸出空字段,是可以觸發單元測試用例執行失敗的。

4.5. 驗證數據對象準則

4.5.1. 必須驗證所有數據對象

在單元測試中,必須驗證所有數據對象:

  1. 來源於被測方法的返回值
  2. 來源於依賴方法的參數捕獲
  3. 來源於被測對象的屬性值
  4. 來源於請求參數的屬性值。

具體案例可以參考《數據對象來源方式》章節。

4.5.2. 必須使用明確語義的斷言

在使用斷言驗證數據對象時,必須使用確定語義的斷言,不能使用不明確語義的斷言。

正例:

Assert.assertTrue("返回值不爲真", NumberHelper.isPositive(1));
Assert.assertEquals("用戶不一致", user, userService.getUser(userId));

反例:

Assert.assertNotNull("用戶不能爲空", userService.getUser(userId));
Assert.assertNotEquals("用戶不能一致", user, userService.getUser(userId));

謹防一些試圖繞過本條準則的案例,試圖用明確語義的斷言去做不明確語義的判斷。

Assert.assertTrue("用戶不能爲空", Objects.nonNull(userService.getUser(userId)));

4.5.3. 儘量採用整體驗證方式

如果一個模型類,會根據業務需要新增字段。那麼,針對這個模型類所對應的數據對象,儘量採用整體驗證方式。

正例:

UserVO user = userService.getUser(userId);
String text = ResourceHelper.getResourceAsString(getClass(), path + "user.json");
Assert.assertEquals("用戶不一致", text, JSON.toJSONString(user));

反例:

UserVO user = userService.getUser(userId);
Assert.assertEquals("用戶標識不一致", Long.valueOf(123L), user.getId());
Assert.assertEquals("用戶名稱不一致", "changyi", user.getName());
...

上面這種數據驗證方式,如果模型類刪除了屬性字段,是可以驗證出來的。但是,如果模型類添加了字段,是無法驗證出來的。所以,如果採用了這種驗證方式,在新增了模型類屬性字段後,需要梳理並補全測試用例。否則,在使用單元測試用例迴歸代碼時,它將會告訴你這裏沒有任何問題

5. 驗證拋出異常

異常作爲Java語言的重要特性,是Java語言健壯性的重要體現。捕獲並驗證拋出異常,也是測試用例的一種。所以,在單元測試中,也需要對拋出異常進行驗證。

5.1. 拋出異常來源方式

5.1.1. 來源於屬性字段的判斷

判斷屬性字段是否非法,否則拋出異常。

private Map<String, MessageHandler> messageHandlerMap = ...;
public void handleMessage(Message message) {
    ...
    // 判斷處理器映射非空
    if (CollectionUtils.isEmpty(messageHandlerMap)) {
        throw new ExampleException("消息處理器映射不能爲空");
    }
    ...
}

5.1.2. 來源於輸入參數的判斷

判斷輸入參數是否合法,否則拋出異常。

public void handleMessage(Message message) {
    ...
    // 判斷獲取處理器非空
    MessageHandler messageHandler = messageHandlerMap.get(message.getType());
    if (CollectionUtils.isEmpty(messageHandler)) {
        throw new ExampleException("獲取消息處理器不能爲空");
    }
    ...
}

注意:這裏採用的是Spring框架提供的Assert類,跟if-throw語句的效果一樣。

5.1.3. 來源於返回值的判斷

判斷返回值是否合法,否則拋出異常。

public void handleMessage(Message message) {
    ...
    // 進行消息處理器處理
    boolean result = messageHandler.handleMessage(message);
    if (!reuslt) {
        throw new ExampleException("處理消息異常");
    }
    ...
}

5.1.4. 來源於模擬方法的調用

調用模擬的依賴方法時,可能模擬的依賴方法會拋出異常。

public void handleMessage(Message message) {
    ...
    // 進行消息處理器處理
    boolean result = messageHandler.handleMessage(message); // 直接拋出異常
    ...
}

這裏,可以進行異常捕獲處理,或打印輸出日誌,或繼續拋出異常。

5.1.5. 來源於靜態方法的調用

有時候,靜態方法調用也有可能拋出異常。

// 可能會拋出IOException
String response = HttpHelper.httpGet(url, parameterMap);

除此之外,還有別的拋出異常來源方式,這裏不再累述。

5.2. 拋出異常驗證方式

在單元測試中,通常存在四種驗證拋出異常方法。

5.2.1. 通過try-catch語句驗證拋出異常

Java單元測試用例中,最簡單直接的異常捕獲方式就是使用try-catch語句。

@Test
public void testCreateUserWithException() {
    // 模擬依賴方法
    Mockito.doReturn(true).when(userDAO).existName(Mockito.anyString());

    // 調用測試方法
    UserCreateVO userCreate = new UserCreateVO();
    try {
        userCreate.setName("changyi");
        userCreate.setDescription("Java Programmer");
        userService.createUser(userCreate);
    } catch (ExampleException e) {
        Assert.assertEquals("異常編碼不一致", ErrorCode.OBJECT_EXIST, e.getCode());
        Assert.assertEquals("異常消息不一致", "用戶已存在", e.getMessage());
    }

    // 驗證依賴方法
    Mockito.verify(userDAO).existName(userCreate.getName());
}

5.2.2. 通過@Test註解驗證拋出異常

JUnit的@Test註解提供了一個expected屬性,可以指定一個期望的異常類型,用來捕獲並驗證異常。

@Test(expected = ExampleException.class)
public void testCreateUserWithException() {
    // 模擬依賴方法
    Mockito.doReturn(true).when(userDAO).existName(Mockito.anyString());

    // 調用測試方法
    UserCreateVO userCreate = new UserCreateVO();
    userCreate.setName("changyi");
    userCreate.setDescription("Java Programmer");
    userService.createUser(userCreate);

    // 驗證依賴方法(不會執行)
    Mockito.verify(userDAO).existName(userCreate.getName());
}

注意:測試用例在執行到 userService.createUser方法後將跳出方法,導致後續驗證語句無法執行。所以,這種方式無法驗證異常編碼、消息、原因等內容,也無法驗證依賴方法及其參數。

5.2.3. 通過@Rule註解驗證拋出異常

如果想要驗證異常原因和消息,就需求採用@Rule註解定義ExpectedException對象,然後在測試方法的前面聲明要捕獲的異常類型、原因和消息。

@Rule
public ExpectedException exception = ExpectedException.none();
@Test
public void testCreateUserWithException1() {
    // 模擬依賴方法
    Mockito.doReturn(true).when(userDAO).existName(Mockito.anyString());

    // 調用測試方法
    UserCreateVO userCreate = new UserCreateVO();
    userCreate.setName("changyi");
    userCreate.setDescription("Java Programmer");
    exception.expect(ExampleException.class);
    exception.expectMessage("用戶已存在");
    userService.createUser(userCreate);

    // 驗證依賴方法(不會執行)
    Mockito.verify(userDAO).existName(userCreate.getName());
}

注意:測試用例在執行到 userService.createUser方法後將跳出方法,導致後續驗證語句無法執行。所以,這種方式無法驗證依賴方法及其參數。由於ExpectedException的驗證方法只支持驗證異常類型、原因和消息,無法驗證異常的自定義屬性字段值。目前,JUnit官方建議使用Assert.assertThrows替換。

5.2.4. 通過Assert.assertThrows方法驗證拋出異常

在最新版的JUnit中,提供了一個更爲簡潔的異常驗證方式——Assert.assertThrows方法。

@Test
public void testCreateUserWithException() {
    // 模擬依賴方法
    Mockito.doReturn(true).when(userDAO).existName(Mockito.anyString());

    // 調用測試方法
    UserCreateVO userCreate = new UserCreateVO();
    userCreate.setName("changyi");
    userCreate.setDescription("Java Programmer");
    ExampleException exception = Assert.assertThrows("異常類型不一致", ExampleException.class, () -> userService.createUser(userCreate));
    Assert.assertEquals("異常編碼不一致", ErrorCode.OBJECT_EXIST, exception.getCode());
    Assert.assertEquals("異常消息不一致", "用戶已存在", exception.getMessage());

    // 驗證依賴方法
    Mockito.verify(userDAO).existName(userCreate.getName());
}

5.2.5. 四種拋出異常驗證方式對比

根據不同的驗證異常功能項,對四種拋出異常驗證方式對比。結果如下:

對比內容 try-catch語句 @Test註解 @Rule註解 Assert.assertThrows方法
驗證異常類型 支持 支持 支持 支持
驗證異常消息 支持 不支持 支持 支持
驗證異常原因 支持 不支持 支持 支持
驗證自定義屬性 支持 不支持 不支持 支持
驗證依賴方法及其參數 支持 不支持 不支持 支持
單元測試代碼優雅性 不優雅 優雅 不優雅 優雅
JUnit官方推薦使用 不推薦 推薦 不推薦 推薦

綜上所述,採用Assert.assertThrows方法驗證拋出異常是最佳的,也是JUnit官方推薦使用的。

5.3. 驗證拋出異常問題

這裏,以創建用戶時拋出異常爲例,來說明驗證拋出異常時所存在的問題。

代碼案例:

private UserDAO userDAO;
public void createUser(@Valid UserCreateVO userCreateVO) {
    try {
        UserDO userCreateDO = new UserDO();
        userCreateDO.setName(userCreateVO.getName());
        userCreateDO.setDesc(userCreateVO.getDesc());
        userDAO.create(userCreateDO);
    } catch (RuntimeException e) {
        log.error("創建用戶異常: userName={}", userName, e)
        throw new ExampleException(ErrorCode.DATABASE_ERROR, "創建用戶異常", e);
    }
}

5.3.1. 不驗證拋出異常類型

反面案例:

在驗證拋出異常時,很多人使用@Test註解的expected屬性,並且指定取值爲Exception.class,主要原因是:

  1. 單元測試用例的代碼簡潔,只有一行@Test註解;
  2. 不管拋出什麼異常,都能保證單元測試用例通過。
@Test(expected = Exception.class)
public void testCreateUserWithException() {
    // 模擬依賴方法
    Throwable e = new RuntimeException();
    Mockito.doThrow(e).when(userDAO).create(Mockito.any(UserCreateVO.class));

    // 調用測試方法
    UserCreateVO userCreateVO = ...;
    userService.createUser(userCreate);
}

存在問題:

上面用例指定了通用異常類型,沒有對拋出異常類型進行驗證。所以,如果把ExampleException異常改爲RuntimeException異常,該單元測試用例是無法驗證出來的。

throw new RuntimeException("創建用戶異常", e);

5.3.2. 不驗證拋出異常屬性

反面案例:

既然需要驗證異常類型,簡單地指定@Test註解的expected屬性爲ExampleException.class即可。

@Test(expected = ExampleException.class)
public void testCreateUserWithException() {
    // 模擬依賴方法
    Throwable e = new RuntimeException();
    Mockito.doThrow(e).when(userDAO).create(Mockito.any(UserCreateVO.class));

    // 調用測試方法
    UserCreateVO userCreateVO = ...;
    userService.createUser(userCreate);
}

存在問題:

上面用例只驗證了異常類型,沒有對拋出異常屬性字段(異常消息、異常原因、錯誤編碼等)進行驗證。所以,如果把錯誤編碼DATABASE_ERROR(數據庫錯誤)改爲PARAMETER_ERROR(參數錯誤),該單元測試用例是無法驗證出來的。

throw new ExampleException(ErrorCode.PARAMETER_ERROR, "創建用戶異常", e);

5.3.3. 只驗證拋出異常部分屬性

反面案例:

如果要驗證異常屬性,就必須用Assert.assertThrows方法捕獲異常,並對異常的常用屬性進行驗證。但是,有些人爲了偷懶,只驗證拋出異常部分屬性。

// 模擬依賴方法
Throwable e = new RuntimeException();
Mockito.doThrow(e).when(userDAO).create(Mockito.any(UserCreateVO.class));

// 調用測試方法
UserCreateVO userCreateVO = ...;
ExampleException exception = Assert.assertThrows("異常類型不一致", ExampleException.class, () -> userService.createUser(userCreateVO));
Assert.assertEquals("異常編碼不一致", ErrorCode.DATABASE_ERROR, exception.getCode());

存在問題:

上面用例只驗證了異常類型和錯誤編碼,如果把錯誤消息"創建用戶異常"改爲"創建用戶錯誤",該單元測試用例是無法驗證出來的。

throw new ExampleException(ErrorCode.DATABASE_ERROR, "創建用戶錯誤", e);

5.3.4. 不驗證拋出異常原因

反面案例:

先捕獲拋出異常,再驗證異常編碼和異常消息,看起來很完美了。

// 模擬依賴方法
Throwable e = new RuntimeException();
Mockito.doThrow(e).when(userDAO).create(Mockito.any(UserCreateVO.class));

// 調用測試方法
UserCreateVO userCreateVO = ...;
ExampleException exception = Assert.assertThrows("異常類型不一致", ExampleException.class, () -> userService.createUser(userCreateVO));
Assert.assertEquals("異常編碼不一致", ErrorCode.OBJECT_EXIST, exception.getCode());
Assert.assertEquals("異常消息不一致", “創建用戶異常”, exception.getMessage());

存在問題:

通過代碼可以看出,在拋出ExampleException異常時,最後一個參數e是我們模擬的userService.createUser方法拋出的RuntimeException異常。但是,我們沒有對拋出異常原因進行驗證。如果修改代碼,把最後一個參數e去掉,上面的單元測試用例是無法驗證出來的。

throw new ExampleException(ErrorCode.DATABASE_ERROR, "創建用戶異常");

5.3.5. 不驗證相關方法調用

反面案例:

很多人認爲,驗證拋出異常就只驗證拋出異常,驗證依賴方法調用不是必須的。

// 模擬依賴方法
Throwable e = new RuntimeException();
Mockito.doThrow(e).when(userDAO).create(Mockito.any(UserCreateVO.class));

// 調用測試方法
UserCreateVO userCreateVO = ...;
ExampleException exception = Assert.assertThrows("異常類型不一致", ExampleException.class, () -> userService.createUser(userCreateVO));
Assert.assertEquals("異常編碼不一致", ErrorCode.OBJECT_EXIST, exception.getCode());
Assert.assertEquals("異常消息不一致", “創建用戶異常”, exception.getMessage());
Assert.assertEquals("異常原因不一致", e, exception.getCause());

存在問題:

如果不驗證相關方法調用,如何能證明代碼走過這個分支?比如:我們在創建用戶之前,檢查用戶名稱無效並拋出異常。

// 檢查用戶名稱有效
String userName = userCreateVO.getName();
if (StringUtils.length(userName) < USER_NAME_LENGTH) {
    throw new ExampleException(ErrorCode.INVALID_USERNAME, "無效用戶名稱");
}

5.3.6. 完美地驗證拋出異常

一個完美的異常驗證,除對異常類型、異常屬性、異常原因等進行驗證外,還需對拋出異常前的依賴方法調用進行驗證。

完美案例:

// 模擬依賴方法
Throwable e = new RuntimeException();
Mockito.doThrow(e).when(userDAO).create(Mockito.any(UserCreateVO.class));

// 調用測試方法
String text = ResourceHelper.getResourceAsString(getClass(), path + "userCreateVO.json");
UserCreateVO userCreateVO = JSON.parseObject(text, UserCreateVO.class);
ExampleException exception = Assert.assertThrows("異常類型不一致", ExampleException.class, () -> userService.createUser(userCreateVO));
Assert.assertEquals("異常編碼不一致", ErrorCode.OBJECT_EXIST, exception.getCode());
Assert.assertEquals("異常消息不一致", “創建用戶異常”, exception.getMessage());
Assert.assertEquals("異常原因不一致", e, exception.getCause());

// 驗證依賴方法
ArgumentCaptor<UserDO> userCreateCaptor = ArgumentCaptor.forClass(UserDO.class);
Mockito.verify(userDAO).create(userCreateCaptor.capture());
text = ResourceHelper.getResourceAsString(getClass(), path + "userCreateDO.json");
Assert.assertEquals("用戶創建不一致", text, JSON.toJSONString(userCreateCaptor.getValue()));

5.4. 驗證拋出異常準則

5.4.1. 必須驗證所有拋出異常

在單元測試中,必須驗證所有拋出異常:

  1. 來源於屬性字段的判斷
  2. 來源於輸入參數的判斷
  3. 來源於返回值的判斷
  4. 來源於模擬方法的調用
  5. 來源於靜態方法的調用

具體內容可以參考《拋出異常來源方式》章節。

5.4.2. 必須驗證異常類型、異常屬性、異常原因

在驗證拋出異常時,必須驗證異常類型、異常屬性、異常原因等。

正例:

ExampleException exception = Assert.assertThrows("異常類型不一致", ExampleException.class, () -> userService.createUser(userCreateVO));
Assert.assertEquals("異常編碼不一致", ErrorCode.OBJECT_EXIST, exception.getCode());
Assert.assertEquals("異常消息不一致", "用戶已存在", exception.getMessage());
Assert.assertEquals("異常原因不一致", e, exception.getCause());

反例:

@Test(expected = ExampleException.class)
public void testCreateUserWithException() {
    ...
    userService.createUser(userCreateVO);
}

5.4.3. 驗證拋出異常後,必須驗證相關方法調用

在驗證拋出異常後,必須驗證相關方法調用,來保證單元測試用例走的是期望分支。

正例:

// 調用測試方法
...

// 驗證依賴方法
ArgumentCaptor<UserDO> userCreateCaptor = ArgumentCaptor.forClass(UserDO.class);
Mockito.verify(userDAO).create(userCreateCaptor.capture());
text = ResourceHelper.getResourceAsString(getClass(), path + "userCreateDO.json");
Assert.assertEquals("用戶創建不一致", text, JSON.toJSONString(userCreateCaptor.getValue()));

6. 驗證方法調用

在單元測試中,驗證方法調用是爲了驗證依賴方法的調用次數和順序以及是否傳入了期望的參數值。

6.1. 方法調用來源方式

6.1.1. 來源於注入對象的方法調用

最常見的方法調用就是對注入依賴對象的方法調用。

private UserDAO userDAO;
public UserVO getUser(Long userId) {
    UserDO user = userDAO.get(userId); // 方法調用
    return convertUser(user);
}

6.1.2. 來源於輸入參數的方法調用

有時候,也可以通過輸入參數傳入依賴對象,然後調用依賴對象的方法。

public <T> List<T> executeQuery(String sql, DataParser<T> dataParser) {
    List<T> dataList = new ArrayList<>();
    List<Record> recordList = SQLTask.getResult(sql);
    for (Record record : recordList) {
        T data = dataParser.parse(record); // 方法調用
        if (Objects.nonNull(data)) {
            dataList.add(data);
        }
    }
    return dataList;
}

6.1.3. 來源於返回值的方法調用

private UserHsfService userHsfService;
public User getUser(Long userId) {
    Result<User> result = userHsfService.getUser(userId);
    if (!result.isSuccess()) { // 方法調用1
        throw new ExampleException("獲取用戶異常");
    }
    return result.getData(); // 方法調用2
}

6.1.4. 來源於靜態方法的調用

在Java中,靜態方法是指被static修飾的成員方法,不需要通過對象實例就可以被調用。在日常代碼中,靜態方法調用一直佔有一定的比例。

String text = JSON.toJSONString(user); // 方法調用

6.2. 方法調用驗證方式

在單元測試中,驗證依賴方法調用是確認模擬對象的依賴方法是否被按照預期調用的過程。

6.2.1. 驗證依賴方法的調用參數

// 1.驗證無參數依賴方法調用
Mockito.verify(userDAO).deleteAll();

// 2.驗證指定參數依賴方法調用
Mockito.verify(userDAO).delete(userId);

// 3.驗證任意參數依賴方法調用
Mockito.verify(userDAO).delete(Mockito.anyLong());

// 4.驗證可空參數依賴方法調用
Mockito.verify(userDAO).queryCompany(Mockito.anyLong(), Mockito.nullable(Long.class));

// 5.驗證必空參數依賴方法調用
Mockito.verify(userDAO).queryCompany(Mockito.anyLong(), Mockito.isNull());

// 6.驗證可變參數依賴方法調用
Mockito.verify(userService).delete(1L, 2L, 3L);
Mockito.verify(userService).delete(Mockito.any(Long.class));  // 匹配一個
Mockito.verify(userService).delete(Mockito.<Long>any()); // 匹配多個

6.2.2. 驗證依賴方法的調用次數

// 1.驗證依賴方法默認調用1次
Mockito.verify(userDAO).delete(userId);

// 2.驗證依賴方法從不調用
Mockito.verify(userDAO, Mockito.never()).delete(userId);

// 3.驗證依賴方法調用n次
Mockito.verify(userDAO, Mockito.times(n)).delete(userId);

// 4.驗證依賴方法調用至少1次
Mockito.verify(userDAO, Mockito.atLeastOnce()).delete(userId);

// 5.驗證依賴方法調用至少n次
Mockito.verify(userDAO, Mockito.atLeast(n)).delete(userId);

// 6.驗證依賴方法調用最多1次
Mockito.verify(userDAO, Mockito.atMostOnce()).delete(userId);

// 7.驗證依賴方法調用最多n次
Mockito.verify(userDAO, Mockito.atMost(n)).delete(userId); 

// 8.驗證依賴方法調用指定n次
Mockito.verify(userDAO, Mockito.call(n)).delete(userId); // 不會被標記爲已驗證

// 9.驗證依賴對象及其方法僅調用1次
Mockito.verify(userDAO, Mockito.only()).delete(userId);

6.2.3. 驗證依賴方法並捕獲參數值

// 1.使用ArgumentCaptor.forClass方法定義參數捕獲器
ArgumentCaptor<UserDO> userCaptor = ArgumentCaptor.forClass(UserDO.class);
Mockito.verify(userDAO).modify(userCaptor.capture());
UserDO user = userCaptor.getValue();

// 2.使用@Captor註解定義參數捕獲器
@Captor
private ArgumentCaptor<UserDO> userCaptor;

// 3.捕獲多次方法調用的參數值列表
ArgumentCaptor<UserDO> userCaptor = ArgumentCaptor.forClass(UserDO.class);
Mockito.verify(userDAO, Mockito.atLeastOnce()).modify(userCaptor.capture());
List<UserDO> userList = userCaptor.getAllValues();

6.2.4. 驗證其它類型的依賴方法調用

// 1.驗證 final 方法調用
final方法的驗證跟普通方法類似。

// 2.驗證私有方法調用
PowerMockito.verifyPrivate(mockClass, times(1)).invoke("unload", any(List.class));

// 3.驗證構造方法調用
PowerMockito.verifyNew(MockClass.class).withNoArguments();
PowerMockito.verifyNew(MockClass.class).withArguments(someArgs);

// 4.驗證靜態方法調用
PowerMockito.verifyStatic(StringUtils.class);
StringUtils.isEmpty(string);

6.2.5. 驗證依賴對象沒有更多方法調用

// 1.驗證模擬對象沒有任何方法調用
Mockito.verifyNoInteractions(idGenerator, userDAO);

// 2.驗證模擬對象沒有更多方法調用
Mockito.verifyNoMoreInteractions(idGenerator, userDAO);

6.3. 驗證依賴方法問題

這裏,以cacheUser(緩存用戶)爲例,來說明驗證依賴方法時所存在的問題。

代碼案例:

private UserCache userCache;
public boolean cacheUser(List<User> userList) {
    boolean result = true;
    for (User user : userList) {
        result = result && userCache.set(user.getId(), user);
    }
    return result;
}

6.3.1. 不驗證依賴方法調用

反面案例:

有些人覺得,既然已經模擬了依賴方法,並且被測方法已經按照預期返回了值,就沒有必要對依賴方法進行驗證。

// 模擬依賴方法
Mockito.doReturn(true).when(userCache).set(Mockito.anyLong(), Mockito.any(User.class));

// 調用測試方法
List<User> userList = ...;
Assert.assertTrue("處理結果不爲真", userService.cacheUser(userList));

// 不驗證依賴方法

存在問題:

模擬了依賴方法,並且被測方法已經按照預期返回了值,並不代表這個依賴方法被調用或者被正確地調用。

比如:在for循環之前,把userList置爲空列表,這個單元測試用例是無法驗證出來的。

// 清除用戶列表
userList = Collections.emptyList();

6.3.2. 不驗證依賴方法調用次數

反面案例:

有些很喜歡用Mockito.verify的驗證至少一次和任意參數的組合,因爲它可以適用於任何依賴方法調用的驗證。

// 驗證依賴方法
Mockito.verify(userCache, Mockito.atLeastOnce()).set(Mockito.anyLong(), Mockito.any(User.class));

存在問題:

這種方法雖然適用於任何依賴方法調用的驗證,但是基本上沒有任何實質作用。

比如:我們不小心,把緩存語句寫了兩次,這個單元測試用例是無法驗證出來的。

// 寫了兩次緩存
result = result && userCache.set(user.getId(), user);
result = result && userCache.set(user.getId(), user);

6.3.3. 不驗證依賴方法調用參數

反面案例:

既然說驗證至少一次有問題,那我就指定一下驗證次數。

// 驗證依賴方法
Mockito.verify(userCache, Mockito.times(userList.size())).set(Mockito.anyLong(), Mockito.any(User.class));

存在問題:

驗證方法次數的問題雖然解決了,但是驗證方法參數的問題任然存在。

比如:我們不小心,把循環緩存每一個用戶寫成循環緩存第一個用戶,這個單元測試用例是無法驗證出來的。

User user = userList.get(0);
for (int i = 0; i < userList.size(); i++) {
    result = result && userCache.set(user.getId(), user);
}

6.3.4. 不驗證所有依賴方法調用

反面案例:

不能用任意參數驗證方法,那隻好用實際參數驗證方法了。但是,驗證所有依賴方法調用代碼太多,所以驗證一兩個依賴方法調用意思意思就行了。

Mockito.verify(userCache).set(user1.getId(), user1);
Mockito.verify(userCache).set(user2.getId(), user2);

存在問題:

如果只驗證了一兩個方法調用,只能保障這一兩個方法調用沒有問題。

比如:我們不小心,在for循環之後,還進行了一個用戶緩存。

// 緩存最後一個用戶
User user = userList.get(userList.size() - 1);
userCache.set(user.getId(), user);

6.3.5. 驗證所有依賴方法調用

反面案例:

既然不驗證所有方法調用有問題,那我就把所有方法調用驗證了吧。

for (User user : userList) {
    Mockito.verify(userCache).set(user.getId(), user);
}

存在問題:

所有方法調用都被驗證了,看起來應該沒有問題了。但是,如果緩存用戶方法中,存在別的方法調用。

比如:我們在進入緩存用戶方法之前,新增了清除所有用戶緩存,這個單元測試用是無法驗證的。

// 刪除所有用戶緩存
userCache.clearAll();

6.3.6. 完美地驗證依賴方法調用

驗證所有的方法調用,只能保證現在的邏輯沒有問題。如果涉及新增方法調用,這個單元測試用例是無法驗證出來的。所有,我們需要驗證所有依賴對象沒有更多方法調用。

完美案例:

// 驗證依賴方法
ArgumentCaptor<Long> userIdCaptor = ArgumentCaptor.forClass(Long.class);
ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
Mockito.verify(userCache, Mockito.atLeastOnce()).set(userIdCaptor.capture(), userCaptor.capture());
Assert.assertEquals("用戶標識列表不一致", userIdList, userIdCaptor.getAllValues());
Assert.assertEquals("用戶信息列表不一致", userList, userCaptor.getAllValues());

// 驗證依賴對象
Mockito.verifyNoMoreInteractions(userCache);

注意:利用ArgumentCaptor(參數捕獲器),不但可以驗證參數,還可以驗證調用次數和順序。

6.4. 驗證方法調用準則

6.4.1. 必須驗證所有的模擬方法調用

在單元測試中,涉及到的所有模擬方法都要被驗證:

  1. 來源於注入對象的方法調用
  2. 來源於輸入參數的方法調用
  3. 來源於返回值的方法調用
  4. 來源於靜態方法的調用

具體案例可以參考《方法調用來源方式》章節。

6.4.2. 必須驗證所有的模擬對象沒有更多方法調用

在單元測試中,爲了防止被測方法中存在或新增別的方法調用,必須驗證所有的模擬對象沒有更多方法調用。

正例:

// 驗證依賴對象
Mockito.verifyNoMoreInteractions(userDAO, userCache);

備註:

作者喜歡在@After方法中對所有模擬對象進行驗證,這樣就不必在每個單元測試用例中驗證模擬對象。

@After
public void afterTest() {
    Mockito.verifyNoMoreInteractions(userDAO, userCache);
}

可惜Mockito.verifyNoMoreInteractions不支持無參數就驗證所有模擬對象的功能,否則這段代碼會變得更簡潔。

6.4.3. 必須使用明確語義的參數值或匹配器

驗證依賴方法時,必須使用明確語義的參數值或匹配器,不能使用任何不明確語義的匹配器,比如:any系列參數匹配器。

正例:

Mockito.verify(userDAO).get(userId);
Mockito.verify(userDAO).query(Mockito.eq(companyId), Mockito.isNull());

反例:

Mockito.verify(userDAO).get(Mockito.anyLong());
Mockito.verify(userDAO).query(Mockito.anyLong(), Mockito.isNotNull());

後記

最後,根據本文所表達的觀點,即興賦詩七言絕句一首:

單元測試
單元測試分真假,
工匠精神貫始終。
覆蓋追求非目的,
迴歸驗證顯奇功。

意思是:

一定要知道如何去分辨單元測試的真假,
一定要把工匠精神貫徹單元測試的始終。
追求單測覆蓋率並不是單元測試的目的,
迴歸驗證代碼才能彰顯單元測試的功效。

原文鏈接

本文爲阿里雲原創內容,未經允許不得轉載

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章