Ohhnews

分类导航

$ cd ..
Baeldung原文

Spring MVC HandlerInterceptor 测试方法

#spring mvc#handlerinterceptor#单元测试#功能开关#mockmvc

1. 概述

Spring MVC 中的 HandlerInterceptor 允许我们在控制器执行前后拦截请求。它适用于日志记录、认证、功能开关等横切关注点。

测试 Spring MVC HandlerInterceptor 可能比较棘手,因为它们被嵌入到请求生命周期中。本文将探讨如何使用 @WebMvcTestMockMvc 来测试拦截器,确保拦截器逻辑正确运行,而无需启动完整的应用程序。

2. HandlerInterceptor 接口

HandlerInterceptor 允许我们在控制器处理请求之前和之后执行逻辑。它运行在 Spring MVC 层内,能够访问处理程序的元数据。

Spring MVC 的 HandlerInterceptor 接口定义了三个生命周期方法:

  • preHandle() —— 在控制器方法执行前运行
  • postHandle() —— 在控制器完成执行后、视图生成前运行
  • afterCompletion() —— 在整个请求完成后运行,无论是否出现异常

3. 应用程序设置

假设我们有一个计算配送费用的订单处理应用程序。我们希望逐步推出新的定价算法,而无需重新部署应用程序。为此,我们配置一个分发百分比,决定多少请求使用新的计算方式(v2),其余请求回退到原有方式(v1)。我们将使用 HandlerInterceptor 在控制器执行前做出路由决策,使控制器不包含功能开关逻辑。

首先定义 FeatureFlagService 接口,它代表一个提供当前分发百分比的外部服务:

$ java
public interface FeatureFlagService {
    int rolloutPercentage();
}

实现细节在此不相关,因此在测试中我们会模拟该服务。

3.1 拦截器

接下来创建 DeliveryChargeInterceptor。它查询分发百分比,生成一个随机值,当随机值低于分发阈值时路由到 v2:

$ java
public class DeliveryChargeInterceptor implements HandlerInterceptor {
    static final String USE_V2_ATTRIBUTE = "useV2";
    private final FeatureFlagService featureFlagService;
    private final SecureRandom random = new SecureRandom();
    private final Logger logger = LoggerFactory.getLogger(
      DeliveryChargeInterceptor.class
    );
    public DeliveryChargeInterceptor(FeatureFlagService featureFlagService) {
        this.featureFlagService = featureFlagService;
    }
    @Override
    public boolean preHandle(
      HttpServletRequest request,
      HttpServletResponse response,
      Object handler) throws Exception {
        if (handler instanceof HandlerMethod) {
            int rollout = featureFlagService.rolloutPercentage();
            boolean useV2 = rollout > 0 && random.nextInt(100) < rollout;
            request.setAttribute(USE_V2_ATTRIBUTE, useV2);
            logger.info(
              "配送费用功能:分发={}%, useV2={}",
              rollout,
              useV2
            );
        }
        return true;
    }
}

handler instanceof HandlerMethod 检查确保我们的逻辑仅应用于控制器方法,跳过静态资源请求。另外需要注意的是,拦截器不执行业务逻辑,它仅仅做出路由决策并通过请求属性传递。

3.2 控制器

接下来创建读取该属性并委托给相应版本的控制器:

$ java
@RestController
public class DeliveryChargesController {
    private final DeliveryChargeService deliveryChargeService;
    public DeliveryChargesController(DeliveryChargeService deliveryChargeService) {
        this.deliveryChargeService = deliveryChargeService;
    }
    @PostMapping("/delivery-charges/calculate")
    public double calculate(
      @RequestParam String postcode,
      HttpServletRequest request) {
        Boolean useV2 = (Boolean) request.getAttribute(
          DeliveryChargeInterceptor.USE_V2_ATTRIBUTE
        );
        if (Boolean.TRUE.equals(useV2)) {
            return deliveryChargeService.calculateV2(postcode);
        }
        return deliveryChargeService.calculateV1(postcode);
    }
}

3.3 注册拦截器

最后,通过 WebMvcConfigurer 注册拦截器,并将其作用域限定在配送费用路径:

$ java
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    private final FeatureFlagService featureFlagService;
    public WebMvcConfig(FeatureFlagService featureFlagService) {
        this.featureFlagService = featureFlagService;
    }
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new DeliveryChargeInterceptor(featureFlagService))
          .addPathPatterns("/delivery-charges/**");
    }
}

addPathPatterns() 方法确保只有匹配该路径的请求才会触发拦截器,其他端点不受影响。

4. 测试 Spring MVC HandlerInterceptor

现在,我们使用 @WebMvcTest 验证拦截器行为。该注解仅加载 Web 层,无需启动完整应用上下文。它包含控制器、拦截器及其配置。我们使用 @MockBean 为服务接口提供模拟实现:

$ java
@WebMvcTest(controllers = DeliveryChargesController.class)
class DeliveryChargeInterceptorIntegrationTest {
    @Autowired
    private MockMvc mockMvc;
    @MockBean
    private DeliveryChargeService deliveryChargeService;
    @MockBean
    private FeatureFlagService featureFlagService;
}

值得注意的是,@WebMvcTest 会自动拾取应用上下文中所有的 WebMvcConfigurer Bean。因为我们的 WebMvcConfig 注册了拦截器,所以拦截器在测试中处于激活状态。这意味着我们能够测试从拦截器到控制器的完整请求流程,而无需额外配置。

首先验证当分发为 0 时所有请求都使用 v1:

$ java
@Test
void givenZeroRollout_whenCalculateDeliveryCharge_thenV1IsUsed() throws Exception {
    when(featureFlagService.rolloutPercentage()).thenReturn(0);
    when(deliveryChargeService.calculateV1("SW1A")).thenReturn(5.0);
    mockMvc.perform(post("/delivery-charges/calculate").param("postcode", "SW1A"))
      .andExpect(status().isOk())
      .andExpect(content().string("5.0"));
}

分发为 0 时,拦截器中的随机检查始终返回 false,因此控制器始终调用 calculateV1()

接下来验证当分发为 100% 时所有请求都使用 v2:

$ java
@Test
void givenFullRollout_whenCalculateDeliveryCharge_thenV2IsUsed() throws Exception {
    when(featureFlagService.rolloutPercentage()).thenReturn(100);
    when(deliveryChargeService.calculateV2("SW1A")).thenReturn(3.5);
    mockMvc.perform(post("/delivery-charges/calculate").param("postcode", "SW1A"))
      .andExpect(status().isOk())
      .andExpect(content().string("3.5"));
}

100% 分发时,随机值始终小于阈值,因此每个请求都路由到 calculateV2()

最后验证 50% 分发会产生两种版本的混合使用。我们发送 20 个请求,断言 v1 和 v2 都接收到流量:

$ java
@Test
void givenPartialRollout_whenCalculateDeliveryCharge_thenBothVersionsAreUsed() throws Exception {
    when(featureFlagService.rolloutPercentage()).thenReturn(50);
    when(deliveryChargeService.calculateV1("SW1A")).thenReturn(5.0);
    when(deliveryChargeService.calculateV2("SW1A")).thenReturn(3.5);
    int v1Count = 0;
    int v2Count = 0;
    for (int i = 0; i < 20; i++) {
        String response = mockMvc.perform(
            post("/delivery-charges/calculate")
              .param("postcode", "SW1A")
          )
          .andExpect(status().isOk())
          .andReturn()
          .getResponse()
          .getContentAsString();
        if ("5.0".equals(response)) {
            v1Count++;
        } else if ("3.5".equals(response)) {
            v2Count++;
        }
    }
    assertThat(v1Count).isGreaterThan(0);
    assertThat(v2Count).isGreaterThan(0);
}

该测试验证拦截器根据配置的百分比在两种版本之间分配流量。

5. 结论

本文探讨了如何使用 @WebMvcTestMockMvc 测试 Spring MVC HandlerInterceptor。这种方法很有用,因为 @WebMvcTest 仅加载 Web 层,使测试速度更快。它会自动拾取通过 WebMvcConfigurer 注册的拦截器,因此我们无需启动完整应用上下文即可验证完整的请求流程。

然而,@WebMvcTest 仅隔离测试 Web 层,服务或仓库的集成问题不会在此暴露。对于需要完整应用上下文的场景,可以使用 @SpringBootTestMockMvc。此外,对于不依赖 Spring 请求处理的简单拦截器逻辑,使用 MockHttpServletRequest 进行单元测试能提供更快的反馈。

与往常一样,本文的源代码可在 GitHub 上获取。