javalambdajunitmockitocompletion-stage

How do I unit test whenCompleteAsync on a lambda with a http request?


I want to create a unit test for the following class:

@Service
public class XService{
    
    public String getSomething(String inputField) {
        final SomeEntity someEntity1 = new SomeEntity();
        final AtomicReference<Throwable> throwable = new AtomicReference<>();

        BiConsumer<Response, Throwable> consumer = (response, error) -> {
        if (error != null) {
            throwable.set(error);
        } else {
            SomeEntity someEntity2 = response.readEntity(SomeEntity.class);
            someEntity1.setSomeField(someEntity2.getSomeField());
            //does some stuff with the response
        }
        };
        
        WebTarget target = client.target("api_url"+inputField);
        target.queryParam("param", param)
            .request(MediaType.APPLICATION_JSON)
            .acceptLanguage(Locale.ENGLISH)
            .header("Authorization", token)
            .rx()
            .get()
            .whenCompleteAsync(consumer);

        return someEntity1.getSomeField();
    }
}

I have mocked everything until .whenCompleteAsync(consumer) using something like this:

when(mockWebTarget.queryParam(any(),any())).thenReturn(mockWebTarget);
CompletionStageRxInvoker completionStageRxInvoker = mock(CompletionStageRxInvoker.class);
when(mockBuilder.rx()).thenReturn(completionStageRxInvoker);
CompletionStage<Response> mockResp = mock(CompletionStage.class);
when(completionStageRxInvoker.get()).thenReturn(mockResp);

I cannot currently change the design of the class, only make tests for it.

How can I mock the consumer object to make the code run inside the lambda? Is this even possible?


Solution

  • The getSomething method has a race condition. It isn't possible to reliably test it, because it has non-deterministic behavior.

    The problem is that consumer is invoked asynchronously, after the request completes. Nothing in getSomething ensures that will happen before return someEntity1.getSomeField() occurs. This means that it might return the field that is copied from the read entity, or it might return the default value of that field. Most likely, it will return before consumer is invoked (since the request is relatively slow). Once the request completes, it will set the field in someEntity1, but by this point, getSomething has already returned the incorrect value to the caller, and the object referenced by someEntity1 won't be read again.

    The correct way to handle this is to make getSomething also return a CompletionStage:

    public CompletionStage<String> getSomething(String inputField) {
        WebTarget target = client.target("api_url"+inputField);
        return target.queryParam("param", param)
            .request(MediaType.APPLICATION_JSON)
            .acceptLanguage(Locale.ENGLISH)
            .header("Authorization", token)
            .rx()
            .get()
            .thenApply(response -> response.readEntity(SomeEntity.class).getSomeField());
    }
    

    Then, to unit test this, you can create mocks for WebTarget, Invocation.Builder, CompletionStageRxInvoker, and Response as you have. Rather than mocking CompletionStage, it will be simpler to have the mocked completionStageRxInvoker.get() method return CompletableFuture.completedFuture(mockResponse). Note that CompletableFuture is a concrete implementation of CompletionStage that is part of JavaSE.

    Even better, to reduce the proliferation of mocks, you can refactor this to separate out the request logic from the response-handling logic. Something like this:

    public CompletionStage<String> getSomething(String inputField) {
        return apiClient
                .get(inputField, param, token)
                .thenApply(SomeEntity::getSomeField);
    }
    

    Where apiClient is an injected instance of a custom class or interface that you can mock, with a method declared like this:

    public CompletionStage<SomeEntity> get(String inputField, Object param, String token) {
        WebTarget target = client.target("api_url"+inputField);
        return target.queryParam("param", param)
                .request(MediaType.APPLICATION_JSON)
                .acceptLanguage(Locale.ENGLISH)
                .header("Authorization", token)
                .rx()
                .get()
                .thenApply(response -> response.readEntity(SomeEntity.class));
    }