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 идея обратная: настройка должна быть минимальной, а сценарий — на первом плане.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ