Ohhnews

分类导航

$ cd ..
Baeldung原文

测试Spring MVC HandlerInterceptor

#spring mvc#拦截器测试#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(
              "Delivery charge feature: rollout={}%, 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 注册了拦截器,因此在测试期间它处于激活状态。这意味着我们无需额外配置即可测试从拦截器到控制器的完整请求流程

首先验证当上线百分比为零时所有请求都使用 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"));
}

上线百分比为零时,拦截器中的随机检查始终为 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 上获取。