Java編程技巧之單元測試用例簡化方法

前言

清代譴責小說家吳趼人在《痛史》中寫道:“卷帙浩繁,望而生畏。

意思是:“ 一部書的篇幅太長,讓人看見就害怕。”編寫單元測試用例也是如此,如果單元測試用例寫起來又長又複雜,自然而然地會讓人“望而生畏”,於是開始反感甚至於最終放棄。爲了便於Java單元測試的推廣,作者總結了十餘種測試用例的簡化方法,希望能夠讓大家編寫單元測試用例時——“化繁爲簡、下筆如神”。

1. 簡化模擬數據對象

1.1. 利用JSON反序列化簡化數據對象賦值語句

利用JSON反序列化,可以簡化大量的數據對象賦值語句。首先,加載JSON資源文件爲JSON字符串;然後,通過JSON反序列化JSON字符串爲數據對象;最後,用該數據對象來模擬類屬性值、方法參數值和方法返回值。

原始用例:

List<UserCreateVO> userCreateList = new ArrayList<>();
UserCreateVO userCreate0 = new UserCreateVO();
userCreate0.setName("Changyi");
... // 約幾十行
userCreateList.add(userCreate0);
UserCreateVO userCreate1 = new UserCreateVO();
userCreate1.setName("Tester");
... // 約幾十行
userCreateList.add(userCreate1);
... // 約幾十條
userService.batchCreate(userCreateList);

簡化用例:

String text = ResourceHelper.getResourceAsString(getClass(), path + "userCreateList.json");
List<UserCreateVO> userCreateList = JSON.parseArray(text, UserCreateVO.class);
userService.batchCreate(userCreateList);

1.2. 利用虛擬數據對象簡化返回值模擬語句

有時候,模擬的方法返回值在測試方法內部並不發生修改,只是起到一個透傳的作用而已。對於這種情況,我們只需要模擬一個對象實例,但並不關心其內部具體內容。所以,可以直接用虛擬數據對象替換真實數據對象,從而簡化了返回值模擬語句。

被測代碼:

@GetMapping("/get")
public ExampleResult<UserVO> getUser(@RequestParam(value = "userId", required = true) Long userId) {
    UserVO user = userService.getUser(userId);
    return ExampleResult.success(user);
}

原始用例:

// 模擬依賴方法
String path = RESOURCE_PATH + "testGetUser/";
String text = ResourceHelper.getResourceAsString(getClass(), path + "user.json");
UserVO user = JSON.parseObject(text, UserVO.class);
Mockito.doReturn(user).when(userService).getUser(user.getId());

// 調用測試方法
ExampleResult<UserVO> result = userController.getUser(user.getId());
Assert.assertEquals("結果編碼不一致", ResultCode.SUCCESS.getCode(), result.getCode());
Assert.assertEquals("結果數據不一致", user, result.getData());

簡化用例:

// 模擬依賴方法
Long userId = 12345L;
UserVO user = Mockito.mock(UserVO.class); // 也可以使用new UserVO()
Mockito.doReturn(user).when(userService).getUser(userId);

// 調用測試方法
ExampleResult<UserVO> result = userController.getUser(userId);
Assert.assertEquals("結果編碼不一致", ResultCode.SUCCESS.getCode(), result.getCode());
Assert.assertSame("結果數據不一致", user, result.getData());

1.3. 利用虛擬數據對象簡化參數值模擬語句

有時候,模擬的方法參數值在測試方法內部並不發生修改,只是起到一個透傳的作用而已。對於這種情況,我們只需要模擬一個對象實例,但並不關心其內部具體內容。所以,可以直接用虛擬數據對象替換真實數據對象,從而簡化參數值模擬語句。

被測代碼:

@GetMapping("/create")
public ExampleResult<Void> createUser(@Valid @RequestBody UserCreateVO userCreate) {
    userService.createUser(userCreate);
    return ExampleResult.success();
}

原始用例:

// 調用測試方法
String path = RESOURCE_PATH + "testCreateUser/";
String text = ResourceHelper.getResourceAsString(getClass(), path + "userCreate.json");
UserCreateVO userCreate = JSON.parseObject(text, UserCreateVO.class);
ExampleResult<Void> result = userController.createUser(userCreate);
Assert.assertEquals("結果編碼不一致", ResultCode.SUCCESS.getCode(), result.getCode());

// 驗證依賴方法
Mockito.verify(userService).createUser(userCreate);

簡化用例:

// 調用測試方法
UserCreateVO userCreate = Mockito.mock(UserCreateVO.class); // 也可以使用new UserCreateVO()
ExampleResult<Void> result = userController.createUser(userCreate);
Assert.assertEquals("結果編碼不一致", ResultCode.SUCCESS.getCode(), result.getCode());

// 驗證依賴方法
Mockito.verify(userService).createUser(userCreate);

2. 簡化模擬依賴方法

2.1. 利用默認返回值簡化模擬依賴方法

模擬對象的方法是具有默認返回值的:當方法返回類型爲基礎類型時,默認返回值是0或false;當方法返回類型爲對象類型時,默認返回值是null。在測試用例中,當需要模擬方法返回值爲上述默認值時,我們可以省略這些模擬方法語句。當然,顯式地寫上這些模擬方法語句,可以讓測試用例變得更便於理解。

原始用例:

Mockito.doReturn(false).when(userDAO).existName(userName);
Mockito.doReturn(0L).when(userDAO).countByCompany(companyId);
Mockito.doReturn(null).when(userDAO).queryByCompany(companyId, startIndex, pageSize);

簡化用例:

可以把以上模擬方法語句直接刪除。

2.2. 利用任意匹配參數簡化模擬依賴方法

在模擬依賴方法時,有些參數需要使用到後面加載的數據對象,比如下面案例中的UserCreateVO的name屬性值。這樣,我們就需要提前加載UserCreateVO對象,既讓模擬方法語句看起來比較繁瑣,又讓加載UserCreateVO對象語句和使用UserCreateVO對象語句分離(優秀的代碼,變量定義和初始化一般緊挨着變量使用代碼)。

利用任意匹配參數就可以解決這些問題,使測試用例變得更簡潔更便於維護。但是要注意,驗證該方法時,不能再用任意匹配參數去驗證,必須使用真實的值去驗證。

原始用例:

// 模擬依賴方法
String path = RESOURCE_PATH + "testCreateUserWithSuccess/";
String text = ResourceHelper.getResourceAsString(getClass(), path + "userCreateVO.json");
UserCreateVO userCreateVO = JSON.parseObject(text, UserCreateVO.class);
Mockito.doReturn(false).when(userDAO).existName(userCreateVO.getName());
...

// 調用測試方法
Assert.assertEquals("用戶標識不一致", userId, userService.createUser(userCreateVO));

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

簡化用例:

// 模擬依賴方法
Mockito.doReturn(false).when(userDAO).existName(Mockito.anyString());
...

// 調用測試方法
String path = RESOURCE_PATH + "testCreateUserWithSuccess/";
String text = ResourceHelper.getResourceAsString(getClass(), path + "userCreateVO.json");
UserCreateVO userCreateVO = JSON.parseObject(text, UserCreateVO.class);
Assert.assertEquals("用戶標識不一致", userId, userService.createUser(userCreateVO));

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

2.3. 利用do/thenAnswer簡化模擬依賴方法

當一個方法需要調用多次,但返回值跟調用順序無關,只跟輸入參數有關的時,可以用映射來模擬方法不同返回值。先加載一個映射JSON資源文件,通過JSON.parseObject方法轉化爲映射,然後利用Mockito的doAnswer-when或when-thenAnswer語法來模擬方法返回對應值(根據指定參數返回映射中的對應值)。

原始用例:

String text = ResourceHelper.getResourceAsString(getClass(), path + "user1.json");
UserDO user1 = JSON.parseObject(text, UserDO.class);
Mockito.doReturn(user1).when(userDAO).get(user1.getId());
text = ResourceHelper.getResourceAsString(getClass(), path + "user2.json");
UserDO user2 = JSON.parseObject(text, UserDO.class);
Mockito.doReturn(user2).when(userDAO).get(user2.getId());
...

簡化用例:

String text = ResourceHelper.getResourceAsString(getClass(), path + "userMap.json");
Map<Long, UserDO> userMap = JSON.parseObject(text, new TypeReference<Map<Long, UserDO>>() {});
Mockito.doAnswer(invocation -> userMap.get(invocation.getArgument(0))).when(userDAO).get(Mockito.anyLong());

2.4. 利用Mock參數簡化模擬鏈式調用方法

在日常編碼過程中,很多人都喜歡使用鏈式調用,這樣可以讓代碼變得更簡潔。對於鏈式調用,Mockito提供了更加簡便的單元測試方法——提供Mockito.RETURNS_DEEP_STUBS參數,實現鏈式調用的對象自動mock。

被測代碼:

public void addCorsMappings(CorsRegistry registry) {
    registry.addMapping("/**")
        .allowedOrigins("*")
        .allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
        .allowCredentials(true)
        .maxAge(MAX_AGE)
        .allowedHeaders("*");
}

原始用例:

正常情況下,每一個依賴對象及其調用方法都要mock,編寫的代碼如下:

@Test
public void testAddCorsMappings() {
    // 模擬依賴方法
    CorsRegistry registry = Mockito.mock(CorsRegistry.class);
    CorsRegistration registration = Mockito.mock(CorsRegistration.class);
    Mockito.doReturn(registration).when(registry).addMapping(Mockito.anyString());
    Mockito.doReturn(registration).when(registration).allowedOrigins(Mockito.any());
    Mockito.doReturn(registration).when(registration).allowedMethods(Mockito.any());
    Mockito.doReturn(registration).when(registration).allowCredentials(Mockito.anyBoolean());
    Mockito.doReturn(registration).when(registration).maxAge(Mockito.anyLong());
    Mockito.doReturn(registration).when(registration).allowedHeaders(Mockito.any());

    // 調用測試方法
    webAuthInterceptConfig.addCorsMappings(registry);

    // 驗證依賴方法
    Mockito.verify(registry).addMapping("/**");
    Mockito.verify(registration).allowedOrigins("*");
    Mockito.verify(registration).allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS");
    Mockito.verify(registration).allowCredentials(true);
    Mockito.verify(registration).maxAge(3600L);
    Mockito.verify(registration).allowedHeaders("*");
}

簡化用例:

利用Mockito.RETURNS_SELF參數編寫的測試用例如下:

@Test
public void testAddCorsMapping() {
    // 模擬依賴方法
    CorsRegistry registry = Mockito.mock(CorsRegistry.class);
    CorsRegistration registration = Mockito.mock(CorsRegistration.class, Answers.RETURNS_SELF);
    Mockito.doReturn(registration).when(registry).addMapping(Mockito.anyString());

    // 調用測試方法
    webAuthInterceptConfig.addCorsMappings(registry);

    // 驗證依賴方法
    Mockito.verify(registry).addMapping("/**");
    Mockito.verify(registration).allowedOrigins("*");
    Mockito.verify(registration).allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS");
    Mockito.verify(registration).allowCredentials(true);
    Mockito.verify(registration).maxAge(3600L);
    Mockito.verify(registration).allowedHeaders("*");
}

代碼說明:

  1. 在mock對象時,對於自返回對象,需要指定Mockito.RETURNS_SELF參數;
  2. 在mock方法時,無需對自返回對象進行mock方法,因爲框架已經mock方法返回了自身;
  3. 在verify方法時,可以像普通測試法一樣優美地驗證所有方法調用。

參數說明:

在mock參數中,有2個參數適合於mock鏈式調用:

  1. RETURNS_SELF參數:mock調用方法語句最少,適合於鏈式調用返回相同值;
  2. RETURNS_DEEP_STUBS參數:mock調用方法語句較少,適合於鏈式調用返回不同值。

3. 簡化驗證數據對象

3.1. 利用JSON序列化簡化數據對象驗證語句

利用JSON反序列化,可以簡化大量的數據對象驗證語句。首先,加載JSON資源文件爲JSON字符串;然後,通過JSON序列化數據對象(方法返回值或方法參數值)爲JSON字符串;最後,再驗證兩個JSON字符串是否一致。

原始用例:

List<UserVO> userList = userService.queryByCompanyId(companyId);
UserVO user0 = userList.get(0);
Assert.assertEquals("name不一致", "Changyi", user0.getName());
... // 約幾十行
UserVO user1 = userList.get(1);
Assert.assertEquals("name不一致", "Tester", user1.getName());
... // 約幾十行
... // 約幾十條

簡化用例:

List<UserVO> userList = userService.queryByCompanyId(companyId);
String text = ResourceHelper.getResourceAsString(getClass(), path + "userList.json");
Assert.assertEquals("用戶列表不一致", text, JSON.toJSONString(userList));

小知識點:

  1. 如果數據對象中存在Map對象,爲了保證序列化後的字段順序一致,需要添加SerializerFeature.MapSortField特徵。
JSON.toJSONString(userMap, SerializerFeature.MapSortField);
  1. 如果數據對象中存在隨機對象,比如時間、隨機數等,需要使用過濾器過濾這些字段。

排除所有類的屬性字段:

List<UserVO> userList = ...;
SimplePropertyPreFilter filter = new SimplePropertyPreFilter();
filter.getExcludes().addAll(Arrays.asList("gmtCreate", "gmtModified"));
Assert.assertEquals("用戶信息不一致", text, JSON.toJSONString(user, filter));

排除單個類的屬性字段:

List<UserVO> userList = ...;
SimplePropertyPreFilter filter = new SimplePropertyPreFilter(UserVO.class);
filter.getExcludes().addAll(Arrays.asList("gmtCreate", "gmtModified"));
Assert.assertEquals("用戶信息不一致", text, JSON.toJSONString(user, filter));

排除多個類的屬性字段:

Pair<UserVO, CompanyVO> userCompanyPair = ...;
SimplePropertyPreFilter userFilter = new SimplePropertyPreFilter(UserVO.class);
userFilter.getExcludes().addAll(Arrays.asList("gmtCreate", "gmtModified"));
SimplePropertyPreFilter companyFilter = new SimplePropertyPreFilter(CompanyVO.class);
companyFilter.getExcludes().addAll(Arrays.asList("createTime", "modifyTime"));
Assert.assertEquals("用戶公司對不一致", text, JSON.toJSONString(userCompanyPair, new SerializeFilter[]{userFilter, companyFilter});

3.2. 利用數據對象相等簡化返回值驗證語句

在用Assert.assertEquals方法驗證返回值時,可以直接指定基礎類型值或數據對象實例。當數據對象實例不一致時,只要其數據對象相等(equals比較返回true),Assert.assertEquals方法也會認爲其是相等的。所以,可以利用數據對象相等來替換JSON字符串驗證,從而簡化測試方法返回值驗證語句。

原始用例:

List<Long> userIdList = userService.getAllUserIds(companyId);
String text = JSON.toJSONString(Arrays.asList(1L, 2L, 3L));
Assert.assertEquals("用戶標識列表不一致", text, JSON.toJSONString(userIdList));

簡化用例:

List<Long> userIdList = userService.getAllUserIds(companyId);
Assert.assertEquals("用戶標識列表不一致", Arrays.asList(1L, 2L, 3L), userIdList);

小知識點:

  1. Assert.assertSame用於相同類實例驗證——類實例相同;
  2. Assert.assertEquals用於相等類實例驗證——類實例相同或相等(equals爲true)。

注意:

這裏不建議爲了使用這個功能而重載equals方法,只建議針對相同或已重載equals方法的類實例使用。而針對未重載equals方法的類實例,建議轉化爲JSON字符串後再驗證。

3.3. 利用數據對象相等簡化參數值驗證語句

在用Mockito.verify方法驗證依賴方法參數時,可以直接指定基礎類型值或數據對象實例。當數據對象實例不一致時,只要其數據對象相等(equals比較返回true),Mockito.verify方法也會認爲其是相等的。所以,可以利用數據對象相等來替換ArgumentCaptor參數捕獲,從而簡化依賴方法參數值驗證語句。

原始用例:

ArgumentCaptor<List<Long>> userIdListCaptor = CastHelper.cast(ArgumentCaptor.forClass(List.class));
Mockito.verify(userDAO).batchDelete(userIdListCaptor.capture());
Assert.assertEquals("用戶標識列表不一致", Arrays.asList(1L, 2L, 3L), userIdListCaptor.getValue());

簡化用例:

Mockito.verify(userDAO).batchDelete(Arrays.asList(1L, 2L, 3L));

注意:

這裏不建議爲了使用這個功能而重載equals方法,只建議針對相同或已重載equals方法的類實例使用。而針對未重載equals方法的類實例,建議先捕獲參數轉化爲JSON字符串後再驗證。

4. 簡化驗證依賴方法

4.1. 利用ArgumentCaptor簡化驗證依賴方法

當一個模擬方法被多次調用時,需要對每一次模擬方法調用進行驗證,就顯得模擬方法驗證代碼過於繁瑣。這裏,可以通過ArgumentCaptor來捕獲參數值,然後通過getAllValues方法來獲取參數值列表,最後對參數值列表進行統一驗證即可。其中,即驗證了方法調用次數(列表長度),又驗證了方法參數值(列表數據)。

原始用例:

Mockito.verify(userDAO).get(user1.getId());
Mockito.verify(userDAO).get(user2.getId());
...

簡化用例:

ArgumentCaptor<Long> userIdCaptor = ArgumentCaptor.forClass(Long.class);
Mockito.verify(userDAO, Mockito.atLeastOnce()).get(userIdCaptor.capture());
text = ResourceHelper.getResourceAsString(getClass(), path + "userIdList.json");
Assert.assertEquals("用戶標識列表不一致", text, JSON.toJSONString(userIdCaptor.getAllValues()));

5. 簡化單元測試用例

5.1. 利用直接測試私有方法簡化單元測試用例

習慣性地,我們通過構造共有方法的測試用例,來覆蓋公有方法及其私有方法的所有分支。這種方式沒有問題,但有時候顯得測試用例比較繁瑣。我們可以直接測試私有方法,單獨對私有方法進行全覆蓋,從而減少對公有方法的測試用例。

被測代碼:

public UserVO getUser(Long userId) {
    // 獲取用戶信息
    UserDO userDO = userDAO.get(userId);
    if (Objects.isNull(userDO)) {
        throw new ExampleException(ErrorCode.OBJECT_NONEXIST, "用戶不存在");
    }
    
    // 返回用戶信息
    UserVO userVO = new UserVO();
    userVO.setId(userDO.getId());
    userVO.setName(userDO.getName());
    userVO.setVip(isVip(userDO.getRoleIdList()));
    ...
    return userVO;
}
private static boolean isVip(List<Long> roleIdList) {
    for (Long roleId : roleIdList) {
        if (VIP_ROLE_ID_SET.contains(roleId)) {
            return true;
        }
    }
    return false;
}

原始用例:

@Test
public void testGetUserWithVip() {
    // 模擬依賴方法
    String path = RESOURCE_PATH + "testGetUserWithVip/";
    String text = ResourceHelper.getResourceAsString(getClass(), path + "userDO.json");
    UserDO userDO = JSON.parseObject(text, UserDO.class);
    Mockito.doReturn(userDO).when(userDAO).get(userDO.getId());
    
    // 調用測試方法
    UserVO userVO = userService.getUser(userDO.getId());
    text = ResourceHelper.getResourceAsString(getClass(), path + "userVO.json");
    Assert.assertEquals("用戶信息不一致", text, JSON.toJSONString(userVO));
    
    // 驗證依賴方法
    Mockito.verify(userDAO).get(userDO.getId());
}
@Test
public void testGetUserWithNotVip() {
    ... // 代碼跟testGetUserWithVip一致, 只是測試數據不同而已
}

簡化用例:

@Test
public void testGetUserWithNormal() {
    ... // 代碼跟原testGetUserWithVip一致
}
@Test
public void testIsVipWithTrue() throws Exception {
    List<Long> roleIdList = ...; // 包含VIP角色標識
    Assert.assertTrue("返回值不爲真", Whitebox.invokeMethod(UserService.class, "isVip", roleIdList));
}
@Test
public void testIsVipWithFalse() throws Exception {
    List<Long> roleIdList = ...; // 不含VIP角色標識
    Assert.assertFalse("返回值不爲假", Whitebox.invokeMethod(UserService.class, "isVip", roleIdList));
}

5.2. 利用JUnit的參數化測試簡化單元測試用例

有時候我們會發現,同一方法的在不同場景下的單元測試,除了加載的數據不同之外,單元測試用例的代碼基本完全一致。我們可以這樣分析:雖然單元測試用例的場景不一樣——執行代碼的分支不一樣,調用方法方法的順序、次數、返回值不一樣;但是,其調用的依賴方法的數量是完全一致的;所以,最終寫出來的單元測試用例的代碼也是完全一致的。這時,我們就可以採用JUnit的參數化測試來簡化單元測試用例。

被測代碼:

同上一章的測試代碼。

原始用例:

同上一章的原始用例。

簡化用例:

@ParameterizedTest
@ValueSource(strings = { "vip/", "notVip/"})
public void testGetUserWithNormal(String dir) {
    // 模擬依賴方法
    String path = RESOURCE_PATH + "testGetUserWithNormal/" + dir;
    String text = ResourceHelper.getResourceAsString(getClass(), path + "userDO.json");
    UserDO userDO = JSON.parseObject(text, UserDO.class);
    Mockito.doReturn(userDO).when(userDAO).get(userDO.getId());
    
    // 調用測試方法
    UserVO userVO = userService.getUser(userDO.getId());
    text = ResourceHelper.getResourceAsString(getClass(), path + "userVO.json");
    Assert.assertEquals("用戶信息不一致", text, JSON.toJSONString(userVO));
    
    // 驗證依賴方法
    Mockito.verify(userDAO).get(userDO.getId());
}

如上簡化用例所示:在資源目錄testGetUserWithNormal中創建了兩個目錄vip和notVip,用於存儲相同名稱的JSON文件userDO.json和userVO.json,但是其文件內容根據場景又有所不同。

備註:本案例採用JUnit5.0的參數化測試新特性。

後記

本文只是拋磚引玉,起了個話題打了個底,希望大家繼續深挖並完善。

原文鏈接

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

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