Как издеваться над Spring WebFlux WebClient?

Мы написали небольшое приложение Spring Boot REST, которое выполняет запрос REST на другой конечной точке REST.

@RequestMapping("/api/v1")
@SpringBootApplication
@RestController
@Slf4j
public class Application
{
    @Autowired
    private WebClient webClient;

    @RequestMapping(value = "/zyx", method = POST)
    @ResponseBody
    XyzApiResponse zyx(@RequestBody XyzApiRequest request, @RequestHeader HttpHeaders headers)
    {
        webClient.post()
            .uri("/api/v1/someapi")
            .accept(MediaType.APPLICATION_JSON)
            .contentType(MediaType.APPLICATION_JSON)
            .body(BodyInserters.fromObject(request.getData()))
            .exchange()
            .subscribeOn(Schedulers.elastic())
            .flatMap(response ->
                    response.bodyToMono(XyzServiceResponse.class).map(r ->
                    {
                        if (r != null)
                        {
                            r.setStatus(response.statusCode().value());
                        }

                        if (!response.statusCode().is2xxSuccessful())
                        {
                            throw new ProcessResponseException(
                                    "Bad status response code " + response.statusCode() + "!");
                        }

                        return r;
                    }))
            .subscribe(body ->
            {
                // Do various things
            }, throwable ->
            {
                // This section handles request errors
            });

        return XyzApiResponse.OK;
    }
}

Мы новичок в Spring и не можем написать Unit Test для этого небольшого фрагмента кода.

Есть ли элегантный (реактивный) способ издеваться над самим webClient или запускать макетный сервер, который webClient может использовать в качестве конечной точки?

Ответ 1

Я думаю, что встроенная поддержка spring для этого все еще продолжается - https://jira.spring.io/browse/SPR-15286

Мне действительно нравится wiremock, чтобы (интеграция) проверять такие сценарии. Тем более, что вы проверяете всю сериализацию и десериализацию с этим. С помощью wiremock вы запускаете сервер, который обслуживает ваши запросы, используя предопределенные заглушки.

Ответ 2

С помощью следующего метода можно было смоделировать WebClient с помощью Mockito для таких вызовов:

webClient
.get()
.uri(url)
.header(headerName, headerValue)
.retrieve()
.bodyToMono(String.class);

или же

webClient
.get()
.uri(url)
.headers(hs -> hs.addAll(headers));
.retrieve()
.bodyToMono(String.class);

Макет метода:

private static WebClient getWebClientMock(final String resp) {
    final var mock = Mockito.mock(WebClient.class);
    final var uriSpecMock = Mockito.mock(WebClient.RequestHeadersUriSpec.class);
    final var headersSpecMock = Mockito.mock(WebClient.RequestHeadersSpec.class);
    final var responseSpecMock = Mockito.mock(WebClient.ResponseSpec.class);

    when(mock.get()).thenReturn(uriSpecMock);
    when(uriSpecMock.uri(ArgumentMatchers.<String>notNull())).thenReturn(headersSpecMock);
    when(headersSpecMock.header(notNull(), notNull())).thenReturn(headersSpecMock);
    when(headersSpecMock.headers(notNull())).thenReturn(headersSpecMock);
    when(headersSpecMock.retrieve()).thenReturn(responseSpecMock);
    when(responseSpecMock.bodyToMono(ArgumentMatchers.<Class<String>>notNull()))
            .thenReturn(Mono.just(resp));

    return mock;
}

Ответ 3

Вы можете использовать MockWebServer командой OkHttp. По сути, команда Spring использует его и для своих тестов (по крайней мере, как они сказали здесь). Вот пример использования кода из этого сообщения блога:

Давайте рассмотрим, что у нас есть следующий сервис

class ApiCaller {

    private WebClient webClient;

    ApiCaller(WebClient webClient) {
        this.webClient = webClient;
    }

    Mono<SimpleResponseDto> callApi() {
        return webClient.put()
                .uri("/api/resource")
                .contentType(MediaType.APPLICATION_JSON)
                .header("Authorization", "customAuth")
                .syncBody(new SimpleRequestDto())
                .retrieve()
                .bodyToMono(SimpleResponseDto.class);
    }
}

тогда тест может быть разработан таким красноречивым способом:

class ApiCallerTest {

    private final MockWebServer mockWebServer = new MockWebServer();
    private final ApiCaller apiCaller = new ApiCaller(WebClient.create(mockWebServer.url("/").toString()));

    @AfterEach
    void tearDown() throws IOException {
        mockWebServer.shutdown();
    }

    @Test
    void call() throws InterruptedException {
        mockWebServer.enqueue(
                new MockResponse()
                        .setResponseCode(200)
                        .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                        .setBody("{\"y\": \"value for y\", \"z\": 789}")
        );
        SimpleResponseDto response = apiCaller.callApi().block();
        assertThat(response, is(not(nullValue())));
        assertThat(response.getY(), is("value for y"));
        assertThat(response.getZ(), is(789));

        RecordedRequest recordedRequest = mockWebServer.takeRequest();
        //use method provided by MockWebServer to assert the request header
        recordedRequest.getHeader("Authorization").equals("customAuth");
        DocumentContext context = JsonPath.parse(recordedRequest.getBody().inputStream());
        //use JsonPath library to assert the request body
        assertThat(context, isJson(allOf(
                withJsonPath("$.a", is("value1")),
                withJsonPath("$.b", is(123))
        )));
    }
}

Ответ 4

Мне удалось смоделировать метод get, однако сообщение, что я получаю исключение нулевого указателя при запуске получения. Любая идея?

Ответ 5

Проводные макеты подходят для интеграционных тестов, хотя я считаю, что они не нужны для модульных тестов. При выполнении модульных тестов мне будет просто интересно узнать, был ли вызван мой WebClient с нужными параметрами. Для этого вам нужен макет экземпляра WebClient. Или вы могли бы вместо этого ввести WebClientBuilder.

Давайте рассмотрим упрощенный метод, который выполняет запрос по почте, как показано ниже.

@Service
@Getter
@Setter
public class RestAdapter {

    public static final String BASE_URI = "http://some/uri";
    public static final String SUB_URI = "some/endpoint";

    @Autowired
    private WebClient.Builder webClientBuilder;

    private WebClient webClient;

    @PostConstruct
    protected void initialize() {
        webClient = webClientBuilder.baseUrl(BASE_URI).build();
    }

    public Mono<String> createSomething(String jsonDetails) {

        return webClient.post()
                .uri(SUB_URI)
                .accept(MediaType.APPLICATION_JSON)
                .body(Mono.just(jsonDetails), String.class)
                .retrieve()
                .bodyToMono(String.class);
    }
}

Метод createSomething просто принимает String, предполагаемый как Json для простоты примера, выполняет запрос post на URI и возвращает тело выходного ответа, которое предполагается в виде String.

Метод можно протестировать модулем, как показано ниже, с помощью StepVerifier.

public class RestAdapterTest {
    private static final String JSON_INPUT = "{\"name\": \"Test name\"}";
    private static final String TEST_ID = "Test Id";

    private WebClient.Builder webClientBuilder = mock(WebClient.Builder.class);
    private WebClient webClient = mock(WebClient.class);

    private RestAdapter adapter = new RestAdapter();
    private WebClient.RequestBodyUriSpec requestBodyUriSpec = mock(WebClient.RequestBodyUriSpec.class);
    private WebClient.RequestBodySpec requestBodySpec = mock(WebClient.RequestBodySpec.class);
    private WebClient.RequestHeadersSpec requestHeadersSpec = mock(WebClient.RequestHeadersSpec.class);
    private WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class);

    @BeforeEach
    void setup() {
        adapter.setWebClientBuilder(webClientBuilder);
        when(webClientBuilder.baseUrl(anyString())).thenReturn(webClientBuilder);
        when(webClientBuilder.build()).thenReturn(webClient);
        adapter.initialize();
    }

    @Test
    @SuppressWarnings("unchecked")
    void createSomething_withSuccessfulDownstreamResponse_shouldReturnCreatedObjectId() {
        when(webClient.post()).thenReturn(requestBodyUriSpec);
        when(requestBodyUriSpec.uri(RestAdapter.SUB_URI))
                .thenReturn(requestBodySpec);
        when(requestBodySpec.accept(MediaType.APPLICATION_JSON)).thenReturn(requestBodySpec);
        when(requestBodySpec.body(any(Mono.class), eq(String.class)))
                .thenReturn(requestHeadersSpec);
        when(requestHeadersSpec.retrieve()).thenReturn(responseSpec);
        when(responseSpec.bodyToMono(String.class)).thenReturn(Mono.just(TEST_ID));


        ArgumentCaptor<Mono<String>> captor
                = ArgumentCaptor.forClass(Mono.class);

        Mono<String> result = adapter.createSomething(JSON_INPUT);

        verify(requestBodySpec).body(captor.capture(), eq(String.class));
        Mono<String> testBody = captor.getValue();
        assertThat(testBody.block(), equalTo(JSON_INPUT));
        StepVerifier
                .create(result)
                .expectNext(TEST_ID)
                .verifyComplete();
    }
}

Обратите внимание, что операторы when проверяют все параметры, кроме тела запроса. Даже если один из параметров не совпадает, модульное тестирование завершается неудачно, подтверждая все это. Затем тело запроса утверждается в отдельной проверке и утверждении, поскольку "Mono" не может быть приравнено. Затем результат проверяется с помощью пошагового верификатора.

И затем, мы можем выполнить интеграционный тест с проводным макетом, как упомянуто в других ответах, чтобы увидеть, правильно ли подключается этот класс, и вызывает ли конечную точку нужное тело и т.д.