JavaRush /Курсы /Spring Test /@RestClientTest и

@RestClientTest и MockRestServiceServer

Spring Test
24 уровень , 1 лекция
Открыта

1. Роль RestClientModerationClient в тестах

Когда в приложении появляется внешний HTTP-вызов, рука так и тянется проверить всё одним большим интеграционным тестом: поднять @SpringBootTest, пройти публикационный flow, и пусть оно само как-нибудь сходится. Проблема в том, что если в таком тесте что-то сломалось, вы получаете вопрос на миллион: «Сломалась бизнес-логика? конфигурация? сериализация? безопасность? или внешний JSON внезапно изменился?». И вы потом ходите с этим вопросом по офису (или по своей кухне), как с котом, который на вас обиделся, но вы не знаете почему.

Граница уже отделила две ответственности: сервис принимает решение через порт, а HTTP живёт внутри адаптера. Теперь нужно проверить самого переводчика — RestClientModerationClient. Ниже это будет наш рабочий baseline для outbound-блока: тот же request/response DTO, тот же baseUrl и тот же MockRestServiceServer.

Поэтому у outbound-адаптера — своя зона ответственности и своя отдельная проверка. RestClientModerationClient должен доказать две вещи: что он формирует правильный HTTP-запрос (URL, метод, заголовки, тело), и что он умеет корректно разобрать внешний ответ в понятный нашему домену результат. Бизнес-решения уровня «при BLOCK переводим статью в REJECTED» живут и тестируются в сервисе вокруг порта, но корректность «HTTP → доменная модель» — это ответственность адаптера.

Чтобы тестировать адаптер без сети, нам нужен специальный режим: мы поднимаем минимальный Spring-контекст только для клиента и перехватываем его запросы локальным сервером-заглушкой. Это ровно то, что делает @RestClientTest в паре с MockRestServiceServer.

2. Возможности @RestClientTest

Если вы раньше жили в мире «есть @SpringBootTest, а остальное — компромиссы», то @RestClientTest сначала кажется странным. Но в реальности это как раз очень честная инженерная позиция: мы поднимаем ровно столько Spring, сколько нужно, чтобы проверить HTTP-адаптер, и не тащим в тест всё приложение целиком.

@RestClientTest — это test slice для клиентской стороны. Он нацелен на классы, которые ходят наружу по HTTP (в нашем случае — RestClientModerationClient). Внутри он включает автоконфигурацию, нужную для RestClient, Jackson и тестовых инструментов вокруг HTTP. И самое важное — он умеет поднять MockRestServiceServer, который перехватит запросы и вернёт вам заданный ответ, не обращаясь к реальной сети.

Чтобы было проще удерживать это в голове, давайте сравним три близких режима:

Режим теста Что проверяем Что поднимается Что НЕ поднимается (и это плюс)
Unit-тест адаптера (без Spring) Обычно мало что, потому что RestClient живёт в Spring-мире Ничего Никакой автоконфигурации, но и много ручной возни
@RestClientTest Внешний HTTP-контракт клиента + маппинг ответа Узкий контекст для RestClient + MockRestServiceServer Не тянем MVC, security, репозитории и всю вашу вселенную
@SpringBootTest Сквозной сценарий (wiring + поведение системы) Полный контекст приложения Дорого, медленно, сложнее диагностировать «что сломалось»

Важно честно проговорить ограничение: @RestClientTest не доказывает, что ваш сервисный слой правильно использует порт в бизнес-сценарии. Он доказывает, что конкретный HTTP-клиент правильно разговаривает с внешним контрактом и корректно переводит ответ в доменные типы. Для ContentHub это идеальный «серединный слой уверенности»: уже не чистый unit, но ещё и не тяжёлый full integration test.

3. Подмена HTTP через MockRestServiceServer

Слово “server” в названии MockRestServiceServer звучит так, будто мы сейчас будем поднимать какой-нибудь Tomcat, слушать порт и воевать с конфликтами. Хорошая новость: нет. Очень хорошая. Прямо «съел печеньку и понял, что жизнь снова имеет смысл».

MockRestServiceServer работает как перехватчик запросов, которые отправляет RestClient. Он не требует реального сетевого соединения. Вы заранее описываете ожидание: «ожидаю запрос на такой-то URL, таким-то методом, с таким-то телом», и задаёте ответ: «верни JSON вот такой». А потом вызываете ваш адаптер как обычно, и он “думает”, что сходил наружу.

Ментальная модель может быть такой:

sequenceDiagram
    participant A as RestClientModerationClient
    participant R as RestClient
    participant M as MockRestServiceServer

    A->>R: POST https://moderation.local/api/check
    R->>M: "перехват запроса внутри теста"
    M-->>R: 200 OK + JSON body
    R-->>A: ModerationResponse

То есть мы проверяем реальный код адаптера, но вместо реального внешнего moderation service у нас «тренажёр»: он отдаёт ровно те ответы, которые мы хотим протестировать. Это позволяет зафиксировать внешний контракт тестами так же жёстко, как DTO JSON-контракт мы фиксировали через @JsonTest, только теперь речь о контракте между сервисами (внутри одной JVM, без сети).

4. Базовые модели и RestClient-адаптер

Перед тем как писать тест, нужно, чтобы мы говорили об одном и том же коде. Ниже — тот самый рабочий baseline клиента: отдельный внешний DTO, RestClient.Builder и baseUrl в одном месте. Здесь нет «магии» и нет бизнес-ветвлений; только то, что нужно адаптеру: входной request, внешний response и доменное решение.

Начнём с порта и доменных типов:

import java.util.Objects;

// Порт (интерфейс) — точка входа в интеграцию для домена.
// Домену не важно, как именно устроен HTTP-вызов.
public interface ModerationClient {
    ModerationDecision moderate(ModerationRequest request);
}

// Доменный запрос: то, что мы хотим проверить во внешнем сервисе.
public record ModerationRequest(String title, String body) {
    public ModerationRequest {
        // Базовая защита от "пустых" вызовов: доменные данные не должны быть null.
        Objects.requireNonNull(title);
        Objects.requireNonNull(body);
    }
}

// Доменное решение: его будет использовать бизнес-логика (не HTTP-клиент).
public record ModerationDecision(ModerationResult result, String reason) {
}

// Доменный enum: именно его должен вернуть адаптер после разбора ответа.
public enum ModerationResult {
    OK, WARN, BLOCK
}

Дальше нам нужен внешний payload. Обычно удобно держать его как отдельный тип, чтобы не смешивать внешний JSON и внутренние доменные решения:

// DTO внешнего сервиса: структура соответствует JSON-контракту moderation service.
public record ModerationResponse(ModerationResult result, String reason) {

    // Явное преобразование "внешнего ответа" в "доменное решение".
    ModerationDecision toDecision() {
        return new ModerationDecision(result, reason);
    }
}

Теперь сам адаптер. В реальном проекте у вас будет больше нюансов (таймауты, обработка ошибок, логирование), но для лекции достаточно показать “суть”: сформировать POST-запрос, отправить ModerationRequest, прочитать ModerationResponse и вернуть ModerationDecision.

import org.springframework.http.MediaType;
import org.springframework.web.client.RestClient;

public final class RestClientModerationClient implements ModerationClient {

    private final RestClient restClient;

    public RestClientModerationClient(RestClient.Builder builder, String baseUrl) {
        // RestClient собирается через Builder, чтобы Spring мог подложить свою конфигурацию в test-slice.
        this.restClient = builder.baseUrl(baseUrl).build();
    }

    @Override
    public ModerationDecision moderate(ModerationRequest request) {
        // Важно: фиксируем endpoint и то, что отправляем JSON-тело.
        ModerationResponse response = restClient.post()
                .uri("/api/check") // путь относительно baseUrl
                .contentType(MediaType.APPLICATION_JSON)
                .body(request) // тело запроса: ModerationRequest будет сериализован Jackson'ом
                .retrieve()
                .body(ModerationResponse.class); // ожидаем конкретный внешний DTO

        // Адаптер отвечает за перевод "HTTP/JSON" -> "доменные типы".
        return response.toDecision();
    }
}

Обратите внимание: здесь адаптер принимает RestClient.Builder (чтобы Spring мог его нормально сконфигурировать в тестовом slice), и отдельно baseUrl. В полноценном ContentHub это чаще будет @ConfigurationProperties, но сейчас нам важно показать принцип, а не устроить конкурс аннотаций.

Дальше наша задача — проверить, что при заданном внешнем ответе "OK" адаптер возвращает ModerationDecision(OK, null), а при "BLOCK" возвращает решение с причиной. И параллельно мы хотим убедиться, что запрос ушёл на правильный URL и правильным методом.

5. Скелет @RestClientTest и свойства

У @RestClientTest есть приятная особенность: тест выглядит почти как обычный Spring Boot тест, но под капотом поднимается узкий контекст. Это означает, что запуск будет быстрым, а зависимости — контролируемыми. Для новичка это отличный компромисс: вы всё ещё в знакомом Spring-мире (@Autowired, свойства, автоконфигурация), но не тонете в полном приложении.

Начнём со скелета RestClientModerationClientTest. Здесь важно два внедрения: сам RestClientModerationClient и MockRestServiceServer. Клиент — наш объект тестирования, сервер — подмена внешней сети.

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.client.MockRestServiceServer;

@RestClientTest(RestClientModerationClient.class) // поднимаем только test-slice для HTTP-клиента
@TestPropertySource(properties = {
        // Подкладываем baseUrl в тестовый контекст, чтобы RestClient корректно собирал полный URL.
        "contenthub.moderation.base-url=https://moderation.local"
})
class RestClientModerationClientTest {

    @Autowired
    private RestClientModerationClient client; // объект тестирования (наш адаптер)

    @Autowired
    private MockRestServiceServer server; // перехватчик HTTP-запросов RestClient (без реальной сети)
}

Но мы передаём baseUrl в конструктор как String. Значит, нам нужно показать, откуда он берётся. Один из простых учебных способов — @Value("${contenthub.moderation.base-url}"). В production-коде вы, скорее всего, оформите это как properties-класс, но механика теста от этого не меняется.

Мини-конструктор с @Value:

import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.client.RestClient;

public RestClientModerationClient(
        RestClient.Builder builder,
        @Value("${contenthub.moderation.base-url}") String baseUrl // берём baseUrl из конфигурации
) {
    // Собираем RestClient с базовым адресом, чтобы в коде можно было использовать относительные uri("/api/check").
    this.restClient = builder.baseUrl(baseUrl).build();
}

Так тестовое свойство из @TestPropertySource будет действительно использовано. Это деталь, но важная: если вы забудете подложить base-url, адаптер начнёт строить URL из воздуха (а воздух — плохой источник конфигурации, он слишком легкомысленный).

6. Проверка контракта и маппинга

Happy path: OK

Теперь самое приятное: пишем тест, который читается как сценарий. Мы задаём ожидания для MockRestServiceServer, затем вызываем client.moderate(...), затем делаем AssertJ-проверки результата.

Пусть внешний сервис возвращает такой JSON:

{"result":"OK","reason":null}

Тест может выглядеть так:

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

import static org.springframework.http.HttpMethod.POST;
import static org.springframework.http.MediaType.APPLICATION_JSON;

import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;

import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;

class RestClientModerationClientTest {

    // ... @Autowired поля

    @Test
    void shouldMapOkResponse() {
        // Говорим мок-серверу: ожидай конкретный запрос (URL + метод)...
        server.expect(requestTo("https://moderation.local/api/check"))
                .andExpect(method(POST))
                // ...и верни заранее заданный JSON как будто это ответ реального сервиса.
                .andRespond(withSuccess("""
                        {"result":"OK","reason":null}
                        """, APPLICATION_JSON));

        // Вызываем адаптер как обычный код: он "думает", что ходит наружу.
        ModerationDecision decision = client.moderate(
                new ModerationRequest("Hello", "Some body")
        );

        // Проверяем доменный результат, а не "сырой" JSON.
        assertThat(decision.result()).isEqualTo(ModerationResult.OK);
        assertThat(decision.reason()).isNull();
    }
}

Здесь мы уже защищаем два слоя риска: мы фиксируем, что запрос пошёл именно на https://moderation.local/api/check, и что ответ корректно превратился в доменное решение. Для новичка это ключевая мысль: тест адаптера — это не «проверить, что вернулось OK», а «проверить, что адаптер правильно разговаривает по контракту».

Обратите внимание на """...""". Текстовые блоки Java — это отличный способ держать JSON прямо в тесте без бесконечных экранирований. Да, это выглядит как «мини-JSON-файл», только без файловой системы. В небольших примерах это уместно, а когда payload сложнее, лучше переходить к fixture-файлам (но это уже отдельная дисциплина, не будем сейчас устраивать “войну форматов”).

Контракт запроса: заголовки и тело

Иногда студенты пишут такой тест и довольны. А потом выясняется, что адаптер, например, случайно отправляет GET вместо POST (или вообще не отправляет тело, потому что «ой, забыл .body(request)»), и тест всё равно зелёный. Чтобы не попасть в такую ловушку, можно добавить пару проверок на запрос.

Самая практичная проверка — убедиться, что мы действительно отправили JSON. MockRestServiceServer позволяет матчить request body. Не нужно превращать это в тотальный снимок всего JSON (иначе тест станет хрупким), но проверить ключевые поля — полезно.

import org.junit.jupiter.api.Test;

import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;

@Test
void shouldSendJsonBody() {
    server.expect(requestTo("https://moderation.local/api/check"))
            .andExpect(method(POST))
            // Проверяем, что заголовок Content-Type выставлен корректно.
            .andExpect(content().contentType(APPLICATION_JSON))
            // Проверяем форму запроса: ключевые поля, которые важны внешнему контракту.
            .andExpect(content().json("""
                    {"title":"Hello","body":"Some body"}
                    """))
            .andRespond(withSuccess("""
                    {"result":"OK","reason":null}
                    """, APPLICATION_JSON));

    ModerationDecision decision = client.moderate(
            new ModerationRequest("Hello", "Some body")
    );

    // Здесь нам достаточно проверить результат: запрос уже зафиксирован ожиданиями выше.
    assertThat(decision.result()).isEqualTo(ModerationResult.OK);
}

Здесь мы уже зафиксировали гораздо более «контрактный» аспект: форма запроса. Если завтра кто-то переименует поле body в text в ModerationRequest, то тест сразу начнёт кричать. И это хорошо, потому что внешний сервис не обязан угадывать наши намерения; он умеет читать только то, что мы ему отправили.

При этом важно не переборщить. Если вы начнёте сравнивать весь JSON до последней запятой, вы быстро придёте к тестам, которые ломаются от косметики. В адаптере обычно стоит проверять то, что действительно является частью внешнего контракта: URL, метод, content type, ключевые поля.

Ветки ответа: WARN и BLOCK

Очень частая ошибка начинающих — желание написать «один большой тест на все случаи». Он выглядит героически, но падает трагически: любой дефект ломает весь сценарий, и вы долго выясняете, какая именно ветка поломалась.

Гораздо стабильнее держать «одна ветка — один тест». Тогда тест является документацией: вот что значит OK, вот что значит BLOCK. Для нашей модерации BLOCK важен особенно: причина блокировки должна доехать до домена и дальше до бизнес-логики.

import org.junit.jupiter.api.Test;

@Test
void shouldMapBlockResponse() {
    // Ветка BLOCK: важно, чтобы причина не потерялась при маппинге.
    server.expect(requestTo("https://moderation.local/api/check"))
            .andRespond(withSuccess("""
                    {"result":"BLOCK","reason":"spam"}
                    """, APPLICATION_JSON));

    ModerationDecision decision = client.moderate(
            new ModerationRequest("Buy now", "Cheap cheap cheap")
    );

    assertThat(decision.result()).isEqualTo(ModerationResult.BLOCK);
    assertThat(decision.reason()).isEqualTo("spam");
}

Аналогично можно зафиксировать WARN:

import org.junit.jupiter.api.Test;

@Test
void shouldMapWarnResponse() {
    // Ветка WARN: результат должен попасть в домен, а пояснение — сохраниться.
    server.expect(requestTo("https://moderation.local/api/check"))
            .andRespond(withSuccess("""
                    {"result":"WARN","reason":"too many links"}
                    """, APPLICATION_JSON));

    ModerationDecision decision = client.moderate(
            new ModerationRequest("Links", "http://a http://b http://c")
    );

    assertThat(decision.result()).isEqualTo(ModerationResult.WARN);
    assertThat(decision.reason()).isEqualTo("too many links");
}

Мы пока сознательно держимся happy path: HTTP-ответ успешный (200 OK), JSON валиден. Здесь важно зафиксировать базовый, узкий и понятный тест, который держит внешний контракт адаптера без лишней инфраструктуры. Когда этот путь стабилен, на него уже легко навешивать отказные ветки того же клиента.

7. Читаемость и устойчивость тестов

Когда тестов становится больше трёх, появляется новый риск: тесты начинают выглядеть как «простыня из server.expect(...)». И тут очень легко в попытке “сократить код” спрятать смысл. Мы уже проходили это на MockMvc и на unit-тестах: helper’ы полезны, пока они не превращают тест в загадку.

Хорошая практическая точка — вынести повторяющиеся куски, которые не несут смысла сценария, но оставить читабельными ключевые отличия. Например, URL можно сделать константой. Это небольшая вещь, но она спасает от «опечаток на миллион долларов» (точнее, на миллион минут дебага).

private static final String CHECK_URL = "https://moderation.local/api/check";

Дальше можно оформить маленький helper для ответа:

private void stubResponse(String json) {
    server.expect(requestTo(CHECK_URL))
            .andRespond(withSuccess(json, APPLICATION_JSON));
}

И в тесте останется только смысл:

@Test
void shouldMapOkResponse() {
    stubResponse("""{"result":"OK","reason":null}""");

    ModerationDecision decision = client.moderate(
            new ModerationRequest("Hello", "Some body")
    );

    assertThat(decision.result()).isEqualTo(ModerationResult.OK);
}

Но важно не уйти слишком далеко и не спрятать, например, HTTP-метод или проверку тела запроса. Потому что потом вы забудете, что у вас вообще проверяется метод POST, и тест станет «вроде что-то проверяет, но что именно — тайна древних». В тестах адаптера ключевое — сохранить прозрачность внешнего контракта.

Ещё одна полезная техника, особенно для новичков, — группировать ветки через @Nested. Тогда тест-класс читается как мини-документация по протоколу:

import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

@Nested
class Mapping {

    @Test
    void ok() { /* ... */ }

    @Test
    void warn() { /* ... */ }

    @Test
    void block() { /* ... */ }
}

Это не делает тесты «магически лучше», но резко повышает читаемость. А читаемость — это то, что спасает вас через три месяца, когда вы уже не помните, зачем вы вообще написали этот тест, но он внезапно упал в CI.

И последний нюанс: помните, что requestTo(...) в ожиданиях чаще всего должен быть полным URL, если вы используете baseUrl. Это одна из самых частых причин падений. Вы пишете ожидание на "/api/check", а реальный запрос уходит на "https://moderation.local/api/check". И MockRestServiceServer честно говорит: «не совпало». Он не вредный — он просто буквальный. Как любой хороший тестовый инструмент.

8. Типичные ошибки при @RestClientTest

Ошибка №1: тестировать адаптер через @SpringBootTest, потому что “так надёжнее”.
Это выглядит логично, пока вы не замечаете цену. Полный контекст делает тест медленным, часто требует больше конфигурации и даёт хуже диагностику: падение может быть из-за чего угодно. @RestClientTest как раз создан, чтобы проверять именно контракт адаптера быстро и изолированно, не превращая каждую проверку в мини-запуск всего приложения.

Ошибка №2: проверять только результат (decision.result()), но не проверять внешний контракт.
Если вы не фиксируете URL, метод и хотя бы минимальную форму тела запроса, тест может стать зелёным даже при серьёзной поломке: например, адаптер начал ходить на другой endpoint или “случайно” отправляет GET вместо POST. Тест адаптера ценен тем, что он защищает договорённость между вашим кодом и внешним сервисом, а не только ваш enum внутри JVM.

Ошибка №3: ожидать в requestTo(...) путь, а не полный URL, когда у вас используется baseUrl.
Это один из самых раздражающих багов, потому что он “похож на мелочь”, но ломает весь тест. Если вы собираете RestClient через builder.baseUrl("https://moderation.local"), то реальный запрос будет на полный адрес. И MockRestServiceServer тоже ждёт полный адрес, иначе он не понимает, что именно вы хотели проверить.

Ошибка №4: превращать тест в проверку “каждой запятой” и делать его хрупким.
Да, можно сравнить весь JSON запроса/ответа в точности. Но тогда любая косметика (например, порядок полей, появление нового необязательного поля) начнёт ломать тесты, даже если бизнес-смысл не изменился. Удерживайте баланс: проверяйте то, что реально является контрактом, и не превращайте тест в “скриншот” всей структуры без необходимости.

Ошибка №5: смешивать в одном тесте ответственность адаптера и ответственность сервиса.
Если тест начинает проверять «и что запрос правильный, и что статья сменила статус, и что security не пустил анонима, и что уведомление отправилось», то вы снова получаете дорогой, длинный сценарий с плохой диагностикой. Адаптер проверяем в @RestClientTest, бизнес-реакцию сервиса — unit-тестом с fake/stub порта, сквозные эффекты — integration-тестами. Каждый слой должен доказывать свою часть правды, иначе тестовая “истина” превращается в кашу.

1
Задача
Spring Test, 24 уровень, 1 лекция
Недоступна
Узкий тест для клиента оценки заголовка
Узкий тест для клиента оценки заголовка
1
Задача
Spring Test, 24 уровень, 1 лекция
Недоступна
Узкий тест для клиента извлечения ключевых слов
Узкий тест для клиента извлечения ключевых слов
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ