作者:陳昌毅(常意)
那些年,為了學分,我們學會了程式導向程式設計;
那些年,為了就業,我們學會了物件導向程式設計;
那些年,為了生活,我們學會了面向工資程式設計;
那些年,為了升職加薪,我們學會了面向領導程式設計;
那些年,為了完成指標,我們學會了面向指標程式設計;
……
那些年,我們學會了敷衍地程式設計;
那些年,我們程式設計只是為了敷衍。
現在,要響應提高程式碼質量的號召,需要提升單元測試的程式碼覆蓋率。當然,我們要努力提高單元測試的程式碼覆蓋率。至於單元測試用例的有效性,我們大抵是不用關心的,因為我們只是面向指標程式設計。
我曾經閱讀過一個Java服務專案,單元測試的程式碼覆蓋率非常高,但是通篇沒有一個依賴方法驗證(Mockito.verify)、滿紙僅存幾個資料物件斷言(Assert.assertNotNull)。我說,這些都是無效的單元測試用例,根本起不到測試程式碼BUG和迴歸驗證程式碼的作用。後來,在一個月黑風高的夜裡,一個新增的方法呼叫,引起了一場血雨腥風。
編寫單元測試用例的目的,並不是為了追求單元測試程式碼覆蓋率,而是為了利用單元測試驗證迴歸程式碼——試圖找出程式碼中潛藏著的BUG。所以,我們應該具備工匠精神、懷著一顆敬畏心,編寫出有效的單元測試用例。在這篇文章裡,作者通過日常的單元測試實踐,系統地總結出一套避免編寫無效單元測試用例的方法和原則。
在維基百科中是這樣描述的:
在計算機程式設計中,單元測試又稱為模組測試,是針對程式模組來進行正確性檢驗的測試工作。程式單元是應用的最小可測試部件。在過程化程式設計中,一個單元就是單個程式、函數、過程等;對於物件導向程式設計,最小單元就是方法,包括基礎類別、抽象類、或者派生類中的方法。
首先,通過一個簡單的服務程式碼案例,讓我們認識一下整合測試和單元測試。
這裡,以使用者服務(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);
}
}
很多人認為,凡是用到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));
}
}
整合測試用例主要有以下特點:
採用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);
}
}
單元測試用例主要有以下特點:
為什麼整合測試不算單元測試呢?我們可以從單元測試原則上來判斷。在業界,常見的單元測試原則有AIR原則和FIRST原則。
AIR原則內容如下:
1、A-Automatic(自動的)
單元測試應該是全自動執行的,並且非互動式的。測試用例通常是被定期執行的,執行過程必須完全自動化才有意義。輸出結果需要人工檢查的測試不是一個好的單元測試。單元測試中不準使用System.out來進行人肉驗證,必須使用assert來驗證。
2、I-Independent(獨立的)
單元測試應該保持的獨立性。為了保證單元測試穩定可靠且便於維護,單元測試用例之間決不能互相呼叫,也不能對外部資源有所依賴。
3、R-Repeatable(可重複的)
單元測試是可以重複執行的,不能受到外界環境的影響。單元測試通常會被放入持續整合中,每次有程式碼提交時單元測試都會被執行。
FIRST原則內容如下:
1、F-Fast(快速的)
單元測試應該是可以快速執行的,在各種測試方法中,單元測試的執行速度是最快的,大型專案的單元測試通常應該在幾分鐘內執行完畢。
2、I-Independent(獨立的)
單元測試應該是可以獨立執行的,單元測試用例互相之間無依賴,且對外部資源也無任何依賴。
3、R-Repeatable(可重複的)
單元測試應該可以穩定重複的執行,並且每次執行的結果都是穩定可靠的。
4、S-SelfValidating(自我驗證的)
單元測試應該是用例自動進行驗證的,不能依賴人工驗證。
5、T-Timely(及時的)
單元測試必須及時進行編寫,更新和維護,以保證用例可以隨著業務程式碼的變化動態的保障質量。
阿里的夕華先生也提出了一條ASCII原則:
1、A-Automatic(自動的)
單元測試應該是全自動執行的,並且非互動式的。
2、S-SelfValidating(自我驗證的)
單元測試中必須使用斷言方式來進行正確性驗證,而不能根據輸出進行人肉驗證。
3、C-Consistent(一致的)
單元測試的引數和結果是確定且一致的。
4、I-Independent(獨立的)
單元測試之間不能互相呼叫,也不能依賴執行的先後次序。
5、I-Isolated(隔離的)
單元測試需要是隔離的,不要依賴外部資源。
根據上節中的單元測試原則,我們可以對比整合測試和單元測試的滿足情況如下:
整合測試基本上不一定滿足所有單元測試原則;通過上面表格的對比,可以得出以下結論:
所以,根據這些單元測試原則,可以看出整合測試具有很大的不確定性,不能也不可能完全代替單元測試。另外,整合測試始終是整合測試,即便用於代替單元測試也還是整合測試,比如:利用H2記憶體資料庫測試DAO方法。
要想識別無效單元測試,就必須站在對方的角度思考——如何在保障單元測試覆蓋率的前提下,能夠更少地編寫單元測試程式碼。那麼,就必須從單元測試編寫流程入手,看哪一階段哪一方法可以偷工減料。
在維基百科中是這樣描述的:
程式碼覆蓋(Code 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;
}
單元測試覆蓋率,只能代表被測程式碼的類、方法、執行語句、程式碼分支、條件子表示式等是否被執行,但是並不能代表這些程式碼是否被正確地執行並返回了正確的結果。所以,只看單元測試覆蓋率,而不看單元測試有效性,是沒有任何意義的。
首先,介紹一下作者總結的單元測試編寫流程:
定義物件階段主要包括:定義被測物件、模擬依賴物件(類成員)、注入依賴物件(類成員)。
模擬方法階段主要包括:模擬依賴物件(引數、返回值和異常)、模擬依賴方法。
呼叫方法階段主要包括:模擬依賴物件(引數)、呼叫被測方法、驗證引數物件(返回值和異常)。
驗證方法階段主要包括:驗證依賴方法、驗證資料物件(引數)、驗證依賴物件 。
針對單元測試編寫流程的階段和方法,在不影響單元測試覆蓋率的情況,我們是否可以進行一些偷工減料。
通過上表格,可以得出結論,偷工減料主要集中在驗證階段:
通過一些合併和拆分,後續將從以下三部分展開:
在單元測試中,驗證資料物件是為了驗證是否傳入了期望的引數值、返回了期望的返回值、設定了期望的屬性值。
在單元測試中,需要驗證的資料物件主要有以下幾種來源。
資料物件來源於呼叫被測方法的返回值,例如:
PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
資料物件來源於驗證依賴方法的引數捕獲,例如:
ArgumentCaptor<UserDO> userCreateCaptor = ArgumentCaptor.forClass(UserDO.class);
Mockito.verify(userDAO).create(userCreateCaptor.capture());
UserDO userCreate = userCreateCaptor.getValue();
資料物件來源於獲取被測物件的屬性值,例如:
userService.loadRoleMap();
Map<Long, String> roleMap = Whitebox.getInternalState(userService, "roleMap");
資料物件來源於獲取請求引數的屬性值,例如:
OrderContext orderContext = new OrderContext();
orderContext.setOrderId(12345L);
orderService.supplyProducts(orderContext);
List<ProductDO> productList = orderContext.getProductList();
當然,資料物件還有其它來源方式,這裡就不再一一舉例了。
在呼叫被測方法時,需要對返回值和異常進行驗證;在驗證方法呼叫時,也需要對捕獲的引數值進行驗證。
JUnit提供Assert.assertNull和Assert.assertNotNull方法來驗證資料物件空值。
// 1. 驗證資料物件為空
Assert.assertNull("使用者標識必須為空", userId);
// 2. 驗證資料物件非空
Assert.assertNotNull("使用者標識不能為空", userId);
JUnit提供Assert.assertTrue和Assert.assertFalse方法來驗證資料物件布林值的真假。
// 1. 驗證資料物件為真
Assert.assertTrue("返回值必須為真", NumberHelper.isPositive(1));
// 2. 驗證資料物件為假
Assert.assertFalse("返回值必須為假", NumberHelper.isPositive(-1));
JUnit提供Assert.assertSame和Assert.assertNotSame方法來驗證資料物件參照是否一致。
// 1. 驗證資料物件一致
Assert.assertSame("使用者必須一致", expectedUser, actualUser);
// 2. 驗證資料物件不一致
Assert.assertNotSame("使用者不能一致", expectedUser, actualUser);
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"));
當然,資料物件還有其它驗證方法,這裡就不再一一舉例了。
這裡,以分頁查詢公司使用者為例,來說明驗證資料物件時所存在的問題。
程式碼案例:
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;
}
反面案例: 很多人為了偷懶,對資料物件不進行任何驗證。
// 呼叫測試方法
userService.queryUser(companyId, startIndex, pageSize);
存在問題:
無法驗證資料物件是否正確,比如被測程式碼進行了以下修改:
// 返回分頁資料
return null;
反面案例:
既然不驗證資料物件有問題,那麼我就簡單地驗證一下資料物件非空。
// 呼叫測試方法
PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
Assert.assertNotNull("分頁資料不為空", pageData);
存在問題:
無法驗證資料物件是否正確,比如被測程式碼進行了以下修改:
// 返回分頁資料
return new PageDataVO<>();
反面案例:
既然簡單地驗證資料物件非空不行,那麼我就驗證資料物件的部分屬性。
// 呼叫測試方法
PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
Assert.assertEquals("資料總量不為空", totalSize, pageData.getTotalSize());
存在問題:
無法驗證資料物件是否正確,比如被測程式碼進行了以下修改:
// 返回分頁資料
return new PageDataVO<>(totalSize, null);
反面案例:
驗證資料物件部分屬性也不行,那我驗證資料物件所有屬性總行了吧。
// 呼叫測試方法
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);
備註: 本方法僅適用於屬性欄位不可變的資料物件
對於資料物件屬性欄位新增,有沒有完美的驗證方案?有的!答案就是利用JSON序列化,然後比較JSON文字內容。如果資料物件新增了屬性欄位,必然會提示JSON字串不一致。
完美案例:
// 呼叫測試方法
PageDataVO<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
text = ResourceHelper.getResourceAsString(getClass(), path + "pageData.json");
Assert.assertEquals("分頁資料不一致", text, JSON.toJSONString(pageData));
備註: 本方法僅適用於屬性欄位可變的資料物件。
由於沒有模擬資料物件章節,這裡在驗證資料物件章節中插入了模擬資料物件準則。
在上一節中,我們展示瞭如何完美地驗證資料物件。但是,這種方法真正完美嗎?答案是否定。
比如:我們把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;
}
所以,在單元測試中,除觸發條件分支外,模擬物件所有屬性值不能為空。
在上面的案例中,如果UserDO和UserVO新增了屬性欄位age(使用者年齡),且新增了賦值語句如下:
userVO.setAge(userDO.getAge());
如果還是用原有的資料物件執行單元測試,我們會發現單元測試用例執行通過。這是因為,由於屬性欄位age為空,賦值不賦值沒有任何差別。所以,新增屬性類屬性欄位是,必須模擬資料物件的屬性值。
注意: 如果用JSON字串對比,且設定輸出空欄位,是可以觸發單元測試用例執行失敗的。
在單元測試中,必須驗證所有資料物件:
具體案例可以參考《資料物件來源方式》章節。
在使用斷言驗證資料物件時,必須使用確定語意的斷言,不能使用不明確語意的斷言。
正例:
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)));
如果一個模型類,會根據業務需要新增欄位。那麼,針對這個模型類所對應的資料物件,儘量採用整體驗證方式。
正例:
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());
...
上面這種資料驗證方式,如果模型類刪除了屬性欄位,是可以驗證出來的。但是,如果模型類新增了欄位,是無法驗證出來的。所以,如果採用了這種驗證方式,在新增了模型類屬性欄位後,需要梳理並補全測試用例。否則,在使用單元測試用例迴歸程式碼時,它將會告訴你這裡沒有任何問題。
異常作為Java語言的重要特性,是Java語言健壯性的重要體現。捕獲並驗證丟擲異常,也是測試用例的一種。所以,在單元測試中,也需要對丟擲異常進行驗證。
判斷屬性欄位是否非法,否則丟擲異常。
private Map<String, MessageHandler> messageHandlerMap = ...;
public void handleMessage(Message message) {
...
// 判斷處理器對映非空
if (CollectionUtils.isEmpty(messageHandlerMap)) {
throw new ExampleException("訊息處理器對映不能為空");
}
...
}
判斷輸入引數是否合法,否則丟擲異常。
public void handleMessage(Message message) {
...
// 判斷獲取處理器非空
MessageHandler messageHandler = messageHandlerMap.get(message.getType());
if (CollectionUtils.isEmpty(messageHandler)) {
throw new ExampleException("獲取訊息處理器不能為空");
}
...
}
注意: 這裡採用的是Spring框架提供的Assert類,跟if-throw語句的效果一樣。
判斷返回值是否合法,否則丟擲異常。
public void handleMessage(Message message) {
...
// 進行訊息處理器處理
boolean result = messageHandler.handleMessage(message);
if (!reuslt) {
throw new ExampleException("處理訊息異常");
}
...
}
呼叫模擬的依賴方法時,可能模擬的依賴方法會丟擲異常。
public void handleMessage(Message message) {
...
// 進行訊息處理器處理
boolean result = messageHandler.handleMessage(message); // 直接丟擲異常
...
}
這裡,可以進行異常捕獲處理,或列印輸出紀錄檔,或繼續丟擲異常。
有時候,靜態方法呼叫也有可能丟擲異常。
// 可能會丟擲IOException
String response = HttpHelper.httpGet(url, parameterMap);
除此之外,還有別的丟擲異常來源方式,這裡不再累述。
在單元測試中,通常存在四種驗證丟擲異常方法。
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());
}
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方法後將跳出方法,導致後續驗證語句無法執行。所以,這種方式無法驗證異常編碼、訊息、原因等內容,也無法驗證依賴方法及其引數。
如果想要驗證異常原因和訊息,就需求採用@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替換。
在最新版的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());
}
根據不同的驗證異常功能項,對四種丟擲異常驗證方式對比。結果如下:
綜上所述,採用Assert.assertThrows方法驗證丟擲異常是最佳的,也是JUnit官方推薦使用的。
這裡,以建立使用者時丟擲異常為例,來說明驗證丟擲異常時所存在的問題。程式碼案例:
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);
}
}
反面案例:
在驗證丟擲異常時,很多人使用@Test註解的expected屬性,並且指定取值為Exception.class,主要原因是:
@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);
反面案例: 既然需要驗證異常型別,簡單地指定@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);
反面案例:
如果要驗證異常屬性,就必須用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);
反面案例:
先捕獲丟擲異常,再驗證異常編碼和異常訊息,看起來很完美了。
// 模擬依賴方法
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, "建立使用者異常");
反面案例:
很多人認為,驗證丟擲異常就只驗證丟擲異常,驗證依賴方法呼叫不是必須的。
// 模擬依賴方法
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, "無效使用者名稱稱");
}
一個完美的異常驗證,除對異常型別、異常屬性、異常原因等進行驗證外,還需對丟擲異常前的依賴方法呼叫進行驗證。
完美案例:
// 模擬依賴方法
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()));
在單元測試中,必須驗證所有丟擲異常:
具體內容可以參考 《丟擲異常來源方式》 章節。
在驗證丟擲異常時,必須驗證異常型別、異常屬性、異常原因等。
正例:
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);
}
在驗證丟擲異常後,必須驗證相關方法呼叫,來保證單元測試用例走的是期望分支。
正例:
/ 呼叫測試方法
...
// 驗證依賴方法
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()));
在單元測試中,驗證方法呼叫是為了驗證依賴方法的呼叫次數和順序以及是否傳入了期望的引數值。
最常見的方法呼叫就是對注入依賴物件的方法呼叫。
private UserDAO userDAO;
public UserVO getUser(Long userId) {
UserDO user = userDAO.get(userId); // 方法呼叫
return convertUser(user);
}
有時候,也可以通過輸入引數傳入依賴物件,然後呼叫依賴物件的方法。
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;
}
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
}
在Java中,靜態方法是指被static修飾的成員方法,不需要通過物件範例就可以被呼叫。在日常程式碼中,靜態方法呼叫一直佔有一定的比例。
String text = JSON.toJSONString(user); // 方法呼叫
在單元測試中,驗證依賴方法呼叫是確認模擬物件的依賴方法是否被按照預期呼叫的過程。
// 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()); // 匹配多個
// 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);
// 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();
// 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);
// 1.驗證模擬物件沒有任何方法呼叫
Mockito.verifyNoInteractions(idGenerator, userDAO);
// 2.驗證模擬物件沒有更多方法呼叫
Mockito.verifyNoMoreInteractions(idGenerator, userDAO);
這裡,以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;
}
反面案例:
有些人覺得,既然已經模擬了依賴方法,並且被測方法已經按照預期返回了值,就沒有必要對依賴方法進行驗證。
// 模擬依賴方法
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();
反面案例:
有些很喜歡用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);
反面案例:
既然說驗證至少一次有問題,那我就指定一下驗證次數。
// 驗證依賴方法
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);
}
反面案例:
不能用任意引數驗證方法,那隻好用實際引數驗證方法了。但是,驗證所有依賴方法呼叫程式碼太多,所以驗證一兩個依賴方法呼叫意思意思就行了。
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);
反面案例:
既然不驗證所有方法呼叫有問題,那我就把所有方法呼叫驗證了吧。
for (User user : userList) {
Mockito.verify(userCache).set(user.getId(), user);
}
存在問題:
所有方法呼叫都被驗證了,看起來應該沒有問題了。但是,如果快取使用者方法中,存在別的方法呼叫。比如:我們在進入快取使用者方法之前,新增了清除所有使用者快取,這個單元測試用是無法驗證的。
// 刪除所有使用者快取
userCache.clearAll();
驗證所有的方法呼叫,只能保證現在的邏輯沒有問題。如果涉及新增方法呼叫,這個單元測試用例是無法驗證出來的。所有,我們需要驗證所有依賴物件沒有更多方法呼叫。
完美案例:
// 驗證依賴方法
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(引數捕獲器),不但可以驗證引數,還可以驗證呼叫次數和順序。
在單元測試中,涉及到的所有模擬方法都要被驗證:
具體案例可以參考 《方法呼叫來源方式》 章節。
在單元測試中,為了防止被測方法中存在或新增別的方法呼叫,必須驗證所有的模擬物件沒有更多方法呼叫。
正例:
// 驗證依賴物件
Mockito.verifyNoMoreInteractions(userDAO, userCache);
備註:
作者喜歡在@After方法中對所有模擬物件進行驗證,這樣就不必在每個單元測試用例中驗證模擬物件。
@After
public void afterTest() {
Mockito.verifyNoMoreInteractions(userDAO, userCache);
}
可惜Mockito.verifyNoMoreInteractions不支援無引數就驗證所有模擬物件的功能,否則這段程式碼會變得更簡潔。
驗證依賴方法時,必須使用明確語意的引數值或匹配器,不能使用任何不明確語意的匹配器,比如: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());
最後,根據本文所表達的觀點,即興賦詩七言絕句一首:
《單元測試》
單元測試分真假,
工匠精神貫始終。
覆蓋追求非目的,
迴歸驗證顯奇功。
意思是:
一定要知道如何去分辨單元測試的真假,
一定要把工匠精神貫徹單元測試的始終。
追求單測覆蓋率並不是單元測試的目的,
迴歸驗證程式碼才能彰顯單元測試的功效。