JavaRush /Курсы /Spring Test /Live-server тест с RestTes...

Live-server тест с RestTestClient

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

1. Роль RestTestClient в live-server тестах

RANDOM_PORT уже закрыл первую задачу: сервер действительно поднят на реальном порту. Но сам по себе поднятый сервер ещё ничего не проверил — он просто существует. Чтобы доказать реальный HTTP-вход, нам нужен клиент, который умеет отправлять запросы к этому серверу и удобно проверять ответы, не утопив тест в инфраструктурном шуме.

В live-server тесте есть простая логика: тест играет роль внешнего клиента (условного браузера/мобильного приложения), сервер играет роль нашего Spring Boot приложения. Между ними — реальный HTTP. Это важнее, чем кажется, потому что именно на этом «стыке» часто вылезают проблемы: неправильный URL, неожиданный статус, странный content-type, неожиданное поведение фильтра или другая мелкая радость, которая в mocked MVC выглядит иначе.

Но если мы будем строить клиент вручную, то быстро окажемся в ситуации: «тест на 5 строк бизнес-смысла и 25 строк подготовки клиента». Такой код плохо читается, а ещё хуже поддерживается — особенно junior-разработчиком, который в тестах пока и так чувствует себя как в новом городе без карты.

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

2. Шаблон live-server теста

Сейчас будет момент, когда вы почувствуете, что Spring Boot — это иногда не только «магия, которую надо терпеть», но и «магия, которая экономит время». Мы уже умеем запускать приложение на случайном порту через @SpringBootTest(webEnvironment = RANDOM_PORT). Следующий шаг — получить готовый тестовый HTTP-клиент, который уже знает, куда стучаться, и не заставляет нас руками собирать base URL.

Минимальный каркас тестового класса выглядит так: полный контекст (@SpringBootTest) в режиме живого сервера + автоконфигурация клиента (@AutoConfigureRestTestClient) + внедрение клиента через @Autowired. Обратите внимание: мы не хардкодим порт, не пишем http://localhost:8080, не тащим @LocalServerPort в каждый тест только ради того, чтобы склеить строку.

Вот «скелет» теста (пока без тест-методов):

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
class PublicArticleRunningServerTest {

    @Autowired
    RestTestClient restClient;
}

Здесь важно проговорить смысл каждого элемента человеческим языком.

@SpringBootTest(webEnvironment = RANDOM_PORT) означает: «Подними весь Spring Boot контекст и реально стартуй веб-сервер на случайном порту». Это фундамент, без него live-server тест превращается в «у нас нет сервера, но мы очень стараемся».

@AutoConfigureRestTestClient означает: «Сконфигурируй тестовый клиент так, чтобы он мог ходить в поднятый сервер без ручной сборки URL». То есть Spring Boot сам связывает “я поднял сервер на вот таком порту” и “я даю тебе клиент, который этот порт уже знает”.

@Autowired RestTestClient restClient означает: «Дай мне этот клиент в поле, я буду им пользоваться в тестах». Да, field injection в проде мы не любим, но в тесте это допустимо и иногда даже удобно: здесь не про дизайн, а про читаемость и минимум церемоний.

3. Что делает @AutoConfigureRestTestClient

В мире тестирования есть вечный спор: «магия vs контроль». И вот хорошая новость: @AutoConfigureRestTestClient — это как раз тот случай, когда магия не прячет важное, а убирает рутину. Вам не нужно каждый раз вспоминать, где взять порт, как правильно собрать base URL и как настроить клиент под интеграционный режим. Это то, что Spring Boot способен сделать безопасно и одинаково для всех тестов.

За что мы платим в live-server тесте? За старт приложения, за настоящий веб-сервер, за реальную обработку HTTP. Если к этому добавить ещё и десятки строк ручной настройки клиента, получится «дорого и ещё дороже». Поэтому на уровне курса мы выбираем подход: клиент должен быть максимально “готовым из коробки”, чтобы вы тратили внимание на смысл сценария.

Полезно сравнить две стратегии.

Подход Как выглядит Что вам это даёт Чем может раздражать
Ручная сборка клиента через @LocalServerPort Вы берёте порт, клеите http://localhost: и т.д. Максимум явного контроля Много boilerplate, легко ошибиться, тесты пухнут
Автоконфигурация через @AutoConfigureRestTestClient Клиент уже готов, вы пишете только .uri("/...") Минимум шума, тесты читаемее Нужно помнить аннотацию и понимать, что она ожидает running server

Слово boilerplate тут означает «повторяющийся обязательный код, который не несёт бизнес-смысла». Он как «включите, пожалуйста, микрофон» на каждом созвоне — технически необходимо, но никто не хочет делать это вручную 50 раз в день.

Если вы когда-нибудь писали тест, где половина строк — “setup клиента”, то вы понимаете, почему в интеграционных тестах ценится всё, что этот setup сжимает.

Чтобы зафиксировать ощущение, вот как обычно выглядит ручной подход (это не рекомендация дня — это иллюстрация, почему мы от него уходим):

import org.springframework.boot.test.web.server.LocalServerPort;

// Spring подставит фактический порт, который выбрал для RANDOM_PORT
@LocalServerPort
int port;

// Base URL собираем вручную (и вот это как раз то, от чего хочется уйти)
String baseUrl = "http://localhost:" + port;

Да, это всего несколько строк. Но если вы дальше в каждом тесте строите URL, настраиваете клиент, добавляете заголовки, то эта «пара строк» быстро превращается в привычную рутину, которую вы даже перестаёте замечать — пока однажды не начнёт падать CI из-за мелочи в сборке URL.

4. Первый live-server тест: проверяем статус

Есть сильный соблазн в первом же тесте проверить всё: и статус, и заголовки, и JSON-тело, и чтобы slug был ровно такой, и чтобы title совпал, и чтобы вселенная была гармоничной. Но это ловушка. Когда вы только заводите новый тип теста, первое, что нужно доказать — что запрос реально проходит через живой HTTP-вход и попадает в нужную ветку поведения.

Поэтому мы начинаем с теста, который проверяет только статус. Не потому что мы ленивые (хотя… ладно, это тоже), а потому что status — это самый дешёвый и самый информативный сигнал на первом шаге. Если статус неверный, то обсуждать тело ответа бессмысленно: вы, возможно, вообще не там оказались.

Для ContentHub есть идеальный стартовый сценарий, не требующий сложной подготовки данных: запросить публичную статью по несуществующему slug и получить 404 Not Found. Такой тест сразу доказывает несколько вещей: сервер жив, routing работает, контроллер подцеплен, error handling включён, и вы действительно тестируете внешний HTTP-вход.

Пример тест-метода:

import org.junit.jupiter.api.Test; 

@Test
void returnsNotFoundForUnknownSlug() {
    // Отправляем запрос как внешний клиент (относительный URI, без localhost и порта)
    restClient.get()
            .uri("/api/public/articles/missing-article")
            .exchange() // Реальный HTTP-вызов в поднятый сервер
            .expectStatus().isNotFound(); // Проверяем, что сервер отдал 404
}

Заметьте важную деталь: в .uri(...) мы передаём относительный URL (relative URL). Мы не пишем http://localhost:12345/.... Почему так можно? Потому что RestTestClient уже знает base URL поднятого сервера. Пока нам нужен только этот факт: тест снова читается как описание endpoint'а, а не как сборка адреса.

Пока мы проверяем только 404. И это нормально. На следующем шаге вы начнёте аккуратно добавлять проверки, но уже на устойчивой базе: запрос точно ходит туда, куда должен.

5. Практика: вызов и проверки

Ментальная модель: запрос → exchange() → проверка ответа

Когда вы смотрите на цепочку .get().uri(...).exchange().expectStatus()..., в ней есть важная идея: запрос строится отдельно от момента “вызова”, а проверка ответа — отдельная стадия после вызова. Это помогает вам писать тесты как сценарии, а не как кашу из действий.

exchange() в данном стиле — это «совершить запрос». До него вы конфигурируете запрос (метод, URI, возможно headers/body), после него вы работаете с ответом. В интеграционном тесте это особенно важно: между вызовом и ответом реально происходит обработка запроса сервером, а не “магия внутри одного процесса”.

Полезный приём для читаемости — сохранить результат exchange() в переменную. Это сразу делает AAA-структуру (Arrange–Act–Assert) более заметной: вы видите, где вы “Act” делаете, и где вы “Assert”.

Пример:

var exchange = restClient.get()
        .uri("/api/public/articles/{slug}", "missing-article")
        .exchange();

exchange.expectStatus().isNotFound();

Пока здесь только статус, но важен сам стиль: exchange — это “результат взаимодействия” клиента и сервера. У вас появляется объект, вокруг которого вы дальше строите проверки.

Если вы когда-то писали тесты на MockMvc, то ощущение будет похожим: там вы тоже делаете perform(...), а потом проверяете ожидания. Но в live-server тестах ключевая разница в том, что запрос реально проходит через живой сервер и настоящий порт. И нам важно, чтобы код теста был настолько простой, чтобы вы могли сосредоточиться на этом различии, а не на синтаксисе клиента.

Чтобы зафиксировать картинку, вот упрощённая схема потока запроса в live-server тесте:

sequenceDiagram
    participant T as "Test (JUnit)"
    participant C as RestTestClient
    participant S as "Running Server (RANDOM_PORT)"
    participant MVC as "Spring MVC (controllers/advice)"
    T->>C: build request (GET /api/public/articles/...)
    C->>S: real HTTP request
    S->>MVC: dispatch to controller
    MVC-->>S: HTTP response
    S-->>C: real HTTP response
    C-->>T: exchange result / expectations

Мы не углубляемся в детали сервлета и контейнера — это не задача дня. Нам важнее ментально отделить: «я проверяю сервер так, как его увидит внешний клиент».

AssertJ-friendly стиль без лишней обёртки

У вас уже выработалась привычка: если вы в unit-тестах, вы пишете assertThat(...). Это хороший инстинкт, но в running-server тестах на старте не стоит добавлять ещё один промежуточный слой только ради того, чтобы всё выглядело одинаково. Built-in expect... API у RestTestClient и так достаточно короткий и не прячет HTTP-семантику.

Если команде позже захочется оборачивать чтение ответа в свои helper'ы, делайте это вокруг уже извлечённого exchange-результата, а не вокруг нового “магического” объекта посередине. Когда понадобится тело ответа, его проще доставать прямо из exchange()-результата, не придумывая отдельный промежуточный слой.

Как не испортить идею «минимального boilerplate»

Очень легко взять новый удобный инструмент и превратить его в большой «комбайн». Особенно в тестах. Особенно когда вы устали. Особенно когда хочется “чтобы просто работало”. Поэтому, пока вы только входите в RestTestClient, полезно удерживать несколько простых ориентиров, которые сохраняют тесты читаемыми.

Хороший live-server тест обычно выглядит как короткий сценарий: вы видите URI, видите момент вызова, видите пару проверок. Он не должен быть похож на простыню из 50 строк. Если тест длинный, чаще всего это сигнал не “я молодец и всё проверил”, а “я смешал несколько проверок в одну точку, и завтра это будет тяжело чинить”.

Для начала держите один тестовый класс сфокусированным на одном API-куске. В рамках ContentHub сегодня проще всего фокусироваться на public endpoints, потому что они не требуют обсуждать security (это будет отдельный уровень). Тест “missing slug → 404” — вообще идеальный: он не требует фикстур, не требует заранее созданных статей, но доказывает, что сервер действительно поднят и вы через него ходите.

Ещё один полезный ориентир: не пытайтесь “переписать MockMvc-стиль один-в-один”. Live-server тесты и так дороже, поэтому вы хотите их меньше. Их задача — не заменить все ваши MVC и unit-тесты, а дать уверенность именно в реальном HTTP-входе и “клиент-сервер” поведении. Если вы начнёте дублировать все MockMvc проверки в RestTestClient, вы получите suite, который долго бежит и который никто не любит запускать. А тесты, которые никто не любит запускать, в какой-то момент начинают… загадочно не запускаться.

Здесь полезно держать первую важную привычку: сначала заставьте тест быть коротким и работающим, и только потом обрастайте более глубокими проверками.

6. Типичные ошибки

Когда вы пишете первый running-server тест, вы почти гарантированно наступите на одну из типовых грабель. Это нормально: грабли — часть образовательного процесса, иначе мы бы учили вас в пещере при свете логов Hibernate. Главное — чтобы вы понимали, почему грабли лежат именно тут, и как их обходить, не превращая проект в шаманство.

Ошибка №1: забыли @AutoConfigureRestTestClient и удивляетесь, что RestTestClient не внедряется.
Обычно это выглядит так: вы написали @Autowired RestTestClient restClient; запустили тест и получили ошибку уровня “No qualifying bean of type …”. Лекарство простое: RestTestClient появляется не “сам по себе”, а через автоконфигурацию тестового клиента. В нашем дне это именно @AutoConfigureRestTestClient. Если её нет, Spring Boot честно говорит: “я не понял, откуда взять этот бин”.

Ошибка №2: запустили @SpringBootTest в режиме MOCK и ожидаете, что RestTestClient будет ходить в живой сервер.
Тут проблема концептуальная: MOCK означает “server не стартует”. То есть порта нет, слушателя нет, реального HTTP-входа нет. В таком тесте RestTestClient либо не сможет корректно работать, либо будет вести себя не так, как вы ожидаете. Если цель — live-server test, то режим должен быть RANDOM_PORT (или реже DEFINED_PORT, но это не сегодняшний выбор).

Ошибка №3: хардкодите http://localhost:8080 по привычке.
Это один из самых вредных рефлексов. Вы вроде бы написали integration test, но он стал зависеть от состояния локальной машины. Если 8080 занят — тест падает. Если в CI параллельно стартует другой сервис — тест падает. Если вы забыли выключить старый процесс — тест падает. А потом вы начинаете “чинить” тест не кодом, а танцами вокруг процессов. В RANDOM_PORT + RestTestClient стиль как раз и хорош тем, что порт выбирать не надо, он сам выбирается и прокидывается.

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

Ошибка №5: смешали настройку клиента и смысл теста в один гигантский setup-блок.
Иногда хочется вынести всё в @BeforeEach: собрать URL, собрать клиент, выставить дефолтные заголовки, и уже потом “сделать тест”. Но тогда каждый тест начинает читать не с “что мы проверяем”, а с “что мы настроили”. С @AutoConfigureRestTestClient идея обратная: настройка должна быть минимальной, а сценарий — на первом плане.

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