Java程式設計技巧
Java程式設計技巧之單元測試用例編寫流程 一 編寫單元測試用例
1 測試框架簡介
Mockito是一個單元測試模擬框架,可以讓你寫出優雅、簡潔的單元測試程式碼。Mockito採用了模擬技術,模擬了一些在應用中依賴的複雜物件,從而把測試物件和依賴物件隔離開來。
PowerMock是一個單元測試模擬框架,是在其它單元測試模擬框架的基礎上做出擴充套件。通過提供定製的類載入器以及一些位元組碼篡改技術的應用,PowerMock實現了對靜態方法、構造方法、私有方法以及final方法的模擬支援等強大的功能。但是,正因為PowerMock進行了位元組碼篡改,導致部分單元測試用例並不被JaCoco統計覆蓋率。
通過作者多年單元測試的編寫經驗,優先推薦使用Mockito提供的功能;只有在Mockito提供的功能不能滿足需求時,才會採用PowerMock提供的功能;但是,不推薦使用影響JaCoco統計覆蓋率的PowerMock功能。在本文中,我們也不會對影響JaCoco統計覆蓋率的PowerMock功能進行介紹。
下面,將以Mockito為主、以PowerMock為輔,介紹一下如何編寫單元測試用例。
2 測試框架引入
為了引入Mockito和PowerMock包,需要在maven專案的pom.xml檔案中加入以下包依賴:
org.powermock powermock-core ${powermock.version} test org.powermock powermock-api-mockito2 ${powermock.version} test org.powermock powermock-module-junit4 ${powermock.version} test
其中,powermock.version為2.0.9,為當前的最新版本,可根據實際情況修改。在PowerMock包中,已經包含了對應的Mockito和JUnit包,所以無需單獨引入Mockito和JUnit包。
3 典型程式碼案例
一個典型的服務程式碼案例如下:
/** * 使用者服務類 / @Service public class UserService { /* 服務相關 / /* 使用者DAO / @Autowired private UserDAO userDAO; /* 標識生成器 */ @Autowired private IdGenerator idGenerator;
/** 引數相關 */
/** 可以修改 */
@Value("${userService.canModify}")
private Boolean canModify;
/**
* 建立使用者
*
* @param userCreate 使用者建立
* @return 使用者標識
*/
public Long createUser(UserVO userCreate) {
// 獲取使用者標識
Long userId = userDAO.getIdByName(userCreate.getName());
// 根據存在處理
// 根據存在處理: 不存在則建立
if (Objects.isNull(userId)) {
userId = idGenerator.next();
UserDO create = new UserDO();
create.setId(userId);
create.setName(userCreate.getName());
userDAO.create(create);
}
// 根據存在處理: 已存在可修改
else if (Boolean.TRUE.equals(canModify)) {
UserDO modify = new UserDO();
modify.setId(userId);
modify.setName(userCreate.getName());
userDAO.modify(modify);
}
// 根據存在處理: 已存在禁修改
else {
throw new UnsupportedOperationException("不支援修改");
}
// 返回使用者標識
return userId;
}
}
4 測試用例編寫
採用Mockito和PowerMock單元測試模擬框架,編寫的單元測試用例如下:
UserServiceTest.java
/** * 使用者服務測試類 / @RunWith(PowerMockRunner.class) public class UserServiceTest { /* 模擬依賴物件 / /* 使用者DAO / @Mock private UserDAO userDAO; /* 標識生成器 */ @Mock private IdGenerator idGenerator;
/** 定義被測物件 */
/** 使用者服務 */
@InjectMocks
private UserService userService;
/**
* 在測試之前
*/
@Before
public void beforeTest() {
// 注入依賴物件
Whitebox.setInternalState(userService, "canModify", Boolean.TRUE);
}
/**
* 測試: 建立使用者-新
*/
@Test
public void testCreateUserWithNew() {
// 模擬依賴方法
// 模擬依賴方法: userDAO.getByName
Mockito.doReturn(null).when(userDAO).getIdByName(Mockito.anyString());
// 模擬依賴方法: idGenerator.next
Long userId = 1L;
Mockito.doReturn(userId).when(idGenerator).next();
// 呼叫被測方法
String text = ResourceHelper.getResourceAsString(getClass(), "userCreateVO.json");
UserVO userCreate = JSON.parseObject(text, UserVO.class);
Assert.assertEquals("使用者標識不一致", userId, userService.createUser(userCreate));
// 驗證依賴方法
// 驗證依賴方法: userDAO.getByName
Mockito.verify(userDAO).getIdByName(userCreate.getName());
// 驗證依賴方法: idGenerator.next
Mockito.verify(idGenerator).next();
// 驗證依賴方法: userDAO.create
ArgumentCaptor<UserDO> userCreateCaptor = ArgumentCaptor.forClass(UserDO.class);
Mockito.verify(userDAO).create(userCreateCaptor.capture());
text = ResourceHelper.getResourceAsString(getClass(), "userCreateDO.json");
Assert.assertEquals("使用者建立不一致", text, JSON.toJSONString(userCreateCaptor.getValue()));
// 驗證依賴物件
Mockito.verifyNoMoreInteractions(idGenerator, userDAO);
}
/**
* 測試: 建立使用者-舊
*/
@Test
public void testCreateUserWithOld() {
// 模擬依賴方法
// 模擬依賴方法: userDAO.getByName
Long userId = 1L;
Mockito.doReturn(userId).when(userDAO).getIdByName(Mockito.anyString());
// 呼叫被測方法
String text = ResourceHelper.getResourceAsString(getClass(), "userCreateVO.json");
UserVO userCreate = JSON.parseObject(text, UserVO.class);
Assert.assertEquals("使用者標識不一致", userId, userService.createUser(userCreate));
// 驗證依賴方法
// 驗證依賴方法: userDAO.getByName
Mockito.verify(userDAO).getIdByName(userCreate.getName());
// 驗證依賴方法: userDAO.modify
ArgumentCaptor<UserDO> userModifyCaptor = ArgumentCaptor.forClass(UserDO.class);
Mockito.verify(userDAO).modify(userModifyCaptor.capture());
text = ResourceHelper.getResourceAsString(getClass(), "userModifyDO.json");
Assert.assertEquals("使用者修改不一致", text, JSON.toJSONString(userModifyCaptor.getValue()));
// 驗證依賴物件
Mockito.verifyNoInteractions(idGenerator);
Mockito.verifyNoMoreInteractions(userDAO);
}
/**
* 測試: 建立使用者-異常
*/
@Test
public void testCreateUserWithException() {
// 注入依賴物件
Whitebox.setInternalState(userService, "canModify", Boolean.FALSE);
// 模擬依賴方法
// 模擬依賴方法: userDAO.getByName
Long userId = 1L;
Mockito.doReturn(userId).when(userDAO).getIdByName(Mockito.anyString());
// 呼叫被測方法
String text = ResourceHelper.getResourceAsString(getClass(), "userCreateVO.json");
UserVO userCreate = JSON.parseObject(text, UserVO.class);
UnsupportedOperationException exception = Assert.assertThrows("返回異常不一致",
UnsupportedOperationException.class, () -> userService.createUser(userCreate));
Assert.assertEquals("異常訊息不一致", "不支援修改", exception.getMessage());
}
}
userCreateVO.json
{"name":"test"}
userCreateDO.json
{"id":1,"name":"test"}
userModifyDO.json
{"id":1,"name":"test"}
通過執行以上測試用例,可以看到對原始碼進行了100%的行覆蓋。
二 測試用例編寫流程
通過上一章編寫Java類單元測試用例的實踐,可以總結出以下Java類單元測試用例的編寫流程:
圖片
單元測試用例編寫流程
上面一共有3個測試用例,這裡僅以測試用例testCreateUserWithNew(測試:建立使用者-新)為例說明。
1 定義物件階段
第1步是定義物件階段,主要包括定義被測物件、模擬依賴物件(類成員)、注入依賴物件(類成員)3大部分。
定義被測物件
在編寫單元測試時,首先需要定義被測物件,或直接初始化、或通過Spy包裝……其實,就是把被測試服務類進行例項化。
/** 定義被測物件 / /* 使用者服務 */ @InjectMocks private UserService userService;
模擬依賴物件(類成員)
在一個服務類中,我們定義了一些類成員物件——服務(Service)、資料訪問物件(DAO)、引數(Value)等。在Spring框架中,這些類成員物件通過@Autowired、@Value等方式注入,它們可能涉及複雜的環境配置、依賴第三方介面服務……但是,在單元測試中,為了解除對這些類成員物件的依賴,我們需要對這些類成員物件進行模擬。
/** 模擬依賴物件 / /* 使用者DAO / @Mock private UserDAO userDAO; /* 標識生成器 */ @Mock private IdGenerator idGenerator;
注入依賴物件(類成員)
當模擬完這些類成員物件後,我們需要把這些類成員物件注入到被測試類的例項中。以便在呼叫被測試方法時,可能使用這些類成員物件,而不至於丟擲空指標異常。
/** 定義被測物件 / /* 使用者服務 / @InjectMocks private UserService userService; /* * 在測試之前 */ @Before public void beforeTest() { // 注入依賴物件 Whitebox.setInternalState(userService, "canModify", Boolean.TRUE); }
2 模擬方法階段
第2步是模擬方法階段,主要包括模擬依賴物件(引數或返回值)、模擬依賴方法2大部分。
模擬依賴物件(引數或返回值)
通常,在呼叫一個方法時,需要先指定方法的引數,然後獲取到方法的返回值。所以,在模擬方法之前,需要先模擬該方法的引數和返回值。
Long userId = 1L;
模擬依賴方法
在模擬完依賴的引數和返回值後,就可以利用Mockito和PowerMock的功能,進行依賴方法的模擬。如果依賴物件還有方法呼叫,還需要模擬這些依賴物件的方法。
// 模擬依賴方法 // 模擬依賴方法: userDAO.getByName Mockito.doReturn(null).when(userDAO).getIdByName(Mockito.anyString()); // 模擬依賴方法: idGenerator.next Mockito.doReturn(userId).when(idGenerator).next();
3 呼叫方法階段
第3步是呼叫方法階段,主要包括模擬依賴物件(引數)、呼叫被測方法、驗證引數物件(返回值)3步。
模擬依賴物件(引數)
在呼叫被測方法之前,需要模擬被測方法的引數。如果這些引數還有方法呼叫,還需要模擬這些引數的方法。
String text = ResourceHelper.getResourceAsString(getClass(), "userCreateVO.json"); UserVO userCreate = JSON.parseObject(text, UserVO.class);
呼叫被測方法
在準備好引數物件後,就可以呼叫被測試方法了。如果被測試方法有返回值,需要定義變數接收返回值;如果被測試方法要丟擲異常,需要指定期望的異常。
userService.createUser(userCreate)
驗證資料物件(返回值)
在呼叫被測試方法後,如果被測試方法有返回值,需要驗證這個返回值是否符合預期;如果被測試方法要丟擲異常,需要驗證這個異常是否滿足要求。
Assert.assertEquals("使用者標識不一致", userId, userService.createUser(userCreate));
4 驗證方法階段
第4步是驗證方法階段,主要包括驗證依賴方法、驗證資料物件(引數)、驗證依賴物件3步。
驗證依賴方法
作為一個完整的測試用例,需要對每一個模擬的依賴方法呼叫進行驗證。
// 驗證依賴方法 // 驗證依賴方法: userDAO.getByName Mockito.verify(userDAO).getIdByName(userCreate.getName()); // 驗證依賴方法: idGenerator.next Mockito.verify(idGenerator).next(); // 驗證依賴方法: userDAO.create ArgumentCaptor userCreateCaptor = ArgumentCaptor.forClass(UserDO.class); Mockito.verify(userDAO).create(userCreateCaptor.capture());
驗證資料物件(引數)
對應一些模擬的依賴方法,有些引數物件是被測試方法內部生成的。為了驗證程式碼邏輯的正確性,就需要對這些引數物件進行驗證,看這些引數物件值是否符合預期。
text = ResourceHelper.getResourceAsString(getClass(), "userCreateDO.json"); Assert.assertEquals("使用者建立不一致", text, JSON.toJSONString(userCreateCaptor.getValue()));
驗證依賴物件
作為一個完整的測試用例,應該保證每一個模擬的依賴方法呼叫都進行了驗證。正好,Mockito提供了一套方法,用於驗證模擬物件所有方法呼叫都得到了驗證。
// 驗證依賴物件 Mockito.verifyNoMoreInteractions(idGenerator, userDAO);
三 定義被測物件
在編寫單元測試時,首先需要定義被測物件,或直接初始化、或通過Spy包裝……其實,就是把被測試服務類進行例項化。
1 直接構建物件
直接構建一個物件,總是簡單又直接。
UserService userService = new UserService();
2 利用Mockito.spy方法
Mockito提供一個spy功能,用於攔截那些尚未實現或不期望被真實呼叫的方法,預設所有方法都是真實方法,除非主動去模擬對應方法。所以,利用spy功能來定義被測物件,適合於需要模擬被測類自身方法的情況,適用於普通類、介面和虛基類。
UserService userService = Mockito.spy(new UserService()); UserService userService = Mockito.spy(UserService.class); AbstractOssService ossService = Mockito.spy(AbstractOssService.class);
3 利用@Spy註解
@Spy註解跟Mockito.spy方法一樣,可以用來定義被測物件,適合於需要模擬被測類自身方法的情況,適用於普通類、介面和虛基類。@Spy註解需要配合@RunWith註解使用。
@RunWith(PowerMockRunner.class) public class CompanyServiceTest { @Spy private UserService userService = new UserService();
...
}
注意:@Spy註解物件需要初始化。如果是虛基類或介面,可以用Mockito.mock方法例項化。
4 利用@InjectMocks註解
@InjectMocks註解用來建立一個例項,並將其它物件(@Mock、@Spy或直接定義的物件)注入到該例項中。所以,@InjectMocks註解本身就可以用來定義被測物件。@InjectMocks註解需要配合@RunWith註解使用。
@RunWith(PowerMockRunner.class) public class UserServiceTest { @InjectMocks private UserService userService; ...
}
四 模擬依賴物件
在編寫單元測試用例時,需要模擬各種依賴物件——類成員、方法引數和方法返回值。
1 直接構建物件
如果需要構建一個物件,最簡單直接的方法就是——定義物件並賦值。
Long userId = 1L; String userName = "admin"; UserDO user = new User(); user.setId(userId); user.setName(userName); List userIdList = Arrays.asList(1L, 2L, 3L);
2 反序列化物件
如果物件欄位或層級非常龐大,採用直接構建物件方法,可能會編寫大量構建程式程式碼。這種情況,可以考慮反序列化物件,將會大大減少程式程式碼。由於JSON字串可讀性高,這裡就以JSON為例,介紹反序列化物件。
反序列化模型物件
String text = ResourceHelper.getResourceAsString(getClass(), "user.json"); UserDO user = JSON.parseObject(text, UserDO.class);
反序列化集合物件
String text = ResourceHelper.getResourceAsString(getClass(), "userList.json"); List userList = JSON.parseArray(text, UserDO.class);
反序列化對映物件
String text = ResourceHelper.getResourceAsString(getClass(), "userMap.json"); Map userMap = JSON.parseObject(text, new TypeReference>() {});
3 利用Mockito.mock方法
Mockito提供一個mock功能,用於攔截那些尚未實現或不期望被真實呼叫的方法,預設所有方法都已被模擬——方法為空並返回預設值(null或0),除非主動執行doCallRealMethod或thenCallRealMethod操作,才能夠呼叫真實的方法。
利用Mockito.mock方法模擬依賴物件,主要用於以下幾種情形:
只使用類例項,不使用類屬性;
類屬性太多,但使用其中少量屬性(可以mock屬性返回值);
類是介面或虛基類,並不關心其具體實現類。
MockClass mockClass = Mockito.mock(MockClass.class); List userIdList = (List)Mockito.mock(List.class);
4 利用@Mock註解
@Mock註解跟Mockito.mock方法一樣,可以用來模擬依賴物件,適用於普通類、介面和虛基類。@Mock註解需要配合@RunWith註解使用。
@RunWith(PowerMockRunner.class) public class UserServiceTest { @Mock private UserDAO userDAO; ...
}
5 利用Mockito.spy方法
Mockito.spy方法跟Mockito.mock方法功能相似,只是Mockito.spy方法預設所有方法都是真實方法,除非主動去模擬對應方法。
UserService userService = Mockito.spy(new UserService()); UserService userService = Mockito.spy(UserService.class); AbstractOssService ossService = Mockito.spy(AbstractOssService.class);
6 利用@Spy註解
@Spy註解跟Mockito.spy方法一樣,可以用來模擬依賴物件,適用於普通類、介面和虛基類。@Spy註解需要配合@RunWith註解使用。
@RunWith(PowerMockRunner.class) public class CompanyServiceTest { @Spy private UserService userService = new UserService();
...
}
注意:@Spy註解物件需要初始化。如果是虛基類或介面,可以用Mockito.mock方法例項化。
五 注入依賴物件
當模擬完這些類成員物件後,我們需要把這些類成員物件注入到被測試類的例項中。以便在呼叫被測試方法時,可能使用這些類成員物件,而不至於丟擲空指標異常。
1 利用Setter方法注入
如果類定義了Setter方法,可以直接呼叫方法設定欄位值。
userService.setMaxCount(100); userService.setUserDAO(userDAO);
2 利用ReflectionTestUtils.setField方法注入
JUnit提供ReflectionTestUtils.setField方法設定屬性欄位值。
ReflectionTestUtils.setField(userService, "maxCount", 100); ReflectionTestUtils.setField(userService, "userDAO", userDAO);
3 利用Whitebox.setInternalState方法注入
PowerMock提供Whitebox.setInternalState方法設定屬性欄位值。
Whitebox.setInternalState(userService, "maxCount", 100); Whitebox.setInternalState(userService, "userDAO", userDAO);
4 利用@InjectMocks註解注入
@InjectMocks註解用來建立一個例項,並將其它物件(@Mock、@Spy或直接定義的物件)注入到該例項中。@InjectMocks註解需要配合@RunWith註解使用。
@RunWith(PowerMockRunner.class) public class UserServiceTest { @Mock private UserDAO userDAO; private Boolean canModify;
@InjectMocks
private UserService userService;
...
}
5 設定靜態常量欄位值
有時候,我們需要對靜態常量物件進行模擬,然後去驗證是否執行了對應分支下的方法。比如:需要模擬Lombok的@Slf4j生成的log靜態常量。但是,Whitebox.setInternalState方法和@InjectMocks註解並不支援設定靜態常量,需要自己實現一個設定靜態常量的方法:
public final class FieldHelper { public static void setStaticFinalField(Class clazz, String fieldName, Object fieldValue) throws NoSuchFieldException, IllegalAccessException { Field field = clazz.getDeclaredField(fieldName); FieldUtils.removeFinalModifier(field); FieldUtils.writeStaticField(field, fieldValue, true); } }
具體使用方法如下:
FieldHelper.setStaticFinalField(UserService.class, "log", log);
注意:經過測試,該方法對於int、Integer等基礎型別並不生效,應該是編譯器常量優化導致。
六 模擬依賴方法
在模擬完依賴的引數和返回值後,就可以利用Mockito和PowerMock的功能,進行依賴方法的模擬。如果依賴物件還有方法呼叫,還需要模擬這些依賴物件的方法。
1 根據返回模擬方法
模擬無返回值方法
Mockito.doNothing().when(userDAO).delete(userId);
模擬方法單個返回值
Mockito.doReturn(user).when(userDAO).get(userId); Mockito.when(userDAO.get(userId)).thenReturn(user);
模擬方法多個返回值
直接列舉出多個返回值:
Mockito.doReturn(record0, record1, record2, null).when(recordReader).read(); Mockito.when(recordReader.read()).thenReturn(record0, record1, record2, null);
轉化列表為多個返回值:
List recordList = ...; Mockito.doReturn(recordList.get(0), recordList.subList(1, recordList.size()).toArray()).when(recordReader).read(); Mockito.when(recordReader.read()).thenReturn(recordList.get(0), recordList.subList(1, recordList.size()).toArray());
模擬方法定製返回值
可利用Answer定製方法返回值:
Map userMap = ...; Mockito.doAnswer(invocation -> userMap.get(invocation.getArgument(0))) .when(userDAO).get(Mockito.anyLong()); Mockito.when(userDAO.get(Mockito.anyLong())) .thenReturn(invocation -> userMap.get(invocation.getArgument(0))); Mockito.when(userDAO.get(Mockito.anyLong())) .then(invocation -> userMap.get(invocation.getArgument(0)));
模擬方法丟擲單個異常
指定單個異常型別:
Mockito.doThrow(PersistenceException.class).when(userDAO).get(Mockito.anyLong()); Mockito.when(userDAO.get(Mockito.anyLong())).thenThrow(PersistenceException.class);
指定單個異常物件:
Mockito.doThrow(exception).when(userDAO).get(Mockito.anyLong()); Mockito.when(userDAO.get(Mockito.anyLong())).thenThrow(exception);
模擬方法丟擲多個異常
指定多個異常型別:
Mockito.doThrow(PersistenceException.class, RuntimeException.class).when(userDAO).get(Mockito.anyLong()); Mockito.when(userDAO.get(Mockito.anyLong())).thenThrow(PersistenceException.class, RuntimeException.class);
指定多個異常物件:
Mockito.doThrow(exception1, exception2).when(userDAO).get(Mockito.anyLong()); Mockito.when(userDAO.get(Mockito.anyLong())).thenThrow(exception1, exception2);
直接呼叫真實方法
Mockito.doCallRealMethod().when(userService).getUser(userId); Mockito.when(userService.getUser(userId)).thenCallRealMethod();
2 根據引數模擬方法
Mockito提供do-when語句和when-then語句模擬方法。
模擬無引數方法
對於無引數的方法模擬:
Mockito.doReturn(deleteCount).when(userDAO).deleteAll(); Mockito.when(userDAO.deleteAll()).thenReturn(deleteCount);
模擬指定引數方法
對於指定引數的方法模擬:
Mockito.doReturn(user).when(userDAO).get(userId); Mockito.when(userDAO.get(userId)).thenReturn(user);
模擬任意引數方法
在編寫單元測試用例時,有時候並不關心傳入引數的具體值,可以使用Mockito引數匹配器的any方法。Mockito提供了anyInt、anyLong、anyString、anyList、anySet、anyMap、any(Class clazz)等方法來表示任意值。
Mockito.doReturn(user).when(userDAO).get(Mockito.anyLong()); Mockito.when(userDAO.get(Mockito.anyLong())).thenReturn(user);
模擬可空引數方法
Mockito引數匹配器的any具體方法,並不能夠匹配null物件。而Mockito提供一個nullable方法,可以匹配包含null物件的任意物件。此外,Mockito.any()方法也可以用來匹配可空引數。
Mockito.doReturn(user).when(userDAO) .queryCompany(Mockito.anyLong(), Mockito.nullable(Long.class)); Mockito.when(userDAO.queryCompany(Mockito.anyLong(), Mockito.any())) .thenReturn(user);
模擬必空引數方法
同樣,如果要匹配null物件,可以使用isNull方法,或使用eq(null)。
Mockito.doReturn(user).when(userDAO).queryCompany(Mockito.anyLong(), Mockito.isNull()); Mockito.when(userDAO.queryCompany(Mockito.anyLong(), Mockito.eq(null))).thenReturn(user);
模擬不同引數方法
Mockito支援按不同的引數分別模擬同一方法。
Mockito.doReturn(user1).when(userDAO).get(1L); Mockito.doReturn(user2).when(userDAO).get(2L); ...
注意:如果一個引數滿足多個模擬方法條件,會以最後一個模擬方法為準。
模擬可變引數方法
對於一些變長度引數方法,可以按實際引數個數進行模擬:
Mockito.when(userService.delete(Mockito.anyLong()).thenReturn(true); Mockito.when(userService.delete(1L, 2L, 3L).thenReturn(true);
也可以用Mockito.any()模擬一個通用匹配方法:
Mockito.when(userService.delete(Mockito.any()).thenReturn(true);
注意:Mockito.any()並不等於Mockito.any(Class type),前者可以匹配null和型別T的可變引數,後者只能匹配T必填引數。
3 模擬其它特殊方法
模擬final方法
PowerMock提供對final方法的模擬,方法跟模擬普通方法一樣。但是,需要把對應的模擬類新增到@PrepareForTest註解中。
// 新增@PrepareForTest註解 @PrepareForTest({UserService.class})
// 跟模擬普通方法完全一致 Mockito.doReturn(userId).when(idGenerator).next(); Mockito.when(idGenerator.next()).thenReturn(userId);
模擬私有方法
PowerMock提供提對私有方法的模擬,但是需要把私有方法所在的類放在@PrepareForTest註解中。
PowerMockito.doReturn(true).when(UserService.class, "isSuper", userId); PowerMockito.when(UserService.class, "isSuper", userId).thenReturn(true);
模擬構造方法
PowerMock提供PowerMockito.whenNew方法來模擬構造方法,但是需要把使用構造方法的類放在@PrepareForTest註解中。
PowerMockito.whenNew(UserDO.class).withNoArguments().thenReturn(userDO); PowerMockito.whenNew(UserDO.class).withArguments(userId, userName).thenReturn(userDO);
模擬靜態方法
PowerMock提供PowerMockito.mockStatic和PowerMockito.spy來模擬靜態方法類,然後就可以模擬靜態方法了。同樣,需要把對應的模擬類新增到@PrepareForTest註解中。
// 模擬對應的類 PowerMockito.mockStatic(HttpHelper.class); PowerMockito.spy(HttpHelper.class);
// 模擬對應的方法 PowerMockito.when(HttpHelper.httpPost(SERVER_URL)).thenReturn(response); PowerMockito.doReturn(response).when(HttpHelper.class, "httpPost", SERVER_URL); PowerMockito.when(HttpHelper.class, "httpPost", SERVER_URL).thenReturn(response);
注意:第一種方式不適用於PowerMockito.spy模擬的靜態方法類。
七 呼叫被測方法
在準備好引數物件後,就可以呼叫被測試方法了。
如果把方法按訪問許可權分類,可以簡單地分為有訪問許可權和無訪問許可權兩種。但實際上,Java語言中提供了public、protected、private和缺失共4種許可權修飾符,在不同的環境下又對應不同的訪問許可權。具體對映關係如下:
修飾符 本類 本包 子類 其它 public 有 有 有 有 protected 有 有 有 無 預設 有 有 無 無 private 有 無 無 無
下面,將根據有訪問許可權和無訪問許可權兩種情況,來介紹如何呼叫被測方法。
1 呼叫構造方法
呼叫有訪問許可權的構造方法
可以直接呼叫有訪問許可權的構造方法。
UserDO user = new User(); UserDO user = new User(1L, "admin");
呼叫無訪問許可權的構造方法
呼叫無訪問許可權的構造方法,可以使用PowerMock提供的Whitebox.invokeConstructor方法。
Whitebox.invokeConstructor(NumberHelper.class); Whitebox.invokeConstructor(User.class, 1L, "admin");
備註:該方法也可以呼叫有訪問許可權的構造方法,但是不建議使用。
2 呼叫普通方法
呼叫有訪問許可權的普通方法
可以直接呼叫有訪問許可權的普通方法。
userService.deleteUser(userId); User user = userService.getUser(userId);
呼叫無許可權訪問的普通方法
呼叫無訪問許可權的普通方法,可以使用PowerMock提供的Whitebox.invokeMethod方法。
User user = (User)Whitebox.invokeMethod(userService, "isSuper", userId);
也可以使用PowerMock提供Whitebox.getMethod方法和PowerMockito.method方法,可以直接獲取對應類方法物件。然後,通過Method的invoke方法,可以呼叫沒有訪問許可權的方法。
Method method = Whitebox.getMethod(UserService.class, "isSuper", Long.class); Method method = PowerMockito.method(UserService.class, "isSuper", Long.class); User user = (User)method.invoke(userService, userId);
備註:該方法也可以呼叫有訪問許可權的普通方法,但是不建議使用。
3 呼叫靜態方法
呼叫有許可權訪問的靜態方法
可以直接呼叫有訪問許可權的靜態方法。
boolean isPositive = NumberHelper.isPositive(-1);
呼叫無許可權訪問的靜態方法
呼叫無許可權訪問的靜態方法,可以使用PowerMock提供的Whitebox.invokeMethod方法。
String value = (String)Whitebox.invokeMethod(JSON.class, "toJSONString", object);
備註:該方法也可以呼叫有訪問許可權的靜態方法,但是不建議使用。
八 驗證依賴方法
在單元測試中,驗證是確認模擬的依賴方法是否按照預期被呼叫或未呼叫的過程。
Mockito提供了許多方法來驗證依賴方法呼叫,給我們編寫單元測試用例帶來了很大的幫助。
1 根據引數驗證方法呼叫
驗證無引數方法呼叫
Mockito.verify(userDAO).deleteAll();
驗證指定引數方法呼叫
Mockito.verify(userDAO).delete(userId); Mockito.verify(userDAO).delete(Mockito.eq(userId));
驗證任意引數方法呼叫
Mockito.verify(userDAO).delete(Mockito.anyLong());
驗證可空引數方法呼叫
Mockito.verify(userDAO).queryCompany(Mockito.anyLong(), Mockito.nullable(Long.class));
驗證必空引數方法呼叫
Mockito.verify(userDAO).queryCompany(Mockito.anyLong(), Mockito.isNull());
驗證可變引數方法呼叫
對於一些變長度引數方法,可以按實際引數個數進行驗證:
Mockito.verify(userService).delete(Mockito.any(Long.class)); Mockito.verify(userService).delete(1L, 2L, 3L);
也可以用Mockito.any()進行通用驗證:
Mockito.verify(userService).delete(Mockito.any());
2 驗證方法呼叫次數
驗證方法預設呼叫1次
Mockito.verify(userDAO).delete(userId);
驗證方法從不呼叫
Mockito.verify(userDAO, Mockito.never()).delete(userId);
驗證方法呼叫n次
Mockito.verify(userDAO, Mockito.times(n)).delete(userId);
驗證方法呼叫至少1次
Mockito.verify(userDAO, Mockito.atLeastOnce()).delete(userId);
驗證方法呼叫至少n次
Mockito.verify(userDAO, Mockito.atLeast(n)).delete(userId);
驗證方法呼叫最多1次
Mockito.verify(userDAO, Mockito.atMostOnce()).delete(userId);
驗證方法呼叫最多n次
Mockito.verify(userDAO, Mockito.atMost(n)).delete(userId);
驗證方法呼叫指定n次
Mockito允許按順序進行驗證方法呼叫,未被驗證到的方法呼叫將不會被標記為已驗證。
Mockito.verify(userDAO, Mockito.call(n)).delete(userId);
驗證物件及其方法呼叫1次
用於驗證物件及其方法呼叫1次,如果該物件還有別的方法被呼叫或者該方法呼叫了多次,都將導致驗證方法呼叫失敗。
Mockito.verify(userDAO, Mockito.only()).delete(userId);
相當於:
Mockito.verify(userDAO).delete(userId); Mockito.verifyNoMoreInteractions(userDAO);
3 驗證方法呼叫並捕獲引數值
Mockito提供ArgumentCaptor類來捕獲引數值,通過呼叫forClass(Class clazz)方法來構建一個ArgumentCaptor物件,然後在驗證方法呼叫時來捕獲引數,最後獲取到捕獲的引數值並驗證。如果一個方法有多個引數都要捕獲並驗證,那就需要建立多個ArgumentCaptor物件。
ArgumentCaptor的主要介面方法:
capture方法,用於捕獲方法引數;
getValue方法,用於獲取捕獲的引數值,如果捕獲了多個引數值,該方法只返回最後一個引數值;
getAllValues方法,使用者獲取捕獲的所有引數值。
使用ArgumentCaptor.forClass方法定義引數捕獲器
在測試用例方法中,直接使用ArgumentCaptor.forClass方法定義引數捕獲器。
ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserDO.class); Mockito.verify(userDAO).modify(userCaptor.capture()); UserDO user = userCaptor.getValue();
注意:定義泛型類的引數捕獲器時,存在強制型別轉換,會引起編譯器警告。
使用@Captor註解定義引數捕獲器
也可以用Mockito提供的@Captor註解,在測試用例類中定義引數捕獲器。
@RunWith(PowerMockRunner.class) public class UserServiceTest { @Captor private ArgumentCaptor userCaptor; @Test public void testModifyUser() { ... Mockito.verify(userDAO).modify(userCaptor.capture()); UserDO user = userCaptor.getValue(); } }
注意:定義泛型類的引數捕獲器時,由於是Mockito自行初始化,不會引起編譯器警告。
捕獲多次方法呼叫的引數值列表
ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserDO.class); Mockito.verify(userDAO, Mockito.atLeastOnce()).modify(userCaptor.capture()); List userList = userCaptor.getAllValues();
4 驗證其它特殊方法
驗證final方法呼叫
final方法的驗證跟普通方法類似,這裡不再累述。
驗證私有方法呼叫
PowerMockito提供verifyPrivate方法驗證私有方法呼叫。
PowerMockito.verifyPrivate(myClass, times(1)).invoke("unload", any(List.class));
驗證構造方法呼叫
PowerMockito提供verifyNew方法驗證構造方法呼叫。
PowerMockito.verifyNew(MockClass.class).withNoArguments(); PowerMockito.verifyNew(MockClass.class).withArguments(someArgs);
驗證靜態方法呼叫
PowerMockito提供verifyStatic方法驗證靜態方法呼叫。
PowerMockito.verifyStatic(StringUtils.class); StringUtils.isEmpty(string);
九 驗證資料物件
JUnit測試框架中Assert類就是斷言工具類,主要驗證單元測試中實際資料物件與期望資料物件一致。在呼叫被測方法時,需要對返回值和異常進行驗證;在驗證方法呼叫時,也需要對捕獲的引數值進行驗證。
1 驗證資料物件空值
驗證資料物件為空
通過JUnit提供的Assert.assertNull方法驗證資料物件為空。
Assert.assertNull("使用者標識必須為空", userId);
驗證資料物件非空
通過JUnit提供的Assert.assertNotNull方法驗證資料物件非空。
Assert.assertNotNull("使用者標識不能為空", userId);
2 驗證資料物件布林值
驗證資料物件為真
通過JUnit提供的Assert.assertTrue方法驗證資料物件為真。
Assert.assertTrue("返回值必須為真", NumberHelper.isPositive(1));
驗證資料物件為假
通過JUnit提供的Assert.assertFalse方法驗證資料物件為假。
Assert.assertFalse("返回值必須為假", NumberHelper.isPositive(-1));
3 驗證資料物件引用
在單元測試用例中,對於一些引數或返回值物件,不需要驗證物件具體取值,只需要驗證物件引用是否一致。
驗證資料物件一致
JUnit提供的Assert.assertSame方法驗證資料物件一致。
UserDO expectedUser = ...; Mockito.doReturn(expectedUser).when(userDAO).get(userId); UserDO actualUser = userService.getUser(userId); Assert.assertSame("使用者必須一致", expectedUser, actualUser);
驗證資料物件不一致
JUnit提供的Assert.assertNotSame方法驗證資料物件一致。
UserDO expectedUser = ...; Mockito.doReturn(expectedUser).when(userDAO).get(userId); UserDO actualUser = userService.getUser(otherUserId); Assert.assertNotSame("使用者不能一致", expectedUser, actualUser);
4 驗證資料物件值
JUnit提供Assert.assertEquals、Assert.assertNotEquals、Assert.assertArrayEquals方法組,可以用來驗證資料物件值是否相等。
驗證簡單資料物件
對於簡單資料物件(比如:基礎型別、包裝型別、實現了equals的資料型別……),可以直接通過JUnit的Assert.assertEquals和Assert.assertNotEquals方法組進行驗證。
Assert.assertNotEquals("使用者名稱稱不一致", "admin", userName); Assert.assertEquals("賬戶金額不一致", 10000.0D, accountAmount, 1E-6D);
驗證簡單陣列或集合物件
對於簡單陣列物件(比如:基礎型別、包裝型別、實現了equals的資料型別……),可以直接通過JUnit的Assert.assertArrayEquals方法組進行驗證。對於簡單集合物件,也可以通過Assert.assertEquals方法驗證。
Long[] userIds = ...; Assert.assertArrayEquals("使用者標識列表不一致", new Long[] {1L, 2L, 3L}, userIds);
List userIdList = ...; Assert.assertEquals("使用者標識列表不一致", Arrays.asList(1L, 2L, 3L), userIdList);
驗證複雜資料物件
對於複雜的JavaBean資料物件,需要驗證JavaBean資料物件的每一個屬性欄位。
UserDO user = ...; Assert.assertEquals("使用者標識不一致", Long.valueOf(1L), user.getId()); Assert.assertEquals("使用者名稱稱不一致", "admin", user.getName()); Assert.assertEquals("使用者公司標識不一致", Long.valueOf(1L), user.getCompany().getId()); ...
驗證複雜陣列或集合物件
對於複雜的JavaBean陣列和集合物件,需要先展開陣列和集合物件中每一個JavaBean資料物件,然後驗證JavaBean資料物件的每一個屬性欄位。
List expectedUserList = ...; List actualUserList = ...; 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()); Assert.assertEquals("使用者公司標識不一致", expectedUsers[i].getCompany().getId(), actualUsers[i].getCompany().getId()); ... }
通過序列化驗證資料物件
如上一節例子所示,當資料物件過於複雜時,如果採用Assert.assertEquals依次驗證每個JavaBean物件、驗證每一個屬性欄位,測試用例的程式碼量將會非常龐大。這裡,推薦使用序列化手段簡化資料物件的驗證,比如利用JSON.toJSONString方法把複雜的資料物件轉化為字串,然後再使用Assert.assertEquals方法進行驗證字串。但是,序列化值必須具備有序性、一致性和可讀性。
List userList = ...; String text = ResourceHelper.getResourceAsString(getClass(), "userList.json"); Assert.assertEquals("使用者列表不一致", text, JSON.toJSONString(userList));
通常使用JSON.toJSONString方法把Map物件轉化為字串,其中key-value的順序具有不確定性,無法用於驗證兩個物件是否一致。這裡,JSON提供序列化選項SerializerFeature.MapSortField(對映排序欄位),可以用於保證序列化後的key-value的有序性。
Map> userMap = ...; String text = ResourceHelper.getResourceAsString(getClass(), "userMap.json"); Assert.assertEquals("使用者對映不一致", text, JSON.toJSONString(userMap, SerializerFeature.MapSortField));
驗證資料物件私有屬性欄位
有時候,單元測試用例需要對複雜物件的私有屬性欄位進行驗證。而PowerMockito提供的Whitebox.getInternalState方法,獲取輕鬆地獲取到私有屬性欄位值。
MapperScannerConfigurer configurer = myBatisConfiguration.buildMapperScannerConfigurer(); Assert.assertEquals("基礎包不一致", "com.alibaba.example", Whitebox.getInternalState(configurer, "basePackage"));
5 驗證異常物件內容
異常作為Java語言的重要特性,是Java語言健壯性的重要體現。捕獲並驗證異常資料內容,也是測試用例的一種。
通過@Test註解驗證異常物件
JUnit的註解@Test提供了一個expected屬性,可以指定一個期望的異常型別,用來捕獲並驗證異常。但是,這種方式只能驗證異常型別,並不能驗證異常原因和訊息。
@Test(expected = ExampleException.class) public void testGetUser() { // 模擬依賴方法 Mockito.doReturn(null).when(userDAO).get(userId);
// 呼叫被測方法
userService.getUser(userId);
}
通過@Rule註解驗證異常物件
如果想要驗證異常原因和訊息,就需求採用@Rule註解定義ExpectedException物件,然後在測試方法的前面宣告要捕獲的異常型別、原因和訊息。
@Rule private ExpectedException exception = ExpectedException.none(); @Test public void testGetUser() { // 模擬依賴方法 Long userId = 123L; Mockito.doReturn(null).when(userDAO).get(userId); // 呼叫被測方法 exception.expect(ExampleException.class); exception.expectMessage(String.format("使用者(%s)不存在", userId)); userService.getUser(userId); }
通過Assert.assertThrows驗證異常物件
在最新版的JUnit中,提供了一個更為簡潔的異常驗證方式——Assert.assertThrows方法。
@Test public void testGetUser() { // 模擬依賴方法 Long userId = 123L; Mockito.doReturn(null).when(userDAO).get(userId); // 呼叫被測方法 ExampleException exception = Assert.assertThrows("異常型別不一致", ExampleException.class, () -> userService.getUser(userId)); Assert.assertEquals("異常訊息不一致", "處理異常", exception.getMessage()); }
十 驗證依賴物件
1 驗證模擬物件沒有任何方法呼叫
Mockito提供了verifyNoInteractions方法,可以驗證模擬物件在被測試方法中沒有任何呼叫。
Mockito.verifyNoInteractions(idGenerator, userDAO);
2 驗證模擬物件沒有更多方法呼叫
Mockito提供了verifyNoMoreInteractions方法,在驗證模擬物件所有方法呼叫後使用,可以驗證模擬物件所有方法呼叫是否都得到驗證。如果模擬物件存在任何未驗證的方法呼叫,就會丟擲NoInteractionsWanted異常。
Mockito.verifyNoMoreInteractions(idGenerator, userDAO);
備註:Mockito的verifyZeroInteractions方法與verifyNoMoreInteractions方法功能相同,但是目前前者已經被廢棄。
3 清除模擬物件所有方法呼叫標記
在編寫單元測試用例時,為了減少單元測試用例數和程式碼量,可以把多組引數定義在同一個單元測試用例中,然後用for迴圈依次執行每一組引數的被測方法呼叫。為了避免上一次測試的方法呼叫影響下一次測試的方法呼叫驗證,最好使用Mockito提供clearInvocations方法清除上一次的方法呼叫。
// 清除所有物件呼叫 Mockito.clearInvocations(); // 清除指定物件呼叫 Mockito.clearInvocations(idGenerator, userDAO);
十一 典型案例及解決方案
這裡,只收集了幾個經典案例,解決了特定環境下的特定問題。
1 測試框架特性導致問題
在編寫單元測試用例時,或多或少會遇到一些問題,大多數是由於對測試框架特性不熟悉導致,比如:
Mockito不支援對靜態方法、構造方法、final方法、私有方法的模擬;
Mockito的any相關的引數匹配方法並不支援可空引數和空引數;
採用Mockito的引數匹配方法時,其它引數不能直接用常量或變數,必須使用Mockito的eq方法;
使用when-then語句模擬Spy物件方法會先執行真實方法,應該使用do-when語句;
PowerMock對靜態方法、構造方法、final方法、私有方法的模擬需要把對應的類新增到@PrepareForTest註解中;
PowerMock模擬JDK的靜態方法、構造方法、final方法、私有方法時,需要把使用這些方法的類加入到@PrepareForTest註解中,從而導致單元測試覆蓋率不被統計;
PowerMock使用自定義的類載入器來載入類,可能導致系統類載入器認為有型別轉換問題;需要加上@PowerMockIgnore({“javax.crypto.*”})註解,來告訴PowerMock這個包不要用PowerMock的類載入器載入,需要採用系統類載入器來載入。
……
對於這些問題,可以根據提示資訊查閱相關資料解決,這裡就不再累述了。
2 捕獲引數值已變更問題
在編寫單元測試用例時,通常採用ArgumentCaptor進行引數捕獲,然後對引數物件值進行驗證。如果引數物件值沒有變更,這個步驟就沒有任何問題。但是,如果引數物件值在後續流程中發生變更,就會導致驗證引數值失敗。
原始程式碼
public void readData(RecordReader recordReader, int batchSize, Function dataParser, Predicate> dataStorage) { try { // 依次讀取資料 Record record; boolean isContinue = true; List dataList = new ArrayList<>(batchSize); while (Objects.nonNull(record = recordReader.read()) && isContinue) { // 解析新增資料 T data = dataParser.apply(record); if (Objects.nonNull(data)) { dataList.add(data); }
// 批量儲存資料
if (dataList.size() == batchSize) {
isContinue = dataStorage.test(dataList);
dataList.clear();
}
}
// 儲存剩餘資料
if (CollectionUtils.isNotEmpty(dataList)) {
dataStorage.test(dataList);
dataList.clear();
}
} catch (IOException e) {
String message = READ_DATA_EXCEPTION;
log.warn(message, e);
throw new ExampleException(message, e);
}
}
測試用例
@Test public void testReadData() throws Exception { // 模擬依賴方法 // 模擬依賴方法: recordReader.read Record record0 = Mockito.mock(Record.class); Record record1 = Mockito.mock(Record.class); Record record2 = Mockito.mock(Record.class); TunnelRecordReader recordReader = Mockito.mock(TunnelRecordReader.class); Mockito.doReturn(record0, record1, record2, null).when(recordReader).read(); // 模擬依賴方法: dataParser.apply Object object0 = new Object(); Object object1 = new Object(); Object object2 = new Object(); Function dataParser = Mockito.mock(Function.class); Mockito.doReturn(object0).when(dataParser).apply(record0); Mockito.doReturn(object1).when(dataParser).apply(record1); Mockito.doReturn(object2).when(dataParser).apply(record2); // 模擬依賴方法: dataStorage.test Predicate> dataStorage = Mockito.mock(Predicate.class); Mockito.doReturn(true).when(dataStorage).test(Mockito.anyList());
// 呼叫測試方法
odpsService.readData(recordReader, 2, dataParser, dataStorage);
// 驗證依賴方法
// 模擬依賴方法: recordReader.read
Mockito.verify(recordReader, Mockito.times(4)).read();
// 模擬依賴方法: dataParser.apply
Mockito.verify(dataParser, Mockito.times(3)).apply(Mockito.any(Record.class));
// 驗證依賴方法: dataStorage.test
ArgumentCaptor<List<Object>> recordListCaptor = ArgumentCaptor.forClass(List.class);
Mockito.verify(dataStorage, Mockito.times(2)).test(recordListCaptor.capture());
Assert.assertEquals("資料列表不一致", Arrays.asList(Arrays.asList(object0, object1), Arrays.asList(object2)), recordListCaptor.getAllValues());
}
問題現象
執行單元測試用例失敗,丟擲以下異常資訊:
java.lang.AssertionError: 資料列表不一致 expected:<[[java.lang.Object@1e3469df, java.lang.Object@79499fa], [java.lang.Object@48531d5]]> but was:<[[], []]>
問題原因
由於引數dataList在呼叫dataStorage.test方法後,都被主動呼叫dataList.clear方法進行清空。由於ArgumentCaptor捕獲的是物件引用,所以最後捕獲到了同一個空列表。
解決方案
可以在模擬依賴方法dataStorage.test時,儲存傳入引數的當前值進行驗證。程式碼如下:
@Test public void testReadData() throws Exception { // 模擬依賴方法 ... // 模擬依賴方法: dataStorage.test List dataList = new ArrayList<>(); Predicate> dataStorage = Mockito.mock(Predicate.class); Mockito.doAnswer(invocation -> dataList.addAll((List)invocation.getArgument(0))) .when(dataStorage).test(Mockito.anyList()); // 呼叫測試方法 odpsService.readData(recordReader, 2, dataParser, dataStorage); // 驗證依賴方法 ... // 驗證依賴方法: dataStorage.test Mockito.verify(dataStorage, Mockito.times(2)).test(Mockito.anyList()); Assert.assertEquals("資料列表不一致", Arrays.asList(object0, object1, object2), dataList); }
3 模擬Lombok的log物件問題
Lombok的@Slf4j註解,廣泛地應用於Java專案中。在某些程式碼分支裡,可能只有log記錄日誌的操作,為了驗證這個分支邏輯被正確執行,需要在單元測試用例中對log記錄日誌的操作進行驗證。
原始方法
@Slf4j @Service public class ExampleService { public void recordLog(int code) { if (code == 1) { log.info("執行分支1"); return; } if (code == 2) { log.info("執行分支2"); return; } log.info("執行預設分支"); } ... }
測試用例
@RunWith(PowerMockRunner.class) public class ExampleServiceTest { @Mock private Logger log; @InjectMocks private ExampleService exampleService; @Test public void testRecordLog1() { exampleService.recordLog(1); Mockito.verify(log).info("執行分支1"); } }
問題現象
執行單元測試用例失敗,丟擲以下異常資訊:
Wanted but not invoked: logger.info("執行分支1");
原因分析
經過調式跟蹤,發現ExampleService中的log物件並沒有被注入。通過編譯發現,Lombok的@Slf4j註解在ExampleService類中生成了一個靜態常量log,而@InjectMocks註解並不支援靜態常量的注入。
解決方案
採用作者實現的FieldHelper.setStaticFinalField方法,可以實現對靜態常量的注入模擬物件。
@RunWith(PowerMockRunner.class) public class ExampleServiceTest { @Mock private Logger log; @InjectMocks private ExampleService exampleService; @Before public void beforeTest() throws Exception { FieldHelper.setStaticFinalField(ExampleService.class, "log", log); } @Test public void testRecordLog1() { exampleService.recordLog(1); Mockito.verify(log).info("執行分支1"); } }
4 相容Pandora等容器問題
阿里巴巴的很多中介軟體,都是基於Pandora容器的,在編寫單元測試用例時,可能會遇到一些坑。
原始方法
@Slf4j public class MetaqMessageSender { @Autowired private MetaProducer metaProducer; public String sendMetaqMessage(String topicName, String tagName, String messageKey, String messageBody) { try { // 組裝訊息內容 Message message = new Message(); message.setTopic(topicName); message.setTags(tagName); message.setKeys(messageKey); message.setBody(messageBody.getBytes(StandardCharsets.UTF_8));
// 傳送訊息請求
SendResult sendResult = metaProducer.send(message);
if (sendResult.getSendStatus() != SendStatus.SEND_OK) {
String msg = String.format("傳送標籤(%s)訊息(%s)狀態錯誤(%s)", tagName, messageKey, sendResult.getSendStatus());
log.warn(msg);
throw new ReconsException(msg);
}
log.info(String.format("傳送標籤(%s)訊息(%s)狀態成功:%s", tagName, messageKey, sendResult.getMsgId()));
// 返回訊息標識
return sendResult.getMsgId();
} catch (MQClientException | RemotingException | MQBrokerException | InterruptedException e) {
// 記錄訊息異常
Thread.currentThread().interrupt();
String message = String.format("傳送標籤(%s)訊息(%s)狀態異常:%s", tagName, messageKey, e.getMessage());
log.warn(message, e);
throw new ReconsException(message, e);
}
}
}
測試用例
@RunWith(PowerMockRunner.class) public class MetaqMessageSenderTest { @Mock private MetaProducer metaProducer; @InjectMocks private MetaqMessageSender metaqMessageSender; @Test public void testSendMetaqMessage() throws Exception { // 模擬依賴方法 SendResult sendResult = new SendResult(); sendResult.setMsgId("msgId"); sendResult.setSendStatus(SendStatus.SEND_OK); Mockito.doReturn(sendResult).when(metaProducer).send(Mockito.any(Message.class));
// 呼叫測試方法
String topicName = "topicName";
String tagName = "tagName";
String messageKey = "messageKey";
String messageBody = "messageBody";
String messageId = metaqMessageSender.sendMetaqMessage(topicName, tagName, messageKey, messageBody);
Assert.assertEquals("messageId不一致", sendResult.getMsgId(), messageId);
// 驗證依賴方法
ArgumentCaptor<Message> messageCaptor = ArgumentCaptor.forClass(Message.class);
Mockito.verify(metaProducer).send(messageCaptor.capture());
Message message = messageCaptor.getValue();
Assert.assertEquals("topicName不一致", topicName, message.getTopic());
Assert.assertEquals("tagName不一致", tagName, message.getTags());
Assert.assertEquals("messageKey不一致", messageKey, message.getKeys());
Assert.assertEquals("messageBody不一致", messageBody, new String(message.getBody()));
}
}
問題現象
執行單元測試用例失敗,丟擲以下異常資訊:
java.lang.RuntimeException: com.alibaba.rocketmq.client.producer.SendResult was loaded by org.powermock.core.classloader.javassist.JavassistMockClassLoader@5d43661b, it should be loaded by Pandora Container. Can not load this fake sdk class.
原因分析
基於Pandora容器的中介軟體,需要使用Pandora容器載入。在上面測試用例中,使用了PowerMock容器載入,從而導致丟擲類載入異常。
解決方案
首先,把PowerMockRunner替換為PandoraBootRunner。其次,為了使@Mock、@InjectMocks等Mockito註解生效,需要呼叫MockitoAnnotations.initMocks(this)方法進行初始化。
@RunWith(PandoraBootRunner.class) public class MetaqMessageSenderTest { ... @Before public void beforeTest() { MockitoAnnotations.initMocks(this); } ... }
十二 消除型別轉換警告
在編寫測試用例時,特別是泛型型別轉換時,很容易產生型別轉換警告。常見型別轉換警告如下:
Type safety: Unchecked cast from Object to List Type safety: Unchecked invocation forClass(Class
作為一個有程式碼潔癖的輕微強迫症程式設計師,是絕對不容許這些型別轉換警告產生的。於是,總結了以下方法來解決這些型別轉換警告。
1 利用註解初始化
Mockito提供@Mock註解來模擬類例項,提供@Captor註解來初始化引數捕獲器。由於這些註解例項是通過測試框架進行初始化的,所以不會產生型別轉換警告。
問題程式碼
Map resultMap = Mockito.mock(Map.class);
ArgumentCaptor
建議程式碼
@Mock
private Map resultMap;
@Captor
private ArgumentCaptor
2 利用臨時類或介面
我們無法獲取泛型類或介面的class例項,但是很容易獲取具體類的class例項。這個解決方案的思路是——先定義繼承泛型類的具體子類,然後mock、spy、forClass以及any出這個具體子類的例項,然後把具體子類例項轉換為父類泛型例項。
問題程式碼
Function dataParser = Mockito.mock(Function.class); AbstractDynamicValue dynamicValue = Mockito.spy(AbstractDynamicValue.class); ArgumentCaptor> requestCaptor = ArgumentCaptor.forClass(ActionRequest.class);
建議程式碼
/** 定義臨時類或介面 */ private interface DataParser extends Function {}; private static abstract class AbstractTemporaryDynamicValue extends AbstractDynamicValue {}; private static class VoidActionRequest extends ActionRequest {};
/** 使用臨時類或介面 */ Function dataParser = Mockito.mock(DataParser.class); AbstractDynamicValue dynamicValue = Mockito.spy(AbstractTemporaryDynamicValue.class); ArgumentCaptor> requestCaptor = ArgumentCaptor.forClass(VoidActionRequest.class);
3 利用CastUtils.cast方法
SpringData包中提供一個CastUtils.cast方法,可以用於型別的強制轉換。這個解決方案的思路是——利用CastUtils.cast方法遮蔽型別轉換警告。
問題程式碼
Function dataParser = Mockito.mock(Function.class); ArgumentCaptor> requestCaptor = ArgumentCaptor.forClass(ActionRequest.class); Map scoreMap = (Map)method.invoke(userService);
建議程式碼
Function dataParser = CastUtils.cast(Mockito.mock(Function.class)); ArgumentCaptor> requestCaptor = CastUtils.cast(ArgumentCaptor.forClass(ActionRequest.class)); Map scoreMap = CastUtils.cast(method.invoke(userService));
這個解決方案,不需要定義註解,也不需要定義臨時類或介面,能夠讓測試用例程式碼更為精簡,所以作者重點推薦使用。如果不願意引入SpringData包,也可以自己參考實現該方法,只是該方法會產生型別轉換警告。
注意:CastUtils.cast方法本質是——先轉換為Object型別,再強制轉換對應型別,本身不會對型別進行校驗。所以,CastUtils.cast方法好用,但是不要亂用,否則就是大坑(只有執行時才能發現問題)。
4 利用型別自動轉換方法
在Mockito中,提供形式如下的方法——泛型型別只跟返回值有關,而跟輸入引數無關。這樣的方法,可以根據呼叫方法的引數型別自動轉換,而無需手動強制型別轉換。如果手動強制型別轉換,反而會產生型別轉換警告。
T getArgument(int index); public static T any(); public static synchronized T invokeMethod(Object instance, String methodToExecute, Object... arguments) throws Exception;
問題程式碼
Mockito.doAnswer(invocation -> dataList.addAll((List)invocation.getArgument(0))) .when(dataStorage).test(Mockito.anyList()); Mockito.doThrow(e).when(workflow).beginToPrepare((ActionRequest)Mockito.any()); Map scoreMap = (Map)Whitebox.invokeMethod(userService, "getScoreMap");
建議程式碼
Mockito.doAnswer(invocation -> dataList.addAll(invocation.getArgument(0))) .when(dataStorage).test(Mockito.anyList()); Mockito.doThrow(e).when(workflow).beginToPrepare(Mockito.any()); Map scoreMap = Whitebox.invokeMethod(userService, "getScoreMap");
其實,SpringData的CastUtils.cast方法之所以這麼強悍,也是採用了型別自動轉化方法。
5 利用doReturn-when語句代替when-thenReturn語句
Mockito的when-thenReturn語句需要對返回型別強制校驗,而doReturn-when語句不會對返回型別強制校驗。利用這個特性,可以利用doReturn-when語句代替when-thenReturn語句解決型別轉換警告。
問題程式碼
List valueList = Mockito.mock(List.class); Mockito.when(listOperations.range(KEY, start, end)).thenReturn(valueList);
建議程式碼
List<?> valueList = Mockito.mock(List.class); Mockito.doReturn(valueList).when(listOperations).range(KEY, start, end);
6 利用Whitebox.invokeMethod方法代替Method.invoke方法
JDK提供的Method.invoke方法返回的是Object型別,轉化為具體型別時需要強制轉換,會產生型別轉換警告。而PowerMock提供的Whitebox.invokeMethod方法返回型別可以自動轉化,不會產生型別轉換警告
問題程式碼
Method method = PowerMockito.method(UserService.class, "getScoreMap"); Map scoreMap = (Map)method.invokeMethod(userService);
建議程式碼
Map scoreMap = Whitebox.invokeMethod(userService, "getScoreMap");
7 利用instanceof關鍵字
在具體型別強制轉換時,建議利用instanceof關鍵字先判斷型別,否則會產生型別轉換警告。
問題程式碼
JSONArray jsonArray = (JSONArray)object; ...
建議程式碼
if (object instanceof JSONArray) { JSONArray jsonArray = (JSONArray)object; ... }
8 利用Class.cast方法
在泛型型別強制轉換時,會產生型別轉換警告。可以採用泛型類的cast方法轉換,從而避免產生型別轉換警告。
問題程式碼
public static V parseValue(String text, Class clazz) { if (Objects.equals(clazz, String.class)) { return (V)text; } return JSON.parseObject(text, clazz); }
建議程式碼
public static V parseValue(String text, Class clazz) { if (Objects.equals(clazz, String.class)) { return clazz.cast(text); } return JSON.parseObject(text, clazz); }
9 避免不必要的型別轉換
有時候,沒有必要進行型別轉換,就儘量避免型別轉換。比如:把Object型別轉換為具體型別,但又把具體型別當Object型別使用,就沒有必要進行型別轉換。像這種情況,可以合併表示式或定義基類變數,從而避免不必要的型別轉化。
問題程式碼
Boolean isSupper = (Boolean)method.invokeMethod(userService, userId); Assert.assertEquals("期望值不為真", Boolean.TRUE, isSupper);
List userList = (Map)method.invokeMethod(userService, companyId); Assert.assertEquals("期望值不一致", expectedJson, JSON.toJSONString(userList));
建議程式碼
Assert.assertEquals("期望值不為真", Boolean.TRUE, method.invokeMethod(userService, userId));
Object userList = method.invokeMethod(userService, companyId); Assert.assertEquals("期望值不一致", expectedJson, JSON.toJSONString(userList))
相關文章
- 好程式設計師Java培訓分享Java程式設計技巧程式設計師Java
- Java 程式設計技巧之資料結構Java程式設計資料結構
- 程式設計技巧整理:Java程式效能最佳化總結!程式設計Java
- 【程式設計小技巧】程式設計
- 中軟卓越:Java程式設計師面試技巧3Java程式設計師面試
- Java程式設計指南:高階技巧解析 - Excel單元格樣式的程式設計設定Java程式設計Excel
- 程式設計技巧│提高 Javascript 程式碼效率的技巧程式設計JavaScript
- 程式常用的設計技巧
- 好程式設計師Java分享Java開發常用規範技巧一程式設計師Java
- Java乾貨神總結,程式設計師面試技巧Java程式設計師面試
- Java程式設計小技巧(1)——方法傳回兩個物件Java程式設計物件
- Python 程式設計實用技巧Python程式設計
- 快速掌握Java8 Stream函數語言程式設計技巧Java函數程式設計
- 美女程式設計師觀點:程式設計師最重要的非程式設計技巧程式設計師
- 程式設計師程式設計時的簡單方法與技巧程式設計師
- Java 網路程式設計(TCP程式設計 和 UDP程式設計)Java程式設計TCPUDP
- 18個Python高效程式設計技巧!Python程式設計
- Java程式設計師學習的6個技巧, 你get到了嗎?Java程式設計師
- Java程式設計師想要跳槽,一定要注意這些技巧!Java程式設計師
- Java程式設計技巧:if-else優化實踐總結歸納Java程式設計優化
- JAVA網路程式設計(2)TCP程式設計Java程式設計TCP
- Java Socket程式設計Java程式設計
- Java程式設計:圖Java程式設計
- 程式設計技巧之-表驅動法程式設計
- IT程式設計師面試技巧有哪些?程式設計師面試
- Linux系統程式設計:mmap使用技巧Linux程式設計
- Golang非同步程式設計方式和技巧Golang非同步程式設計
- Java 網路程式設計 —— 非阻塞式程式設計Java程式設計
- Java程式設計師學習Rust程式設計 - infoworldJava程式設計師Rust
- 五種Java程式設計高效程式設計方法 - BablaJava程式設計
- java併發程式設計系列:java併發程式設計背景知識Java程式設計
- 好程式設計師Python培訓分享Python程式設計師面試技巧程式設計師Python面試
- 好程式設計師Java教程分享Java之設計模式程式設計師Java設計模式
- 【JavaScript】常用設計模式及程式設計技巧(ES6描述)JavaScript設計模式程式設計
- Java 程式設計開發Java程式設計
- java 併發程式設計Java程式設計
- java程式設計環境Java程式設計
- Java 網路程式設計Java程式設計