這可能是解決你Spring MVC接口漏洞百出的關鍵
1. 前言
在 Java 開發(fā)中接觸的開發(fā)者大多數(shù)不太注重對接口的測試,結(jié)果在聯(lián)調(diào)對接中出現(xiàn)各種問題。也有的使用 Postman 等工具進行測試,雖然在使用上沒有什么問題,如果接口增加了權(quán)限測試起來就比較惡心了。所以建議在單元測試中測試接口,保證在交付前先自測接口的健壯性。今天就來分享一下胖哥在開發(fā)中是如何對 Spring MVC 接口進行測試的。
在開始前請務必確認添加了Spring Boot Test相關的組件,在最新的版本中應該包含以下依賴:
- <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>
本文是在Spring Boot 2.3.4.RELEASE下進行的。
2. 單獨測試控制層
如果我們只需要對控制層接口(Controller)進行測試,且該接口不依賴@Service、@Component等注解聲明的 Spring Bean 時,可以借助@WebMvcTest來啟用只針對 Web 控制層的測試,例如
- @WebMvcTest
- class CustomSpringInjectApplicationTests {
- @Autowired
- MockMvc mockMvc;
- @SneakyThrows
- @Test
- void contextLoads() {
- mockMvc.perform(MockMvcRequestBuilders.get("/foo/map"))
- .andExpect(ResultMatcher.matchAll(status().isOk(),
- content().contentType(MediaType.APPLICATION_JSON),
- jsonPath("$.test", Is.is("hello"))))
- .andDo(MockMvcResultHandlers.print());
- }
- }
這種方式要快的多,它只加載了應用程序的一小部分。但是如果你涉及到服務層這種方式是不湊效的,我們就需要整體測試了方了。
3. 整體測試
大多數(shù) Spring Boot 下的接口測試是整體而又全面的測試,涉及到控制層、服務層、持久層等方方面面,所以需要加載比較完整的 Spring Boot 上下文。這時我們可以這樣做,聲明一個抽象的測試基類:
- package cn.felord.custom;
- import org.springframework.beans.factory.annotation.Autowired;
- import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
- import org.springframework.boot.test.context.SpringBootTest;
- import org.springframework.test.web.servlet.MockMvc;
- /**
- * 測試基類,
- * @author felord.cn
- */
- @SpringBootTest
- @AutoConfigureMockMvc
- abstract class CustomSpringInjectApplicationTests {
- /**
- * The Mock mvc.
- */
- @Autowired
- MockMvc mockMvc;
- // 其它公共依賴和處理方法
- }
只有當@AutoConfigureMockMvc存在時MockMvc才會被注入 Spring IoC。
然后針對具體的控制層進行如下測試代碼的編寫:
- package cn.felord.custom;
- import lombok.SneakyThrows;
- import org.hamcrest.core.Is;
- import org.junit.jupiter.api.Test;
- import org.springframework.http.MediaType;
- import org.springframework.test.web.servlet.ResultMatcher;
- import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
- import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
- import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
- /**
- * 測試FooController.
- *
- * @author felord.cn
- */
- public class FooTests extends CustomSpringInjectApplicationTests {
- /**
- * /foo/map接口測試.
- */
- @SneakyThrows
- @Test
- void contextLoads() {
- mockMvc.perform(MockMvcRequestBuilders.get("/foo/map"))
- .andExpect(ResultMatcher.matchAll(status().isOk(),
- content().contentType(MediaType.APPLICATION_JSON),
- jsonPath("$.test", Is.is("bar"))))
- .andDo(MockMvcResultHandlers.print());
- }
- }
4. MockMvc 測試
集成測試時,希望能夠通過輸入 URL 對 Controller 進行測試,如果通過啟動服務器,建立 http client 進行測試,這樣會使得測試變得很麻煩,比如,啟動速度慢,測試驗證不方便,依賴網(wǎng)絡環(huán)境等,為了可以對 Controller 進行測試,所以引入了MockMvc。
MockMvc實現(xiàn)了對 Http 請求的模擬,能夠直接使用網(wǎng)絡的形式,轉(zhuǎn)換到 Controller 的調(diào)用,這樣可以使得測試速度快、不依賴網(wǎng)絡環(huán)境,而且提供了一套驗證的工具,這樣可以使得請求的驗證統(tǒng)一而且很方便。接下來我們來一步步構(gòu)造一個測試的模擬請求,假設我們存在一個下面這樣的接口:
- @RestController
- @RequestMapping("/foo")
- public class FooController {
- @Autowired
- private MyBean myBean;
- @GetMapping("/user")
- public Map<String, String> bar(@RequestHeader("Api-Version") String apiVersion, User user) {
- Map<String, String> map = new HashMap<>();
- map.put("test", myBean.bar());
- map.put("version", apiVersion);
- map.put("username", user.getName());
- //todo your business
- return map;
- }
- }
參數(shù)設定為name=felord.cn&age=18,那么對應的 HTTP 報文是這樣的:
- GET /foo/user?name=felord.cn&age=18 HTTP/1.1
- Host: localhost:8888
- Api-Version: v1
可以預見的返回值為:
- {
- "test": "bar",
- "version": "v1",
- "username": "felord.cn"
- }
事實上對接口的測試可以分為以下幾步。
構(gòu)建請求
構(gòu)建請求由MockMvcRequestBuilders負責,他提供了請求方法(Method),請求頭(Header),請求體(Body),參數(shù)(Parameters),會話(Session)等所有請求的屬性構(gòu)建。/foo/user接口的請求可以轉(zhuǎn)換為:
- MockMvcRequestBuilders.get("/foo/user")
- .param("name", "felord.cn")
- .param("age", "18")
- .header("Api-Version", "v1")
執(zhí)行 Mock 請求
然后由MockMvc執(zhí)行 Mock 請求:
- mockMvc.perform(MockMvcRequestBuilders.get("/foo/user")
- .param("name", "felord.cn")
- .param("age", "18")
- .header("Api-Version", "v1"))
對結(jié)果進行處理
請求結(jié)果被封裝到ResultActions對象中,它封裝了多種讓我們對 Mock 請求結(jié)果進行處理的方法。
對結(jié)果進行預期期望
ResultActions#andExpect(ResultMatcher matcher)方法負責對響應的結(jié)果的進行預期期望,看看是否符合測試的期望值。參數(shù)ResultMatcher負責從響應對象中提取我們需要期望的部位進行預期比對。
假如我們期望接口/foo/user返回的是JSON,并且 HTTP 狀態(tài)為200,同時響應體包含了version=v1的值,我們應該這么聲明:
- ResultMatcher.matchAll(MockMvcResultMatchers.status().isOk(),
- MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON),
- MockMvcResultMatchers.jsonPath("$.version", Is.is("v1")));
JsonPath是一個強大的 JSON 解析類庫,請通過其項目倉庫https://github.com/json-path/JsonPath了解。
對響應進行處理
ResultActions#andDo(ResultHandler handler)方法負責對整個請求/響應進行打印或者 log 輸出、流輸出,由MockMvcResultHandlers工具類提供這些方法。我們可以通過以上三種途徑來查看請求響應的細節(jié)。
例如/foo/user接口:
- MockHttpServletRequest:
- HTTP Method = GET
- Request URI = /foo/user
- Parameters = {name=[felord.cn], age=[18]}
- Headers = [Api-Version:"v1"]
- Body = null
- Session Attrs = {}
- Handler:
- Type = cn.felord.xbean.config.FooController
- Method = cn.felord.xbean.config.FooController#urlEncode(String, Params)
- Async:
- Async started = false
- Async result = null
- Resolved Exception:
- Type = null
- ModelAndView:
- View name = null
- View = null
- Model = null
- FlashMap:
- Attributes = null
- MockHttpServletResponse:
- Status = 200
- Error message = null
- Headers = [Content-Type:"application/json"]
- Content type = application/json
- Body = {"test":"bar","version":"v1","username":"felord.cn"}
- Forwarded URL = null
- Redirected URL = null
- Cookies = []
獲取返回結(jié)果
如果你希望進一步處理響應的結(jié)果,也可以通過ResultActions#andReturn()拿到MvcResult類型的結(jié)果進行進一步的處理。
完整的測試過程
通常andExpect是我們必然會選擇的,而andDo和andReturn在某些場景下會有用,它們兩個是可選的。我們把上面的連在一起。
- @Autowired
- MockMvc mockMvc;
- @SneakyThrows
- @Test
- void contextLoads() {
- mockMvc.perform(MockMvcRequestBuilders.get("/foo/user")
- .param("name", "felord.cn")
- .param("age", "18")
- .header("Api-Version", "v1"))
- .andExpect(ResultMatcher.matchAll(status().isOk(),
- content().contentType(MediaType.APPLICATION_JSON),
- jsonPath("$.version", Is.is("v1"))))
- .andDo(MockMvcResultHandlers.print());
- }
這種流式的接口單元測試從語義上看也是比較好理解的,你可以使用各種斷言、正例、反例測試你的接口,最終讓你的接口更加健壯。
5. 總結(jié)
一旦你熟練了這種方式,你編寫的接口將更加具有權(quán)威性而不會再漏洞百出,甚至有時候你也可以使用 Mock 來設計接口,使之更加貼合業(yè)務。所以 CRUD 不是完全沒有技術(shù)含量,高質(zhì)量高效率的 CRUD 往往需要這種工程化的單元測試來支撐。
本文轉(zhuǎn)載自微信公眾號「碼農(nóng)小胖哥」,可以通過以下二維碼關注。轉(zhuǎn)載本文請聯(lián)系碼農(nóng)小胖哥公眾號。