You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何对ExchangeFilterFunction实现的重试过滤器进行单元测试?

问题:WebClient全局重试过滤器单元测试异常排查

基于Spring Boot 3.0.6和Java Temurin 17实现WebClient全局重试过滤器(ExchangeFilterFunction实现类)时,单元测试阶段遇到两类问题:

  • 初始版本测试触发UnnecessaryStubbingException,提示存在不必要的存根,调试发现Mono.just(clientResponse)的filter方法未执行;
  • 重构过滤器代码后改用StepVerifier做异步验证,出现断言错误:预期onError信号,实际收到onNext,且flatMap逻辑未执行。

初始RetryStrategyFilter代码

@Override
public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
    return next.exchange(request)
            .flatMap(clientResponse -> Mono.just(clientResponse)
                    .filter(response -> {
                        final HttpStatusCode code = clientResponse.statusCode();
                        boolean isRetryable = Boolean.FALSE;
                        if (code.isError()) {
                            //check if its a retryable error
                            isRetryable = Arrays.stream(DEFAULT_RETRYABLE_ERROR_CODES).anyMatch(defaultCode -> defaultCode == code.value()) ||
                                            (retryFilterConfiguration.getRetryErrorCodes() != null &&
                                                retryFilterConfiguration.getRetryErrorCodes().stream().anyMatch(retryErrorCode -> retryErrorCode == code.value()));
                            LOGGER.warn("Request Failed.  Retrying -> url={}; status={}", request.url(), code.value());
                        }
                        return isRetryable;
                    }) // if no errors, filter it out
                    .flatMap(response -> clientResponse.createException()) // let's raise an exception if response was an error
                    .flatMap(Mono::error) // trigger onError signal
                    .thenReturn(clientResponse)
            )
            .retry(retryFilterConfiguration.getRetryCount());
}

初始测试代码

@DisplayName("Retry on Default Error Code")
@Test
public void defaultRetryableErrorCode(){
    //ARRANGE
    ClientRequest mockRequest = Mockito.mock(ClientRequest.class);
    ExchangeFunction mockNext = Mockito.mock(ExchangeFunction.class);
    ClientResponse mockResponse = Mockito.mock(ClientResponse.class, RETURNS_DEEP_STUBS);
    int tooManyRequestsErrorCode = HttpStatus.TOO_MANY_REQUESTS.value();
    when(mockNext.exchange(mockRequest)).thenReturn(Mono.just(mockResponse));
    when(mockResponse.statusCode()).thenReturn(HttpStatusCode.valueOf(tooManyRequestsErrorCode));
    //ACT
    retryStrategyFilter.filter(mockRequest,mockNext);
    //ASSERT
    verify(mockResponse,times(0)).createException();
    verify(retryFilterConfiguration,times(1)).getRetryCount();
}

初始错误信息

Unnecessary stubbings detected.
Clean & maintainable test code requires zero unnecessary code.
Following stubbings are unnecessary (click to navigate to relevant line of code):
  1. -> at org.mycomp.http.filter.RetryStrategyFilterTest.defaultRetryableErrorCode(RetryStrategyFilterTest.java:37)
Please remove unnecessary stubbings or use 'lenient' strictness. More info: javadoc for UnnecessaryStubbingException class.
org.mockito.exceptions.misusing.UnnecessaryStubbingException: 

重构后的RetryStrategyFilter代码

@Override
public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
    return next.exchange(request)
            .flatMap(clientResponse -> {
                        final HttpStatusCode code = clientResponse.statusCode();
                        boolean isRetryable = Boolean.FALSE;
                        if (code.isError()) {
                            //check if its a retryable error
                            isRetryable = Arrays.stream(DEFAULT_RETRYABLE_ERROR_CODES).anyMatch(defaultCode -> defaultCode == code.value()) ||
                                            (retryFilterConfiguration.getRetryErrorCodes() != null &&
                                                retryFilterConfiguration.getRetryErrorCodes().stream().anyMatch(retryErrorCode -> retryErrorCode == code.value()));
                            LOGGER.warn("Request Failed.  Retrying -> url={}; status={}", request.url(), code.value());
                        }
                        if (isRetryable){
                            return  clientResponse.createException()
                                    .flatMap(Mono::error);
                        }else{
                            return Mono.just(clientResponse);
                        }
                    }
            ).retry(retryFilterConfiguration.getRetryCount());
}

重构后的测试代码

@DisplayName("Retry on Default Error Code")
@Test
public void defaultRetryableErrorCode(){
    //ARRANGE
    ClientRequest mockRequest = Mockito.mock(ClientRequest.class);
    ExchangeFunction mockNext = Mockito.mock(ExchangeFunction.class);
    ClientResponse mockResponse = Mockito.mock(ClientResponse.class);
    Mono<ClientResponse> monoResponse = Mono.just(mockResponse);
    int tooManyRequestsErrorCode = HttpStatus.TOO_MANY_REQUESTS.value();
    when(mockNext.exchange(mockRequest)).thenReturn(monoResponse);
    when(mockResponse.statusCode()).thenReturn(HttpStatusCode.valueOf(tooManyRequestsErrorCode));
    //ACT
    retryStrategyFilter.filter(mockRequest,mockNext);
    //ASSERT
    StepVerifier.create(monoResponse)
            .verifyError();
}

重构后的错误信息

expectation "expectError()" failed (expected: onError(); actual: onNext(Mock for ClientResponse, hashCode: 836386144))
java.lang.AssertionError: expectation "expectError()" failed (expected: onError(); actual: onNext(Mock for ClientResponse, hashCode: 836386144))
    at reactor.test.MessageFormatter.assertionError(MessageFormatter.java:115)

问题解决思路

针对初始版本的UnnecessaryStubbingException

  1. Mono未订阅导致逻辑未执行:Reactor异步逻辑只有在订阅后才会触发执行,初始测试中调用filter()后未订阅返回的Mono,导致mockResponse.statusCode()的存根从未被调用,Mockito严格模式判定为不必要存根。
  2. 验证逻辑错误:初始代码中如果是可重试错误,filter会保留响应并执行flatMap(clientResponse.createException()),因此verify(mockResponse,times(0)).createException()的断言逻辑与实际代码行为矛盾。

针对重构版本的断言错误

  1. 验证对象错误:测试中StepVerifier验证的是原始的monoResponse,而非过滤器处理后返回的新Mono。filter()方法会生成经过flatMap和retry处理的新数据流,必须对这个返回值进行验证。
  2. 存根缺失:重构后的过滤器在可重试分支会调用clientResponse.createException(),但测试中未对该方法存根,导致执行时可能抛出未预期异常或分支走偏。
  3. 重试配置未生效:需确保retryFilterConfiguration.getRetryCount()返回大于0的数值,否则retry操作不会触发;同时确认DEFAULT_RETRYABLE_ERROR_CODES包含429状态码。

通用修复步骤

  1. 触发Mono订阅:所有Reactor异步逻辑必须通过StepVerifier或block()触发订阅,才能执行内部逻辑。
  2. 验证正确的数据流:StepVerifier.create()的入参必须是filter()方法返回的Mono,而非原始响应Mono。
  3. 补充必要存根:对clientResponse.createException()进行存根,例如when(mockResponse.createException()).thenReturn(Mono.error(new RuntimeException()))。
  4. 检查配置有效性:确认重试次数配置大于0,默认错误码列表包含目标状态码。
  5. 调整Mockito严格模式:若需保留暂时未调用的存根,可使用lenient()标记,例如lenient().when(mockResponse.statusCode()).thenReturn(...)。

内容的提问来源于stack exchange,提问作者Juan Bustamante

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.21 06:37:35