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;    ...}

四、注意事项

  1. 单元测试执行时间尽量少,在 CI/CD 流水线中不能占有太多时间。建议只测当前模块的功能,依赖尽量走 Mock 比如采用 Mockito。当然依赖服务处理是个难点,这是为什么实际场景中我们直接使用@SpringBootTest拉起整个 Spring容器的原因,代价是慢
  2. 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>
  1. @ActiveProfiles(“dev”)单测类中指定 spring.profiles.active,应用测试配置,避免对其他环境数据库造成污染,或者配置不确定问题
  2. 本文笔者使用的 Spring Boot版本为2.6.4,不同版本可能会有差异

@ActiveProfiles(“dev”)单测类中指定 spring.profiles.active,应用测试配置,避免对其他环境数据库造成污染,或者配置不确定问题

本文笔者使用的 Spring Boot版本为2.6.4,不同版本可能会有差异

1
2.6.4