1. 單元測試概述
最小的可測試的單元就是單元測試龙宏,可以是一個函數明垢,一個類狐赡。
1.1 為什么需要單元測試
- 節(jié)省測試時間
測試一個最小單元是否有邏輯問題盒粮,無需到測試環(huán)境中去(比如創(chuàng)建數據庫,創(chuàng)建文件等一些麻煩且耗時的操作)嵌巷。 - 防止回歸
我們寫的東西不會破壞已有的功能萄凤,確保我們的修改和設計不會破壞已有的功能。比如你修改了一個類搪哪,這個類會被很多其他的類使用靡努,這樣可能會造成一些問題。所以單元測試在代碼重構上相當重要晓折。 - 提高代碼質量
- 保證行為的正確性
比如你輸入了一個異常的數惑朦,就需要拋異常。
1.2 什么是好的單元測試
- 快速:對成熟項目進行數千次單元測試,這很常見漓概。應花非常少的時間來運行單元測試漾月。
- 獨立:單元測試是獨立的,可以單獨運行,并且不依賴文件系統(tǒng)或數據庫等任何外部因素。如果真的連數據庫我們叫它集成測試胃珍。
- 可重復:運行單元測試的結果應該保持一致梁肿,也就是說,如果在運行期間不更改任何內容觅彰,總是返回相同的結果吩蔑。
- 自檢查:測試應該能夠在沒有任何人工交互的情況下,自動檢測測試是否通過填抬。
1.3 測試用例命名
理想情況下,包括一下三個部分:
- 要測試方法的名稱
- 測試的方案
- 調用方案時的預期行為
我們至少包含前兩條,比如:
testGetUserInfoByUserIdWithInvalidUserId()
testGetUserInfoByUserId() -> happy case
1.4 AAA(Arrange, Act, Assert) pattern
Arrange-Act-Assert是單元測試的常見模式
包括三個操作:
- Arrange:安排好所有先要條件和輸入,根據需要進行創(chuàng)建和設置厌秒。
- Act:對要測試的對象或者方法進行調用擅憔。
- Assert:斷言結果是否按預期進行鸵闪。
2. 在 SpringBoot中進行單元測試
我們從文檔中找到依賴,發(fā)現依賴中有exclusion蚌讼,這是因為使用JUnit 5, 就必須exclude JUnit 4个榕。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
2.1 一些用于快速入門的簡單例子
public class AddManager {
public int add(int number) {
return number += 1;
}
}
public class AddManagerTest {
private AddManager addManager = new AddManager();
@Test
void testAdd() {
// Arrange
int number = 100;
// Act
int result = addManager.add(number);
// Assert
assertEquals(101, result);
}
}
如果將101改成80篡石,則會出現以下的報錯
org.opentest4j.AssertionFailedError:
Expected :80
Actual :101
如果在每個方法執(zhí)行之前我們要執(zhí)行一些操作(比如初始化),就可以使用@BeforeEach注解西采。同理還有@AfterEach(主要用于一些拆卸操作)械馆。
public class AddManagerTest {
private AddManager addManager;
@BeforeEach
void setup() {
addManager = new AddManager();
}
@AfterEach
void teardown() {
// .....
}
// ......
}
下面是一個真實的例子。我們要去測試Manager層的UserInfoManager類珊搀。以下是一些解釋尾菇。
- UserInfoMapper 是使用 MyBatis 寫一個接口,用于查詢數據庫并返回用戶信息劳淆,在源碼中沒有該接口的實現類千埃。
- 其他所有的類都有對應的實現類放可,可以拿來直接用朝刊。
- 如果用戶為空,則拋出 ResourceNotFoundException 異常冯挎。
該例子的所有類依賴關系如下所示咙鞍。
UserInfoManage //有實現類 UserInfoManageImpl
└── UserInfoDao //有實現類 UserInfoDaoImpl
└── userInfoMapper //無實現類,只是個接口
└── UserInfoP2CConverter //有實現類 UserInfoP2CConverter
那么問題來了孵奶,UserInfoMapper是個接口蜡峰,無法直接生成實例,而 UserInfoDao 又依賴 UserInfoMapper载绿,怎么去創(chuàng)造這個實例呢油航?方法其實很簡單我們在test/java/com/.../utils下創(chuàng)建一些測試會使用的到的工具類。因為 UserInfoMapper 的作用就是返回用戶信息冀自,我們可以直接返回一些假的數秒啦。具體地余境,我們創(chuàng)建 UserInfoMapperTestImpl 類去繼承 UserInfoMapper 作為它的一個實現類,完成它的功能含末。
public class UserInfoMapperTestImpl implements UserInfoMapper {
@Override
public UserInfo getUserInfoById(long id) {
return id > 0 ? UserInfo.builder()
.username("admin")
.password("admin")
.createTime(LocalDate.now())
.id(1L)
.build() : null;
}
}
如下是單元測試的代碼即舌。在 Assert 階段我們分別使用了 JUnit 5 和 AssertJ 中的不同方法去實現顽聂,AssertJ看其來更加清晰。一般地蜜葱,如果測試一些會拋出異常的函數耀石,我們將 Act 和 Assert 寫在一起,用 assertThrows 方法揭鳞。
public class UserInfoManagerTest2 {
private UserInfoManager userInfoManager;
@BeforeEach
void setup() {
UserInfoP2CConverter userInfoP2CConverter = new UserInfoP2CConverter();
UserInfoMapper userInfoMapper = new UserInfoMapperTestImpl();
UserInfoDao userInfoDao = new UserInfoDaoImpl(userInfoMapper);
userInfoManager = new UserInfoManagerImpl(userInfoDao, userInfoP2CConverter);
}
@Test
void testGetUserInfoById() {
// Arrange
long userId = 1L;
// Act
UserInfo userInfo = userInfoManager.getUserInfoById(userId);
// Assert with JUnit 5
assertEquals("admin", userInfo.getUsername());
assertEquals("admin", userInfo.getPassword());
assertEquals(userId, userInfo.getId());
// Assert With AssertJ
assertThat(userInfo).isNotNull()
.hasFieldOrPropertyWithValue("id", userId)
.hasFieldOrPropertyWithValue("username", "admin")
.hasFieldOrPropertyWithValue("password", "admin");
}
@Test
void testGetUserInfoByIdWithInvalidUserId() {
// Arrange
long userId = -1L;
// Act & Assert
assertThrows(ResourceNotFoundException.class, () -> userInfoManager.getUserInfoById(userId));
}
}
2.2 引入 Mockito 完善單元測試
上文的依賴還不夠復雜鲁驶,如果依賴非常的復雜舞骆,我們難道要一個個造 testImp(test place holder) 嗎?是否可以直接模擬這些復雜函數的行為呢脆霎?比如:
when xxx case
call UserInfoDao.getUserInfoById()
return xxx value or throw xxx exception
2.2.1 Mockito的簡單使用
Mockito 就是完成以上需求的睛蛛,以下是 Mockito 的簡單使用胧谈。
LinkedList mockedList = mock(LinkedList.class);
//stubbing
when(mockedList.get(0)).thenReturn("first");
when(mockedList.get(1)).thenThrow(new RuntimeException());
//following prints "first"
System.out.println(mockedList.get(0));
//following throws runtime exception
System.out.println(mockedList.get(1));
//following prints "null" because get(999) was not stubbed
System.out.println(mockedList.get(999));
//Although it is possible to verify a stubbed invocation, usually it's just redundant
//If your code cares what get(0) returns, then something else breaks (often even before verify() gets executed).
//If your code doesn't care what get(0) returns, then it should not be stubbed.
verify(mockedList).get(0);
Mockito 以 equals() 方法驗證參數菱肖。有時,當需要額外的靈活性時场仲,可以使用參數匹配器退疫。參數匹配器褒繁,只有兩種形式 anyX() 或是 eq()。
//可以用anyInt()燕差,表示任何int
when(mockedList.get(anyInt())).thenReturn("element");
//following prints "element"
System.out.println(mockedList.get(999));
//我們也可以在verify的時候用參數匹配
verify(mockedList).get(anyInt());
//如果你正在使用的是參數匹配器俊抵,所有參數都必須由匹配器提供徽诲。
verify(mock).someMethod(anyInt(), anyString(), eq("third argument"));
//above is correct - eq() is also an argument matcher
verify(mock).someMethod(anyInt(), anyString(), "third argument");
//above is incorrect - exception will be thrown because third argument is given without an argument matcher.
還可以驗證多次調用的函數
verify(mockedList, times(3)).add("three times");
verify(mockedList, never()).add("never happened");
verify(mockedList, atMostOnce()).add("once");
verify(mockedList, atLeastOnce()).add("three times");
verify(mockedList, atLeast(2)).add("three times");
verify(mockedList, atMost(5)).add("three times");
subbing 一些拋出異常的函數
doThrow(new RuntimeException()).when(mockedList).clear();
subbing 的兩種寫法
// doNothing doThrow 只能這樣寫
doReturn("Hello World").when(mockList).get(1)
when(mockedList.get(1)).thenReturn("Hello World");
2.2.2 使用 Mockito 改寫例子
除了用 mock() 的方式外谎替,我們還可以使用注解 @Mock 的方式進行mock,值得注意的是使用注解的方式必須初始化MockitoAnnotations.initMocks(this)
(這是老版本的寫法挫掏,目前已經廢棄秩命,這里提供新的寫法)弃锐。可以看到使用了 Mockito 后我們無需再為一些接口創(chuàng)建實現類了剧蚣,我們更加注重需要 mock 的這個函數到底該完成了什么樣的功能旋廷。
public class UserInfoManagerTest {
private UserInfoManager userInfoManager;
@Mock
private UserInfoDao userInfoDao;
@BeforeEach
void setup() {
MockitoAnnotations.initMocks(this);
userInfoManager = new UserInfoManagerImpl(userInfoDao, new UserInfoP2CConverter());
}
@Test
public void testGetUserInfoById() {
long userId = 1L;
String username = "admin";
String password = "admin";
LocalDate createTime = LocalDate.now();
val userInfo = UserInfo.builder()
.id(userId)
.username(username)
.password(password)
.createTime(createTime)
.build();
doReturn(userInfo).when(userInfoDao).getUserInfoById(userId);
val result = userInfoManager.getUserInfoById(userId);
assertEquals(userId, result.getId());
assertEquals("admin", result.getUsername());
assertEquals("admin", result.getPassword());
verify(userInfoDao).getUserInfoById(eq(userId));
}
@Test
public void testGetUserInfoByIdWithInvalidParameter() {
long userId = -1L;
doReturn(null).when(userInfoDao).getUserInfoById(userId);
assertThrows(ResourceNotFoundException.class, () -> userInfoManager.getUserInfoById(userId));
verify(userInfoDao).getUserInfoById(eq(userId));
}
}
controller層的測試有些不一樣饶碘。按我們前面的做法扎运,在arrange的時候,我們直接 new 一個 controller 的實例用于測試测蹲,這樣是不能測試 MVC 的一些特性(返回的一些東西)鬼吵。這里我們先舉兩個例子齿椅。
例1. 假設有這樣一個GreetingController。
@RestController
public class GreetingController {
@GetMapping("/greeting")
public String greeting(@RequestParam("name") String name) {
return "Hello " + name;
}
}
Spring 自帶了用于測試 MVC 的 MockMvc 類示辈。生成 MockMvc 需要用MockMvcBuilders 的 standaloneSetup 方法遣蚀。MockMvc 的 perform 方法用于執(zhí)行一個請求并返回一個 ResultActions 類,該類型允許對結果鏈式操作操作险耀,例如斷言期望甩牺。考慮到 java 中有很多的 get急但、status搞乏、content 方法查描,這里給出具體的方法(千萬不要 import 錯了):
MockMvcRequestBuilders.get()
MockMvcResultMatchers.status()
MockMvcResultMatchers.content()
public class GreetingControllerTest {
private MockMvc mockMvc;
@BeforeEach
public void setup() {
mockMvc = MockMvcBuilders.standaloneSetup(new GreetingController()).build();
}
@Test
void testGreeting() throws Exception {
mockMvc.perform(get("/greeting").param("name", "admin"))
.andExpect(status().isOk())
.andExpect(content().string("Hello admin"));
}
}
例2. 另有一個 UserController,如果接受到的 userId 小于等于0則拋出 InvalidParameterException匀油,而我們用 @RestControllerAdvice 對異常進行了統(tǒng)一處理(在 SpringBoot 中的異常處理)敌蚜。我們在 MockMvcBuilders 創(chuàng)建MockMvc 時設置Controller的增強(setControllerAdvice)窝爪。
@RestController
@Slf4j
public class UserController {
private final UserInfoManager userInfoManager;
private final UserInfoC2SConverter userInfoC2SConverter;
@Autowired
public UserController(UserInfoManager userInfoManager, UserInfoC2SConverter userInfoC2SConverter) {
this.userInfoManager = userInfoManager;
this.userInfoC2SConverter = userInfoC2SConverter;
}
@GetMapping("/{id}")
public ResponseEntity<?> getUserInfoById(@PathVariable("id") long id) {
if (id <= 0L) {
throw new InvalidParameterException(String.format("User id %s is invalid", id));
}
val userInfo = userInfoManager.getUserInfoById(id);
return ResponseEntity.ok(userInfoC2SConverter.convert(userInfo));
}
}
具體寫法如下所示蒲每,大同小異。親測贫奠,如果不設置Conroller增強望蜡,則會報奇怪的錯誤脖律。
public class UserControllerTest {
private MockMvc mockMvc;
@Mock
public UserInfoManager userInfoManager;
@BeforeEach
void setup() {
MockitoAnnotations.initMocks(this);
mockMvc = MockMvcBuilders.standaloneSetup(new UserController(userInfoManager, new UserInfoC2SConverter()))
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}
@AfterEach
void teardown() {
reset(userInfoManager);
}
@Test
void testGetUserInfoById() throws Exception {
// Arrange
val userId = 100L;
val username = "admin";
val password = "admin";
val userInfoInCommon = com.lazyben.accounting.model.common.UserInfo.builder()
.id(userId)
.username(username)
.password(password)
.build();
doReturn(userInfoInCommon).when(userInfoManager).getUserInfoById(userId);
// Act & Assert
mockMvc.perform(MockMvcRequestBuilders.get("/" + userId))
.andExpect(content().string("{\"id\":100,\"username\":\"admin\",\"password\":null}"))
.andExpect(content().contentType("application/json"))
.andExpect(status().isOk());
verify(userInfoManager).getUserInfoById(anyLong());
}
@Test
void testGetUserInfoByIdWithInvalidUserId() throws Exception {
// Arrange
val userId = -1L;
doThrow(new InvalidParameterException(String.format("User %s is not found", userId)))
.when(userInfoManager)
.getUserInfoById(anyLong());
// Act & Assert {"code":"INVALID_PARAMETER","message":"User id -1 is invalid","statusCode":400,"errorType":"Client"}
mockMvc.perform(MockMvcRequestBuilders.get("/" + userId))
.andExpect(status().is4xxClientError())
.andExpect(content().contentType("application/json"))
.andExpect(content().string("{\"code\":\"INVALID_PARAMETER\",\"message\":\"User id -1 is invalid\",\"statusCode\":400,\"errorType\":\"Client\"}"));
}
}