Spring Boot Controller 层测试 一、问题背景 Spring Boot 框架下一般会分层测试 Controller、Service 和 Repository 层,单测和集成测试能够在上线前发现 SQL 语句,环境配置等常见问题。
Controller层涉及 Restful 请求,Header 头,请求体,filter,interceptor,认证和授权。自测起来比 Service 层和 Repository 层麻烦一些。那么 Controller 层一般怎么测试呢?
二、Controller 层测试 2.1 万能的@SpringBootTest Service 层和 Repository 层测试简单的方法是通过@SpringBootTest。Controller 层当然也可以通过@SpringBootTest,但是因为涉及发起 Restful web 端点请求,还需要借助@AutoConfigureMockMvc注入MockMvc。类似下面:
1 @SpringBootTest@AutoConfigureMockMvcclass UserControllerTest { @Autowired private MockMvc mockMvc; @Test void getUserById() throws Exception { String requestBody = "{\"userName\": xxx}"; String strTime = System.currentTimeMillis() + ""; MvcResult mvcResult = mockMvc.perform(post("/api/user/id") .contentType("application/json") // 设置内容类型为JSON .header("X-Request-Timestamp", strTime) .header("X-Request-Signature", SignUtil.generateSignature("POST", "/api/user/id", strTime, "xxx")) .content(requestBody)) // 设置请求体 .andExpect(status().isOk()) // 验证状态码为200 .andReturn(); //.andExpect(content().string("Received: " + requestBody)); // 验证响应体 System.out.println("Response Content: " + mvcResult.getResponse().getContentAsString()); } ...}
1 @SpringBootTest@AutoConfigureMockMvcclass UserControllerTest { @Autowired private MockMvc mockMvc; @Test void getUserById() throws Exception { String requestBody = "{\"userName\": xxx}"; String strTime = System.currentTimeMillis() + ""; MvcResult mvcResult = mockMvc.perform(post("/api/user/id") .contentType("application/json") // 设置内容类型为JSON .header("X-Request-Timestamp", strTime) .header("X-Request-Signature", SignUtil.generateSignature("POST", "/api/user/id", strTime, "xxx")) .content(requestBody)) // 设置请求体 .andExpect(status().isOk()) // 验证状态码为200 .andReturn(); //.andExpect(content().string("Received: " + requestBody)); // 验证响应体 System.out.println("Response Content: " + mvcResult.getResponse().getContentAsString()); } ...}
2.2 @WebMvcTest 1 @WebMvcTest(controllers = RealUserController.class)@ContextConfiguration(classes = {RealUserController.class}) // 仅加载 Controller 的配置@AutoConfigureMockMvc(addFilters = false)public class RealUserControllerTest { @Autowired private MockMvc mockMvc; // controller 依赖的其他 service bean @MockBean TrUserService userService; @Test void getUserRealByPhone() throws Exception { String requestBody = "{\"userPhone\": \"xxx\"}"; String strTime = System.currentTimeMillis() + ""; MvcResult mvcResult = mockMvc.perform(post("/api/user/real/phone") .contentType("application/json") // 设置内容类型为JSON .header("X-Request-Time", strTime) .header("X-Request-Signature", SignUtil.generateSignature("POST", "/api/user/real/phone", strTime, "DFC54F0E-54AA-4D53-98BC-E6BAEBCB63C2")) .content(requestBody)) // 设置请求体 .andExpect(status().isOk()) // 验证状态码为200 .andReturn(); //.andExpect(content().string("Received: " + requestBody)); // 验证响应体 mvcResult.getResponse().setCharacterEncoding("UTF-8"); System.out.println("Response Content: " + mvcResult.getResponse().getContentAsString()); }
1 @WebMvcTest(controllers = RealUserController.class)@ContextConfiguration(classes = {RealUserController.class}) // 仅加载 Controller 的配置@AutoConfigureMockMvc(addFilters = false)public class RealUserControllerTest { @Autowired private MockMvc mockMvc; // controller 依赖的其他 service bean @MockBean TrUserService userService; @Test void getUserRealByPhone() throws Exception { String requestBody = "{\"userPhone\": \"xxx\"}"; String strTime = System.currentTimeMillis() + ""; MvcResult mvcResult = mockMvc.perform(post("/api/user/real/phone") .contentType("application/json") // 设置内容类型为JSON .header("X-Request-Time", strTime) .header("X-Request-Signature", SignUtil.generateSignature("POST", "/api/user/real/phone", strTime, "DFC54F0E-54AA-4D53-98BC-E6BAEBCB63C2")) .content(requestBody)) // 设置请求体 .andExpect(status().isOk()) // 验证状态码为200 .andReturn(); //.andExpect(content().string("Received: " + requestBody)); // 验证响应体 mvcResult.getResponse().setCharacterEncoding("UTF-8"); System.out.println("Response Content: " + mvcResult.getResponse().getContentAsString()); }
@WebMvcTest是 Controller 层推荐的测试方式,原因是轻量,速度快,只加载 Web 层组件比如 Controller,Filter,MVC 配置等。实践中使用这种方法遇到的问题比较多,举一个例子,生产环境一般启用 Spring Security 提供认证和授权,测试过程中如果也启用,安全相关配置和组件类让测试依赖管理变得复杂,测试用例不能执行。
一个比较坑的地方是因为@WebMvcTest只在 Spring Context 中加载Web 层组件,忽略 Service,Repository 层服务,那么 Controller 如果依赖了服务层和 JPA 的功能,就会出现找不到类的异常。
Mock Service层 一般 Service 层可以通过 @MockBean 方式来解决,隔离服务层逻辑。
禁止JPA层服务 Controller 层不要引用 JPA 层服务,不符合分层设计的理念。实践中可能在 Controller 层用到了JPA @Entity 类,一般应该转化为 Dto 类;Spring Boot Application 启用了@EnableJpaAuditing等,会触发JPA metamodel must not be empty!问题。 尝试过滤 JPA 相关自动配置没有成功。比较有效的方式是通过@ContextConfiguration(classes = {RealUserController.class})精确指定加载类,这样测试框架才不会去找JPA 和 Spring Security 相关的自动配置类了。
1 JPA metamodel must not be empty!
1 @ContextConfiguration(classes = {RealUserController.class})
1 // 这种方式禁止 JPA 自动配置没有生效@WebMvcTest(controllers = RealUserController.class, excludeFilters = { @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = {JpaRepositoriesAutoConfiguration.class, HibernateJpaAutoConfiguration.class})})
1 // 这种方式禁止 JPA 自动配置没有生效@WebMvcTest(controllers = RealUserController.class, excludeFilters = { @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = {JpaRepositoriesAutoConfiguration.class, HibernateJpaAutoConfiguration.class})})
禁止 Spring Security 组件 解决 Spring Security 相关依赖问题包括SecurityAutoConfiguration自动配置和SecurityFilterChain的加载。
禁止SecurityFilterChain SecurityFilterChain有两种方式可以禁止加载。如上面例子中@AutoConfigureMockMvc(addFilters = false);还有一种方式是在Controller 层测试用例中定义并 import TestConfiguration。个人喜欢用第一种方式,代码简洁一些。
1 @AutoConfigureMockMvc(addFilters = false)
1 @WebMvcTest(controllers = RealUserController.class)@Import(RealUserControllerTest.NoSecurityConfig.class)@ContextConfiguration(classes = {RealUserController.class}) // 仅加载 Controller 的配置public class RealUserControllerTest { ... @TestConfiguration static class NoSecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { // 禁用所有安全配置 http.authorizeRequests().anyRequest().permitAll() .and().csrf().disable(); return http.build(); } } }
1 @WebMvcTest(controllers = RealUserController.class)@Import(RealUserControllerTest.NoSecurityConfig.class)@ContextConfiguration(classes = {RealUserController.class}) // 仅加载 Controller 的配置public class RealUserControllerTest { ... @TestConfiguration static class NoSecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { // 禁用所有安全配置 http.authorizeRequests().anyRequest().permitAll() .and().csrf().disable(); return http.build(); } } }
禁止SecurityAutoConfiguration自动配置 实践中在测试类中加下面配置并不能生效。@EnableAutoConfiguration(exclude = {SecurityAutoConfiguration.class})
1 @EnableAutoConfiguration(exclude = {SecurityAutoConfiguration.class})
比较有效的方式还是类似 JPA,在测试类上使用@ContextConfiguration(classes = {RealUserController.class})显式指定配置加载的类,可以解决这里的问题。
1 @ContextConfiguration(classes = {RealUserController.class})
三、结论 Controller 层测试建议使用下面的注解。
1 @WebMvcTest(controllers = RealUserController.class)@ContextConfiguration(classes = {RealUserController.class})@AutoConfigureMockMvc(addFilters = false)public class RealUserControllerTest { @Autowired private MockMvc mockMvc; @MockBean UserService userService; ...}
1 @WebMvcTest(controllers = RealUserController.class)@ContextConfiguration(classes = {RealUserController.class})@AutoConfigureMockMvc(addFilters = false)public class RealUserControllerTest { @Autowired private MockMvc mockMvc; @MockBean UserService userService; ...}
如果依赖太多,@MockBean需要很多或者禁止自动配置方案失效,也可以使用下面通用方法。代价是慢。
1 @SpringBootTest@AutoConfigureMockMvcpublic class RealUserControllerTest { @Autowired private MockMvc mockMvc; ...}
1 @SpringBootTest@AutoConfigureMockMvcpublic class RealUserControllerTest { @Autowired private MockMvc mockMvc; ...}
四、注意事项
单元测试执行时间尽量少,在 CI/CD 流水线中不能占有太多时间。建议只测当前模块的功能,依赖尽量走 Mock 比如采用 Mockito。当然依赖服务处理是个难点,这是为什么实际场景中我们直接使用@SpringBootTest拉起整个 Spring容器的原因,代价是慢
pom文件中不要忘了引入 test starter
单元测试执行时间尽量少,在 CI/CD 流水线中不能占有太多时间。建议只测当前模块的功能,依赖尽量走 Mock 比如采用 Mockito。当然依赖服务处理是个难点,这是为什么实际场景中我们直接使用@SpringBootTest拉起整个 Spring容器的原因,代价是慢
pom文件中不要忘了引入 test starter
1 <!--Spring boot 测试--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>
1 <!--Spring boot 测试--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>
@ActiveProfiles(“dev”)单测类中指定 spring.profiles.active,应用测试配置,避免对其他环境数据库造成污染,或者配置不确定问题
本文笔者使用的 Spring Boot版本为2.6.4,不同版本可能会有差异
@ActiveProfiles(“dev”)单测类中指定 spring.profiles.active,应用测试配置,避免对其他环境数据库造成污染,或者配置不确定问题
本文笔者使用的 Spring Boot版本为2.6.4,不同版本可能会有差异