Juan Bustamante
Juan Bustamante

Reputation: 139

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?

Upvotes: 2

Views: 2202

Answers (4)

cristian.andrei.stan
cristian.andrei.stan

Reputation: 523

For the record, this might be helpful for a noob like me in Reactor testing.

I struggled very much to figure it out how to test an ExchangeFilterFunction. Really it took me many hours and tried all the suggestions from StackOverflow, reading the complete documentation from the Project Reactor about testing and so on. Even tried using AI, but if failed absolutely spectacularly.

And just before I wanted to give up I stumbled about this gem. It is a unit test from the Spring Weblux source code and really opened my eyes.

ExchangeFilterFunctionsTests.java

I speak about this particular one:

    @Test
    void basicAuthenticationUsernamePassword() {
        ClientRequest request = ClientRequest.create(HttpMethod.GET, DEFAULT_URL).build();
        ClientResponse response = mock();

        ExchangeFunction exchange = r -> {
            assertThat(r.headers().containsHeader(HttpHeaders.AUTHORIZATION)).isTrue();
            assertThat(r.headers().getFirst(HttpHeaders.AUTHORIZATION)).startsWith("Basic ");
            return Mono.just(response);
        };

        ExchangeFilterFunction auth = ExchangeFilterFunctions.basicAuthentication("foo", "bar");
        assertThat(request.headers().containsHeader(HttpHeaders.AUTHORIZATION)).isFalse();
        ClientResponse result = auth.filter(request, exchange).block();
        assertThat(result).isEqualTo(response);
    }

Once I put this in my codebase and started playing around with it (I had to adapt it a little and make it compile, probably I have an older Spring version), I could debug and modify it and very soon I was able to test my own implementation of an ExchangeFilterFunction.

This is such a elegant way of testing in isolation just the mutation done on a request inside an ExchangeFilterFunction. I will give the full example. I need to test an ExchangeFilterFunction function which reads some headers from a supplier and adds them to the outgoing request.

  @Test
  void should_propagate_headers_from_supplier_to_outgoing_request() {
    ClientRequest request = ClientRequest.create(HttpMethod.GET, URI.create("https://example.com")).build();
    ClientResponse response = Mockito.mock();

    ExchangeFilterFunction exchangeFilterFunction = getPropagateHeadersExchangeFilter(
        () -> {
          HttpHeaders headers = new HttpHeaders();
          headers.putAll(getTestHeaders());
          return Mono.just(headers);
        });

    ExchangeFunction exchange = clientRequest -> {
      getTestHeaders().forEach((headerName, headerValues) -> {
        assertThat(clientRequest.headers()).containsEntry(headerName, headerValues);
      });
      return Mono.just(response);
    };

    ClientResponse result = exchangeFilterFunction.filter(request, exchange).block();
    assertThat(result).isEqualTo(response);
  }

Upvotes: 0

Juan Bustamante
Juan Bustamante

Reputation: 139

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

Upvotes: 1

Numichi
Numichi

Reputation: 1092

It is my core solution. It can focus only to ExchangeFilterFunction without other side effects.

import java.net.URI;
import java.util.function.Function;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.client.reactive.ClientHttpConnector;
import org.springframework.http.client.reactive.ClientHttpRequest;
import org.springframework.http.client.reactive.ClientHttpResponse;
import org.springframework.mock.http.client.reactive.MockClientHttpRequest;
import org.springframework.mock.http.client.reactive.MockClientHttpResponse;
import org.springframework.web.reactive.function.client.ClientRequest;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.ExchangeFunction;
import org.springframework.web.reactive.function.client.ExchangeFunctions;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

class ExchangeFilterFunctionTest {

    @Test
    void filterTest() {
        ClientHttpResponse response = new MockClientHttpResponse(HttpStatus.BAD_REQUEST);
        ClientRequest request = ClientRequest.create(HttpMethod.GET, URI.create("http://localhost/foo?bar=baz")).build();

        ExchangeFunction next = ExchangeFunctions.create(new MyConnector(response));
        Mono<ClientResponse> result = new Filter().filter(request, next);

        StepVerifier.create(result)
                .expectNextMatches(item -> item.rawStatusCode() == 400)
                .verifyComplete();
    }


    class Filter implements ExchangeFilterFunction {
        @Override
        public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
            return next.exchange(request);
        }
    }

    class MyConnector implements ClientHttpConnector {
        private final  ClientHttpResponse response;

        MyConnector(ClientHttpResponse response) {
            this.response = response;
        }

        @Override
        public Mono<ClientHttpResponse> connect(HttpMethod method, URI uri, Function<? super ClientHttpRequest, Mono<Void>> requestCallback) {
            return requestCallback.apply(new MockClientHttpRequest(method, uri))
                    .thenReturn(response);
        }
    }
}

Upvotes: 0

Feel free
Feel free

Reputation: 1069

You can use @RunWith(MockitoJUnitRunner.Silent.class). This Mockito JUnit Runner implementation ignores stubbing argument mismatches (MockitoJUnitRunner.StrictStubs) and does not detect unused stubbings.

But this is not a solution!

Always better to fix your test. Unnecessary stubbings detected - means that you used an unnecessary when() stub so you need to remove it or use lenient() which stops the exception.

Mockito.lenient().when(mockResponse.statusCode()).thenReturn(HttpStatusCode.valueOf(tooManyRequestsErrorCode));

By the way, your code seems strange. Maybe we need to refactor some? In your code flatMap accepts both Mono and Publisher types so we can easily return mock response instead of Mono.just(mockResponse). Removing nesting monos:

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

By simplifying this code, all should work.

Upvotes: 0

Related Questions