基於spring-boot的應用程序的單元測試方案
概述
本文主要介紹如何對基於spring-boot的web應用編寫單元測試、集成測試的代碼。
此類應用的架構圖一般如下所示:
我們項目的程序,對應到上圖中的web應用部分。這部分一般分為Controller層、service層、持久層。除此之外,應用程序中還有一些數據封裝類,我們稱之為domain。上述各組件的職責如下:
- Controller層/Rest接口層: 負責對外提供Rest服務,接收Rest請求,返回處理結果。
- service層: 業務邏輯層,根據Controller層的需要,實現具體的邏輯。
- 持久層: 訪問數據庫,進行數據的讀寫。向上支撐service層的數據庫訪問需求。
在Spring環境中,我們通常會把這三層註冊到Spring容器,上圖中使用淺藍色背景就是為了表示這一點。
在本文的後續內容,我們將介紹如何對應用進行集成測試,包括啟動web容器的請求測試、不啟動web容器而使用模擬環境的測試;介紹如何對應用進行單元測試,包括單獨測試Controller層、service層、持久層。
集成測試和單元測試的區別是,集成測試通常只需要測試最上面一層,因為上層會自動調用下層,所以會測試完整的流程鏈,流程鏈中每一個環節都是真實、具體的。單元測試是單獨測試流程鏈中的某一環,這一個環所直接依賴的下遊環節使用模擬的方式來提供支撐,這一技術稱為Mock。在介紹單元測試的時候,我們會介紹如何mock依賴對象,並簡單對mock的原理進行介紹。
本文所關註的另一個主題,是在持久層測試時,如何消除修改數據庫的副作用。
集成測試
集成測試是在所有組件都已經開發完成之後,進行組裝測試。有兩種測試方式:啟動web容器進行測試,使用模擬環境測試。這兩種測試的效果沒有什麽差別,只是使用模擬環境測試的話,可以不用啟動web容器,從而會少一些開銷。另外,兩者的測試API會有所不同。
啟動web容器進行測試
我們通過測試最上層的Controller來實施集成測試,我們的測試目標如下:
@RestController public class CityController { @Autowired private CityService cityService; @GetMapping("/cities") public ResponseEntity<?> getAllCities() { List<City> cities = cityService.getAllCities(); return ResponseEntity.ok(cities); } }
這是一個Controller,它對外提供一個服務/cities
,返回一個包含所有城市的列表。這個Controller通過調用下一層的CityService來完成自己的職責。
針對這個Controller的集成測試方案如下:
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class CityControllerWithRunningServer {
@Autowired
private TestRestTemplate restTemplate;
@Test
public void getAllCitiesTest() {
String response = restTemplate.getForObject("/cities", String.class);
Assertions.assertThat(response).contains("San Francisco");
}
}
首先我們使用@RunWith(SpringRunner.class)
聲明在Spring的環境中進行單元測試,這樣Spring的相關註解才會被識別並起效。然後我們使用@SpringBootTest,它會掃描應用程序的spring配置,並構建完整的Spring Context。我們為其參數webEnvironment賦值為SpringBootTest.WebEnvironment.RANDOM_PORT,這樣就會啟動web容器,並監聽一個隨機的端口,同時,為我們自動裝配一個TestRestTemplate類型的bean來輔助我們發送請求。
使用模擬環境測試
測試的目標不變,測試的方案如下:
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class CityControllerWithMockEnvironment {
@Autowired
private MockMvc mockMvc;
@Test
public void getAllCities() throws Exception {
mockMvc.perform(MockMvcRequestBuilders.get("/cities"))
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.content().string(Matchers.containsString("San Francisco")));
}
}
我們依然使用@SpringBootTest
,但是沒有設置其webEnvironment
屬性,這樣依然會構建完整的Spring Context,但是不會再啟動web容器。為了進行測試,我們需要使用MockMvc
實例發送請求,而我們使用@AutoConfigureMockMvc
則是因為這樣可以獲得自動配置的MockMvc
實例。
具體測試的代碼中出現很多新的API,對於API細節的研究不在本文計劃範圍內。
單元測試
上文中描述的兩種集成測試的方案,相同的一點是都會構建整個Spring Context。這表示所有聲明的bean,而不管聲明的方式為何,都會被構建實例,並且都能被依賴。這裏隱含的意思是從上到下整條依賴鏈上的代碼都已實現。
Mock技術
在開發的過程中進行測試,無法滿足上述的條件,Mock技術可以讓我們屏蔽掉下層的依賴,從而專註於當前的測試目標。Mock技術的思想是,當測試目標的下層依賴的行為是可預期的,那麽測試目標本身的行為也是可預期的,測試就是把實際的結果和測試目標的預期結果做比較,而Mock就是預先設定下層依賴的行為表現。
Mock的流程
- 將測試目標的依賴對象進行mock,設定其預期的行為表現。
- 對測試目標進行測試。
- 檢測測試結果,檢查在依賴對象的預期行為下,測試目標的結果是否符合預期。
Mock的使用場景
- 多人協作時,可以通過mock進行無等待的測試先行。
- 當測試目標的依賴對象需要訪問外部的服務,而外部服務不易獲得時,可以通過mock來模擬服務可用。
- 當在排查不容易復現的問題場景時,通過mock來模擬問題。
測試web層
測試的目標不變,測試的方案如下:
/**
* 不構建整個Spring Context,只構建指定的Controller進行測試。需要對相關的依賴進行mock.<br>
* Created by lijinlong9 on 2018/8/22.
*/
@RunWith(SpringRunner.class)
@WebMvcTest(CityController.class)
public class CityControllerWebLayer {
@Autowired
private MockMvc mvc;
@MockBean
private CityService service;
@Test
public void getAllCities() throws Exception {
City city = new City();
city.setId(1L);
city.setName("杭州");
city.setState("浙江");
city.setCountry("中國");
Mockito.when(service.getAllCities()).thenReturn(Collections.singletonList(city));
mvc.perform(MockMvcRequestBuilders.get("/cities"))
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.content().string(Matchers.containsString("杭州")));
}
}
這裏不再使用@SpringBootTest
,而代之以@WebMvcTest
,這樣只會構建web層或者指定的一到多個Controller的bean。@WebMvcTest
同樣可以為我們自動配置MockMvc
類型的bean,我們可以使用它來模擬發送請求。
@MockBean
是一個新接觸的註解,它表示對應的bean是一個模擬的bean。因為我們要測試CityController
,對其依賴的CityService
,我們需要mock其預期的行為表現。在具體的測試方法中,使用Mockito的API對sercive的行為進行mock,它表示當調用service的getAllCities時,會返回預先設定的一個City對象的列表。
之後就是發起請求,並預測結果。
Mockito是Java語言的mock測試框架,spring以自己的方式集成了它。
測試持久層
持久層的測試方案跟具體的持久層技術相關。這裏我們介紹基於Mybatis的持久層的測試。
測試目標是:
@Mapper
public interface CityMapper {
City selectCityById(int id);
List<City> selectAllCities();
int insert(City city);
}
測試方案是:
@RunWith(SpringRunner.class)
@MybatisTest
@FixMethodOrder(value = MethodSorters.NAME_ASCENDING)
// @Transactional(propagation = Propagation.NOT_SUPPORTED)
public class CityMapperTest {
@Autowired
private CityMapper cityMapper;
@Test
public void /*selectCityById*/ test1() throws Exception {
City city = cityMapper.selectCityById(1);
Assertions.assertThat(city.getId()).isEqualTo(Long.valueOf(1));
Assertions.assertThat(city.getName()).isEqualTo("San Francisco");
Assertions.assertThat(city.getState()).isEqualTo("CA");
Assertions.assertThat(city.getCountry()).isEqualTo("US");
}
@Test
public void /*insertCity*/ test2() throws Exception {
City city = new City();
city.setId(2L);
city.setName("HangZhou");
city.setState("ZheJiang");
city.setCountry("CN");
int result = cityMapper.insert(city);
Assertions.assertThat(result).isEqualTo(1);
}
@Test
public void /*selectNewInsertedCity*/ test3() throws Exception {
City city = cityMapper.selectCityById(2);
Assertions.assertThat(city).isNull();
}
}
這裏使用了@MybatisTest
,它負責構建mybatis-mapper層的bean,就像上文中使用的@WebMvcTest
負責構建web層的bean一樣。值得一提的是@MybatisTest
來自於mybatis-spring-boot-starter-test
項目,它是mybatis團隊根據spring的習慣來實現的。Spring原生支持的兩種持久層的測試方案是@DataJpaTest
和@JdbcTest
,分別對應JPA持久化方案和JDBC持久化方案。
@FixMethodOrder
來自junit,目的是為了讓一個測試類中的多個測試方案按照設定的順序執行。一般情況下不需要如此,我這裏想確認test2方法中插入的數據,在test3中是否還存在,所以需要保證兩者的執行順序。
我們註入了CityMapper
,因為其沒有更底層的依賴,所以我們不需要進行mock。
@MybatisTest
除了實例化mapper相關的bean之外,還會檢測依賴中的內嵌數據庫,然後測試的時候使用內嵌數據庫。如果依賴中沒有內嵌數據庫,就會失敗。當然,使用內嵌數據庫是默認的行為,可以使用配置進行修改。
@MybatisTest
還會確保每一個測試方法都是事務回滾的,所以在上述的測試用例中,test2插入了數據之後,test3中依然獲取不到插入的數據。當然,這也是默認的行為,可以改變。
測試任意的bean
service層並不作為一種特殊的層,所以沒有什麽註解能表示“只構建service層的bean”這種概念。
這裏將介紹另一種通用的測試場景,我要測試的是一個普通的bean,沒有什麽特殊的角色,比如不是擔當特殊處理的controller,也不是負責持久化的dao組件,我們要測試的只是一個普通的bean。
上文中我們使用@SpringBootTest
的默認機制,它去查找@SpringBootApplication
的配置,據此構建Spring的上下文。查看@SpringBootTest
的doc,其中有一句是:
Automatically searches for a @SpringBootConfiguration when nested @Configuration is not used, and no explicit classes are specified.
這表示我們可以通過classes屬性來指定Configuration類,或者定義內嵌的Configuration類來改變默認的配置。
在這裏我們通過內嵌的Configuration類來實現,先看下測試目標 - CityService:
@Service
public class CityService {
@Autowired
private CityMapper cityMapper;
public List<City> getAllCities() {
return cityMapper.selectAllCities();
}
}
測試方案:
@RunWith(SpringRunner.class)
@SpringBootTest
public class CityServiceTest {
@Configuration
static class CityServiceConfig {
@Bean
public CityService cityService() {
return new CityService();
}
}
@Autowired
private CityService cityService;
@MockBean
private CityMapper cityMapper;
@Test
public void getAllCities() {
City city = new City();
city.setId(1L);
city.setName("杭州");
city.setState("浙江");
city.setCountry("CN");
Mockito.when(cityMapper.selectAllCities())
.thenReturn(Collections.singletonList(city));
List<City> result = cityService.getAllCities();
Assertions.assertThat(result.size()).isEqualTo(1);
Assertions.assertThat(result.get(0).getName()).isEqualTo("杭州");
}
}
同樣的,對於測試目標的依賴,我們需要進行mock。
Mock操作
單元測試中,需要對測試目標的依賴進行mock,這裏有必要對mock的細節介紹下。上文單元測試部分已對Mock的邏輯、流程和使用場景進行了介紹,此處專註於實踐層面進行說明。
根據方法參數設定預期行為
一般的mock是對方法級別的mock,在方法有入參的情況下,方法的行為可能會跟方法的具體參數值有關。比如一個除法的方法,傳入參數4、2得結果2,傳入參數8、2得結果4,傳入參數2、0得異常。
mock可以針對不同的參數值設定不同的預期,如下所示:
@RunWith(SpringRunner.class)
@SpringBootTest
public class MathServiceTest {
@Configuration
static class ConfigTest {}
@MockBean
private MathService mathService;
@Test
public void testDivide() {
Mockito.when(mathService.divide(4, 2))
.thenReturn(2);
Mockito.when(mathService.divide(8, 2))
.thenReturn(4);
Mockito.when(mathService.divide(ArgumentMatchers.anyInt(), ArgumentMatchers.eq(0))) // 必須同時用matchers語法
.thenThrow(new RuntimeException("error"));
Assertions.assertThat(mathService.divide(4, 2))
.isEqualTo(2);
Assertions.assertThat(mathService.divide(8, 2))
.isEqualTo(4);
Assertions.assertThatExceptionOfType(RuntimeException.class)
.isThrownBy(() -> {
mathService.divide(3, 0);
})
.withMessageContaining("error");
}
}
上面的測試可能有些奇怪,mock的對象也同時作為測試的目標。這是因為我們的目的在於介紹mock,所以簡化了測試流程。
從上述測試用例可以看出,我們除了可以指定具體參數時的行為,也可以指定參數滿足一定匹配規則時的行為。
有返回的方法
對於有返回的方法,mock時可以設定的行為有:
返回設定的結果,如:
when(taskService.findResourcePool(any()))
.thenReturn(resourcePool);
直接拋出異常,如:
when(taskService.createTask(any(), any(), any()))
.thenThrow(new RuntimeException("zz"));
實際調用真實的方法,如:
when(taskService.createTask(any(), any(), any()))
.thenCallRealMethod();
註意,調用真實的方法有違mock的本義,應該盡量避免。如果要調用的方法中調用了其他的依賴,需要自行註入其他的依賴,否則會空指針。
無返回的方法
對於無返回的方法,mock時可以設定的行為有:
直接拋出異常,如:
doThrow(new RuntimeException("test"))
.when(taskService).saveToDBAndSubmitToQueue(any());
實際調用(下列為Mockito類的doc中給出的示例,我並沒有遇到此需求),如:
doAnswer(new Answer() {
public Object answer(InvocationOnMock invocation) {
Object[] args = invocation.getArguments();
Mock mock = invocation.getMock();
return null;
}})
.when(mock).someMethod();
附錄
相關註解的匯總
@RunWith
:
junit的註解,通過這個註解使用SpringRunner.class
,能夠將junit和spring進行集成。後續的spring相關註解才會起效。@SpringBootTest
:
spring的註解,通過掃描應用程序中的配置來構建測試用的Spring上下文。@AutoConfigureMockMvc
:
spring的註解,能夠自動配置MockMvc
對象實例,用來在模擬測試環境中發送http請求。@WebMvcTest
:
spring的註解,切片測試的一種。使之替換@SpringBootTest
能將構建bean的範圍限定於web層,但是web層的下層依賴bean,需要通過mock來模擬。也可以通過參數指定只實例化web層的某一個到多個controller。具體可參考Auto-configured Spring MVC Tests。@RestClientTest
:
spring的註解,切片測試的一種。如果應用程序作為客戶端訪問其他Rest服務,可以通過這個註解來測試客戶端的功能。具體參考Auto-configured REST Clients。@MybatisTest
:
mybatis按照spring的習慣開發的註解,切片測試的一種。使之替換@SpringBootTest
,能夠將構建bean的返回限定於mybatis-mapper層。具體可參考mybatis-spring-boot-test-autoconfigure。@JdbcTest
:
spring的註解,切片測試的一種。如果應用程序中使用Jdbc作為持久層(spring的JdbcTemplate
),那麽可以使用該註解代替@SpringBootTest
,限定bean的構建範圍。官方參考資料有限,可自行網上查找資料。@DataJpaTest
:
spring的註解,切片測試的一種。如果使用Jpa作為持久層技術,可以使用這個註解,參考Auto-configured Data JPA Tests。@DataRedisTest
:
spring的註解,切片測試的一種。具體內容參考Auto-configured Data Redis Tests。
設置測試數據庫
給持久層測試類添加註解@AutoConfigureTestDatabase(replace = Replace.NONE)
可以使用配置的數據庫作為測試數據庫。同時,需要在配置文件中配置數據源,如下:
spring:
datasource:
url: jdbc:mysql://127.0.0.1/test
username: root
password: root
driver-class-name: com.mysql.jdbc.Driver
事務不回滾
可以在測試方法上添加@Rollback(false)
來設置不回滾,也可以在測試類的級別上添加該註解,表示該類所有的測試方法都不會回滾。
參考
- Spring Boot Testing
- Spring Boot Test博客
- Mybatis Spring Boot Test官方資料
基於spring-boot的應用程序的單元測試方案