概述
本文主要介紹如何對(duì)基于spring-boot的web應(yīng)用編寫單元測(cè)試、集成測(cè)試的代碼。
此類應(yīng)用的架構(gòu)圖一般如下所示:

我們項(xiàng)目的程序,對(duì)應(yīng)到上圖中的web應(yīng)用部分。這部分一般分為Controller層、service層、持久層。除此之外,應(yīng)用程序中還有一些數(shù)據(jù)封裝類,我們稱之為domain。上述各組件的職責(zé)如下:
- Controller層/Rest接口層: 負(fù)責(zé)對(duì)外提供Rest服務(wù),接收Rest請(qǐng)求,返回處理結(jié)果。
- service層: 業(yè)務(wù)邏輯層,根據(jù)Controller層的需要,實(shí)現(xiàn)具體的邏輯。
- 持久層: 訪問數(shù)據(jù)庫(kù),進(jìn)行數(shù)據(jù)的讀寫。向上支撐service層的數(shù)據(jù)庫(kù)訪問需求。
在Spring環(huán)境中,我們通常會(huì)把這三層注冊(cè)到Spring容器,上圖中使用淺藍(lán)色背景就是為了表示這一點(diǎn)。
在本文的后續(xù)內(nèi)容,我們將介紹如何對(duì)應(yīng)用進(jìn)行集成測(cè)試,包括啟動(dòng)web容器的請(qǐng)求測(cè)試、不啟動(dòng)web容器而使用模擬環(huán)境的測(cè)試;介紹如何對(duì)應(yīng)用進(jìn)行單元測(cè)試,包括單獨(dú)測(cè)試Controller層、service層、持久層。
集成測(cè)試和單元測(cè)試的區(qū)別是,集成測(cè)試通常只需要測(cè)試最上面一層,因?yàn)樯蠈訒?huì)自動(dòng)調(diào)用下層,所以會(huì)測(cè)試完整的流程鏈,流程鏈中每一個(gè)環(huán)節(jié)都是真實(shí)、具體的。單元測(cè)試是單獨(dú)測(cè)試流程鏈中的某一環(huán),這一個(gè)環(huán)所直接依賴的下游環(huán)節(jié)使用模擬的方式來提供支撐,這一技術(shù)稱為Mock。在介紹單元測(cè)試的時(shí)候,我們會(huì)介紹如何mock依賴對(duì)象,并簡(jiǎn)單對(duì)mock的原理進(jìn)行介紹。
本文所關(guān)注的另一個(gè)主題,是在持久層測(cè)試時(shí),如何消除修改數(shù)據(jù)庫(kù)的副作用。
集成測(cè)試
集成測(cè)試是在所有組件都已經(jīng)開發(fā)完成之后,進(jìn)行組裝測(cè)試。有兩種測(cè)試方式:?jiǎn)?dòng)web容器進(jìn)行測(cè)試,使用模擬環(huán)境測(cè)試。這兩種測(cè)試的效果沒有什么差別,只是使用模擬環(huán)境測(cè)試的話,可以不用啟動(dòng)web容器,從而會(huì)少一些開銷。另外,兩者的測(cè)試API會(huì)有所不同。
啟動(dòng)web容器進(jìn)行測(cè)試
我們通過測(cè)試最上層的Controller來實(shí)施集成測(cè)試,我們的測(cè)試目標(biāo)如下:
@RestController
public class CityController {
@Autowired
private CityService cityService;
@GetMapping("/cities")
public ResponseEntity<?> getAllCities() {
List<City> cities = cityService.getAllCities();
return ResponseEntity.ok(cities);
}
}
這是一個(gè)Controller,它對(duì)外提供一個(gè)服務(wù)/cities,返回一個(gè)包含所有城市的列表。這個(gè)Controller通過調(diào)用下一層的CityService來完成自己的職責(zé)。
針對(duì)這個(gè)Controller的集成測(cè)試方案如下:
@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的環(huán)境中進(jìn)行單元測(cè)試,這樣Spring的相關(guān)注解才會(huì)被識(shí)別并起效。然后我們使用@SpringBootTest,它會(huì)掃描應(yīng)用程序的spring配置,并構(gòu)建完整的Spring Context。我們?yōu)槠鋮?shù)webEnvironment賦值為SpringBootTest.WebEnvironment.RANDOM_PORT,這樣就會(huì)啟動(dòng)web容器,并監(jiān)聽一個(gè)隨機(jī)的端口,同時(shí),為我們自動(dòng)裝配一個(gè)TestRestTemplate類型的bean來輔助我們發(fā)送請(qǐng)求。
使用模擬環(huán)境測(cè)試
測(cè)試的目標(biāo)不變,測(cè)試的方案如下:
@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,但是沒有設(shè)置其webEnvironment屬性,這樣依然會(huì)構(gòu)建完整的Spring Context,但是不會(huì)再啟動(dòng)web容器。為了進(jìn)行測(cè)試,我們需要使用MockMvc實(shí)例發(fā)送請(qǐng)求,而我們使用@AutoConfigureMockMvc則是因?yàn)檫@樣可以獲得自動(dòng)配置的MockMvc實(shí)例。
具體測(cè)試的代碼中出現(xiàn)很多新的API,對(duì)于API細(xì)節(jié)的研究不在本文計(jì)劃范圍內(nèi)。
單元測(cè)試
上文中描述的兩種集成測(cè)試的方案,相同的一點(diǎn)是都會(huì)構(gòu)建整個(gè)Spring Context。這表示所有聲明的bean,而不管聲明的方式為何,都會(huì)被構(gòu)建實(shí)例,并且都能被依賴。這里隱含的意思是從上到下整條依賴鏈上的代碼都已實(shí)現(xiàn)。
Mock技術(shù)
在開發(fā)的過程中進(jìn)行測(cè)試,無法滿足上述的條件,Mock技術(shù)可以讓我們屏蔽掉下層的依賴,從而專注于當(dāng)前的測(cè)試目標(biāo)。Mock技術(shù)的思想是,當(dāng)測(cè)試目標(biāo)的下層依賴的行為是可預(yù)期的,那么測(cè)試目標(biāo)本身的行為也是可預(yù)期的,測(cè)試就是把實(shí)際的結(jié)果和測(cè)試目標(biāo)的預(yù)期結(jié)果做比較,而Mock就是預(yù)先設(shè)定下層依賴的行為表現(xiàn)。
Mock的流程
- 將測(cè)試目標(biāo)的依賴對(duì)象進(jìn)行mock,設(shè)定其預(yù)期的行為表現(xiàn)。
- 對(duì)測(cè)試目標(biāo)進(jìn)行測(cè)試。
- 檢測(cè)測(cè)試結(jié)果,檢查在依賴對(duì)象的預(yù)期行為下,測(cè)試目標(biāo)的結(jié)果是否符合預(yù)期。
Mock的使用場(chǎng)景
- 多人協(xié)作時(shí),可以通過mock進(jìn)行無等待的測(cè)試先行。
- 當(dāng)測(cè)試目標(biāo)的依賴對(duì)象需要訪問外部的服務(wù),而外部服務(wù)不易獲得時(shí),可以通過mock來模擬服務(wù)可用。
- 當(dāng)在排查不容易復(fù)現(xiàn)的問題場(chǎng)景時(shí),通過mock來模擬問題。
測(cè)試web層
測(cè)試的目標(biāo)不變,測(cè)試的方案如下:
/**
* 不構(gòu)建整個(gè)Spring Context,只構(gòu)建指定的Controller進(jìn)行測(cè)試。需要對(duì)相關(guān)的依賴進(jìn)行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("中國(guó)");
Mockito.when(service.getAllCities()).thenReturn(Collections.singletonList(city));
mvc.perform(MockMvcRequestBuilders.get("/cities"))
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.content().string(Matchers.containsString("杭州")));
}
}
這里不再使用@SpringBootTest,而代之以@WebMvcTest,這樣只會(huì)構(gòu)建web層或者指定的一到多個(gè)Controller的bean。@WebMvcTest同樣可以為我們自動(dòng)配置MockMvc類型的bean,我們可以使用它來模擬發(fā)送請(qǐng)求。
@MockBean是一個(gè)新接觸的注解,它表示對(duì)應(yīng)的bean是一個(gè)模擬的bean。因?yàn)槲覀円獪y(cè)試CityController,對(duì)其依賴的CityService,我們需要mock其預(yù)期的行為表現(xiàn)。在具體的測(cè)試方法中,使用Mockito的API對(duì)sercive的行為進(jìn)行mock,它表示當(dāng)調(diào)用service的getAllCities時(shí),會(huì)返回預(yù)先設(shè)定的一個(gè)City對(duì)象的列表。
之后就是發(fā)起請(qǐng)求,并預(yù)測(cè)結(jié)果。
Mockito是Java語(yǔ)言的mock測(cè)試框架,spring以自己的方式集成了它。
測(cè)試持久層
持久層的測(cè)試方案跟具體的持久層技術(shù)相關(guān)。這里我們介紹基于Mybatis的持久層的測(cè)試。
測(cè)試目標(biāo)是:
@Mapper
public interface CityMapper {
City selectCityById(int id);
List<City> selectAllCities();
int insert(City city);
}
測(cè)試方案是:
@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,它負(fù)責(zé)構(gòu)建mybatis-mapper層的bean,就像上文中使用的@WebMvcTest負(fù)責(zé)構(gòu)建web層的bean一樣。值得一提的是@MybatisTest來自于mybatis-spring-boot-starter-test項(xiàng)目,它是mybatis團(tuán)隊(duì)根據(jù)spring的習(xí)慣來實(shí)現(xiàn)的。Spring原生支持的兩種持久層的測(cè)試方案是@DataJpaTest和@JdbcTest,分別對(duì)應(yīng)JPA持久化方案和JDBC持久化方案。
@FixMethodOrder來自junit,目的是為了讓一個(gè)測(cè)試類中的多個(gè)測(cè)試方案按照設(shè)定的順序執(zhí)行。一般情況下不需要如此,我這里想確認(rèn)test2方法中插入的數(shù)據(jù),在test3中是否還存在,所以需要保證兩者的執(zhí)行順序。
我們注入了CityMapper,因?yàn)槠錄]有更底層的依賴,所以我們不需要進(jìn)行mock。
@MybatisTest除了實(shí)例化mapper相關(guān)的bean之外,還會(huì)檢測(cè)依賴中的內(nèi)嵌數(shù)據(jù)庫(kù),然后測(cè)試的時(shí)候使用內(nèi)嵌數(shù)據(jù)庫(kù)。如果依賴中沒有內(nèi)嵌數(shù)據(jù)庫(kù),就會(huì)失敗。當(dāng)然,使用內(nèi)嵌數(shù)據(jù)庫(kù)是默認(rèn)的行為,可以使用配置進(jìn)行修改。
@MybatisTest還會(huì)確保每一個(gè)測(cè)試方法都是事務(wù)回滾的,所以在上述的測(cè)試用例中,test2插入了數(shù)據(jù)之后,test3中依然獲取不到插入的數(shù)據(jù)。當(dāng)然,這也是默認(rèn)的行為,可以改變。
測(cè)試任意的bean
service層并不作為一種特殊的層,所以沒有什么注解能表示“只構(gòu)建service層的bean”這種概念。
這里將介紹另一種通用的測(cè)試場(chǎng)景,我要測(cè)試的是一個(gè)普通的bean,沒有什么特殊的角色,比如不是擔(dān)當(dāng)特殊處理的controller,也不是負(fù)責(zé)持久化的dao組件,我們要測(cè)試的只是一個(gè)普通的bean。
上文中我們使用@SpringBootTest的默認(rèn)機(jī)制,它去查找@SpringBootApplication的配置,據(jù)此構(gòu)建Spring的上下文。查看@SpringBootTest的doc,其中有一句是:
Automatically searches for a @SpringBootConfiguration when nested @Configuration is not used, and no explicit classes are specified.
這表示我們可以通過classes屬性來指定Configuration類,或者定義內(nèi)嵌的Configuration類來改變默認(rèn)的配置。
在這里我們通過內(nèi)嵌的Configuration類來實(shí)現(xiàn),先看下測(cè)試目標(biāo) - CityService:
@Service
public class CityService {
@Autowired
private CityMapper cityMapper;
public List<City> getAllCities() {
return cityMapper.selectAllCities();
}
}
測(cè)試方案:
@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("杭州");
}
}
同樣的,對(duì)于測(cè)試目標(biāo)的依賴,我們需要進(jìn)行mock。
Mock操作
單元測(cè)試中,需要對(duì)測(cè)試目標(biāo)的依賴進(jìn)行mock,這里有必要對(duì)mock的細(xì)節(jié)介紹下。上文單元測(cè)試部分已對(duì)Mock的邏輯、流程和使用場(chǎng)景進(jìn)行了介紹,此處專注于實(shí)踐層面進(jìn)行說明。
根據(jù)方法參數(shù)設(shè)定預(yù)期行為
一般的mock是對(duì)方法級(jí)別的mock,在方法有入?yún)⒌那闆r下,方法的行為可能會(huì)跟方法的具體參數(shù)值有關(guān)。比如一個(gè)除法的方法,傳入?yún)?shù)4、2得結(jié)果2,傳入?yún)?shù)8、2得結(jié)果4,傳入?yún)?shù)2、0得異常。
mock可以針對(duì)不同的參數(shù)值設(shè)定不同的預(yù)期,如下所示:
@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))) // 必須同時(shí)用matchers語(yǔ)法
.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");
}
}
上面的測(cè)試可能有些奇怪,mock的對(duì)象也同時(shí)作為測(cè)試的目標(biāo)。這是因?yàn)槲覀兊哪康脑谟诮榻Bmock,所以簡(jiǎn)化了測(cè)試流程。
從上述測(cè)試用例可以看出,我們除了可以指定具體參數(shù)時(shí)的行為,也可以指定參數(shù)滿足一定匹配規(guī)則時(shí)的行為。
有返回的方法
對(duì)于有返回的方法,mock時(shí)可以設(shè)定的行為有:
返回設(shè)定的結(jié)果,如:
when(taskService.findResourcePool(any()))
.thenReturn(resourcePool);
直接拋出異常,如:
when(taskService.createTask(any(), any(), any()))
.thenThrow(new RuntimeException("zz"));
實(shí)際調(diào)用真實(shí)的方法,如:
when(taskService.createTask(any(), any(), any()))
.thenCallRealMethod();
注意,調(diào)用真實(shí)的方法有違mock的本義,應(yīng)該盡量避免。如果要調(diào)用的方法中調(diào)用了其他的依賴,需要自行注入其他的依賴,否則會(huì)空指針。
無返回的方法
對(duì)于無返回的方法,mock時(shí)可以設(shè)定的行為有:
直接拋出異常,如:
doThrow(new RuntimeException("test"))
.when(taskService).saveToDBAndSubmitToQueue(any());
實(shí)際調(diào)用(下列為Mockito類的doc中給出的示例,我并沒有遇到此需求),如:
doAnswer(new Answer() {
public Object answer(InvocationOnMock invocation) {
Object[] args = invocation.getArguments();
Mock mock = invocation.getMock();
return null;
}})
.when(mock).someMethod();
附錄
相關(guān)注解的匯總

-
@RunWith:
junit的注解,通過這個(gè)注解使用SpringRunner.class,能夠?qū)unit和spring進(jìn)行集成。后續(xù)的spring相關(guān)注解才會(huì)起效。 -
@SpringBootTest:
spring的注解,通過掃描應(yīng)用程序中的配置來構(gòu)建測(cè)試用的Spring上下文。 -
@AutoConfigureMockMvc:
spring的注解,能夠自動(dòng)配置MockMvc對(duì)象實(shí)例,用來在模擬測(cè)試環(huán)境中發(fā)送http請(qǐng)求。 -
@WebMvcTest:
spring的注解,切片測(cè)試的一種。使之替換@SpringBootTest能將構(gòu)建bean的范圍限定于web層,但是web層的下層依賴bean,需要通過mock來模擬。也可以通過參數(shù)指定只實(shí)例化web層的某一個(gè)到多個(gè)controller。具體可參考Auto-configured Spring MVC Tests。 -
@RestClientTest:
spring的注解,切片測(cè)試的一種。如果應(yīng)用程序作為客戶端訪問其他Rest服務(wù),可以通過這個(gè)注解來測(cè)試客戶端的功能。具體參考Auto-configured REST Clients。 -
@MybatisTest:
mybatis按照spring的習(xí)慣開發(fā)的注解,切片測(cè)試的一種。使之替換@SpringBootTest,能夠?qū)?gòu)建bean的返回限定于mybatis-mapper層。具體可參考mybatis-spring-boot-test-autoconfigure。 -
@JdbcTest:
spring的注解,切片測(cè)試的一種。如果應(yīng)用程序中使用Jdbc作為持久層(spring的JdbcTemplate),那么可以使用該注解代替@SpringBootTest,限定bean的構(gòu)建范圍。官方參考資料有限,可自行網(wǎng)上查找資料。 -
@DataJpaTest:
spring的注解,切片測(cè)試的一種。如果使用Jpa作為持久層技術(shù),可以使用這個(gè)注解,參考Auto-configured Data JPA Tests。 -
@DataRedisTest:
spring的注解,切片測(cè)試的一種。具體內(nèi)容參考Auto-configured Data Redis Tests。
設(shè)置測(cè)試數(shù)據(jù)庫(kù)
給持久層測(cè)試類添加注解@AutoConfigureTestDatabase(replace = Replace.NONE)可以使用配置的數(shù)據(jù)庫(kù)作為測(cè)試數(shù)據(jù)庫(kù)。同時(shí),需要在配置文件中配置數(shù)據(jù)源,如下:
spring:
datasource:
url: jdbc:mysql://127.0.0.1/test
username: root
password: root
driver-class-name: com.mysql.jdbc.Driver
事務(wù)不回滾
可以在測(cè)試方法上添加@Rollback(false)來設(shè)置不回滾,也可以在測(cè)試類的級(jí)別上添加該注解,表示該類所有的測(cè)試方法都不會(huì)回滾。