Search code examples
springspring-bootmockitospring-webfluxspring-webclient

How can I unit test an ExchangeFilterFunction?


I am trying to create a global webclient retry filter and trying to unit test it via Mockito. The filter is an implementation of an ExchangeFilterFunction. I am using the technique proposed here.

I am using Spring Boot version 3.0.6, and java temurin 17.

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());
    }

RetryStrategyFilterTest

    @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();
    }

When unit testing the code with a default retriable error code (429), I am expecting it to call retry, but I am getting the following stub error.

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: 

This would mean that the filter function of the Mono.just(clientResponse) isn't being called, and when I debug it, I notice that I am not able to step through it. One lead that I'm following is that I have this line in my test, when(mockNext.exchange(mockRequest)).thenReturn(Mono.just(mockResponse));, and this line in the filter function .flatMap(clientResponse -> Mono.just(clientResponse). Am I nesting Mono's here? Could that be the reason that it is not working?

Update

I've re-factored the filter function code to the following

@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());
    }

I've also updated the verification on my test to the following which includes the StepVerifier since the flatmap is async.

    @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();
    }

I am getting this error.

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)

When I debug, I still notice that it still isn't going into the flatmap lambda function for the retry filter. Anyone have any idea?


Solution

  • I figured out the issue. I wasn't assigning the mono back in the unit test like this Mono<ClientResponse> clientResponseMono = retryStrategyFilter.filter(mockRequest,mockNext);.

    Please see the working code below.

       @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);
            int tooManyRequestsErrorCode = HttpStatus.TOO_MANY_REQUESTS.value();
            when(mockNext.exchange(mockRequest)).thenReturn(Mono.just(mockResponse));
            when(mockResponse.statusCode()).thenReturn(HttpStatusCode.valueOf(tooManyRequestsErrorCode));
            //ACT
            Mono<ClientResponse> clientResponseMono = retryStrategyFilter.filter(mockRequest,mockNext);
            //ASSERT
            StepVerifier.create(clientResponseMono)
                    .verifyError();
        }