本文主要介紹如何對基於spring-boot的web應用編寫單元測試、集成測試的代碼。html
此類應用的架構圖通常以下所示:java
咱們項目的程序,對應到上圖中的web應用部分。這部分通常分爲Controller層、service層、持久層。除此以外,應用程序中還有一些數據封裝類,咱們稱之爲domain。上述各組件的職責以下:mysql
在Spring環境中,咱們一般會把這三層註冊到Spring容器,上圖中使用淺藍色背景就是爲了表示這一點。git
在本文的後續內容,咱們將介紹如何對應用進行集成測試,包括啓動web容器的請求測試、不啓動web容器而使用模擬環境的測試;介紹如何對應用進行單元測試,包括單獨測試Controller層、service層、持久層。github
集成測試和單元測試的區別是,集成測試一般只須要測試最上面一層,由於上層會自動調用下層,因此會測試完整的流程鏈,流程鏈中每個環節都是真實、具體的。單元測試是單獨測試流程鏈中的某一環,這一個環所直接依賴的下游環節使用模擬的方式來提供支撐,這一技術稱爲Mock。在介紹單元測試的時候,咱們會介紹如何mock依賴對象,並簡單對mock的原理進行介紹。web
本文所關注的另外一個主題,是在持久層測試時,如何消除修改數據庫的反作用。redis
集成測試是在全部組件都已經開發完成以後,進行組裝測試。有兩種測試方式:啓動web容器進行測試,使用模擬環境測試。這兩種測試的效果沒有什麼差異,只是使用模擬環境測試的話,能夠不用啓動web容器,從而會少一些開銷。另外,二者的測試API會有所不一樣。spring
咱們經過測試最上層的Controller來實施集成測試,咱們的測試目標以下:sql
@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的使用場景
測試的目標不變,測試的方案以下:
/** * 不構建整個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中依然獲取不到插入的數據。固然,這也是默認的行爲,能夠改變。
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,在方法有入參的狀況下,方法的行爲可能會跟方法的具體參數值有關。好比一個除法的方法,傳入參數四、2得結果2,傳入參數八、2得結果4,傳入參數二、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
:SpringRunner.class
,可以將junit和spring進行集成。後續的spring相關注解纔會起效。@SpringBootTest
:@AutoConfigureMockMvc
:MockMvc
對象實例,用來在模擬測試環境中發送http請求。@WebMvcTest
:@SpringBootTest
能將構建bean的範圍限定於web層,可是web層的下層依賴bean,須要經過mock來模擬。也能夠經過參數指定只實例化web層的某一個到多個controller。具體可參考Auto-configured Spring MVC Tests。@RestClientTest
:@MybatisTest
:@SpringBootTest
,可以將構建bean的返回限定於mybatis-mapper層。具體可參考mybatis-spring-boot-test-autoconfigure。@JdbcTest
:JdbcTemplate
),那麼可使用該註解代替@SpringBootTest
,限定bean的構建範圍。官方參考資料有限,可自行網上查找資料。@DataJpaTest
:@DataRedisTest
:給持久層測試類添加註解@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)
來設置不回滾,也能夠在測試類的級別上添加該註解,表示該類全部的測試方法都不會回滾。