1. Relative URL в RestTestClient
Когда RestTestClient уже настроен под RANDOM_PORT, самая частая лишняя привычка — снова руками писать что-то вроде "http://localhost:8080/...". Проблема в том, что в RANDOM_PORT у вас каждый запуск может дать новый порт, и тест, прибитый гвоздями к 8080, становится не тестом, а капризным домашним питомцем. Поэтому “правильная привычка дня” — писать запросы на relative URL.
Смысл простой: @AutoConfigureRestTestClient настраивает клиент так, чтобы он уже знал базовый адрес поднятого сервера (условно http://localhost:<randomPort>). А вы в тесте указываете только относительный путь, начиная с /. Это делает тесты короче, читабельнее и намного менее зависимыми от окружения. И да, это ещё и спасает вас от бесконечной склейки строк вида "http://localhost:" + port + ..., которая выглядит как «код, написанный в пятницу вечером».
Небольшая таблица, чтобы зафиксировать разницу:
| Что пишем в тесте | Пример | Почему это хорошо/плохо |
|---|---|---|
| Relative URL | "/api/public/articles" | Хорошо: не зависит от порта, прямо читается как контракт endpoint’а |
| Absolute URL | "http://localhost:" + port + "/api/public/articles" | Допустимо как fallback, но шумит в тесте и заставляет помнить о порте |
Скелет класса при этом не меняется: живой сервер, готовый клиент и относительные URI в самих сценариях.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
class PublicArticleRunningServerTest {
@Autowired
RestTestClient restClient;
}
Обратите внимание: дальше во всех примерах мы будем писать .uri("/api/..."), то есть relative URL, и этим самым мы практически «забываем», что порт вообще существует. Порт — это забота инфраструктуры, а не смысл теста.
2. Query-параметры в контракте API
Пагинация, фильтрация и сортировка — это то место, где backend-разработчик чаще всего “случайно ломает клиента”, даже если всё остальное кажется неизменным. Сегодняшняя мораль: query-параметры в тесте должны быть видны, потому что они часть публичного контракта API. Если вы прячете их в хитрый helper “где-то там”, вы через месяц получите тест, который зелёный, но никто не помнит, что он на самом деле проверяет.
В ContentHub публичный список статей (GET /api/public/articles) как раз типичный endpoint с параметрами page, size, возможно sort и фильтрами вроде category. В running-server тесте нам важно, чтобы запрос выглядел максимально “как настоящий” и при этом оставался коротким. Поэтому на старте вполне нормальный стиль — писать query-строку прямо в URL: да, это не «самый элегантный builder», но это самый читаемый контракт.
Вот минимальный пример: просим первую страницу размером 2 элемента и проверяем хотя бы статус. Такой тест уже полезен, потому что он проходит через реальный HTTP-стек, а не только через mocked MVC.
import org.junit.jupiter.api.Test;
@Test
void returnsFirstPageOfPublishedArticles() {
restClient.get()
// Query-параметры оставляем прямо в URL, чтобы тест читался как контракт API
.uri("/api/public/articles?page=0&size=2")
.exchange() // Реальный HTTP-вызов к поднятому серверу
.expectStatus().isOk(); // Минимальная проверка: endpoint жив и отвечает 200
}
Если хочется сделать запрос чуть “богаче” и заодно продемонстрировать, что query-параметры — это контракт, можно добавить сортировку. Даже если вы пока не проверяете порядок в теле ответа, сам факт, что endpoint принимает параметр sort и не падает, уже фиксируется.
import org.junit.jupiter.api.Test;
@Test
void supportsSortingByPublishedAt() {
restClient.get()
// Фиксируем в тесте, что endpoint поддерживает сортировку по publishedAt
.uri("/api/public/articles?page=0&size=5&sort=publishedAt,desc")
.exchange()
.expectStatus().isOk(); // Здесь нам важно именно отсутствие ошибки на контрактном параметре
}
Здесь важно удержать баланс. Если вы начнёте строить URL через сложные builder’ы, тест может стать “красивее”, но менее читаемым. А нам сейчас нужно, чтобы junior-разработчик, открыв тест, сразу увидел: «Ага, это paging; ага, это sorting; ага, вот значения». Это примерно как хороший чек в магазине: пусть он не произведение искусства, зато по нему всё понятно.
3. Детерминированные данные и @Sql
Проверять статус 200 OK на пустой базе данных можно, но это быстро превращается в тесты-пустышки: они зелёные, но уверенности не добавляют. Как только вы хотите проверить тело ответа (а мы хотим), вам нужно, чтобы данные в базе были предсказуемы. И здесь есть отличная новость: все инструменты для этого у вас уже есть из предыдущих дней — Flyway, seed-миграции, @Sql и нормальная дисциплина подготовки состояния.
Психологически важный момент: running-server тест — это уже «почти как прод», поэтому он особенно не любит “случайные данные”. Если вы не контролируете состояние базы, вы не тестируете API, вы тестируете удачу. А удача, как известно, не входит в spring-boot-starter-test ни одной зависимостью.
На практике мы делаем так: перед тестом подготавливаем небольшой dataset, например пару опубликованных статей с известными slug. Самый простой учебный путь — @Sql со скриптом, который лежит в src/test/resources. Возьмём условный файл published-articles.sql и будем придерживаться этого соглашения:
import org.junit.jupiter.api.Test;
import org.springframework.test.context.jdbc.Sql;
@Sql("/db/testdata/published-articles.sql") // Подготовка детерминированных данных перед тестом
@Test
void returnsArticlesFromSeedData() {
restClient.get()
// Тестируем чтение реальных данных, а не пустую базу
.uri("/api/public/articles?page=0&size=10")
.exchange()
.expectStatus().isOk();
}
Если вам хочется представить, что обычно лежит внутри такого SQL-файла, вот очень упрощённый (и “учебный”) пример. В вашем scaffold имена таблиц/колонок могут отличаться — это нормально; главное сейчас понять идею: создать фиксированное состояние с узнаваемыми slug.
-- db/testdata/published-articles.sql
delete from articles;
insert into articles(id, slug, title, status)
values (1001, 'existing-article', 'Existing article', 'PUBLISHED');
insert into articles(id, slug, title, status)
values (1002, 'another-article', 'Another article', 'PUBLISHED');
В этом месте многие новички пытаются “улучшить жизнь” и добавляют @Transactional на тестовый класс, чтобы “всё само откатывалось”. И именно тут running-server режим подсовывает важный сюрприз, который лучше увидеть сейчас, чем потом на ночном релизе.
Если тестовый метод помечен @Transactional, то @Sql по умолчанию может выполниться в той же транзакции, не закоммититься до конца теста, и сервер (который обрабатывает запрос в другом потоке и обычно с другой JDBC-сессией) эти данные может просто не увидеть. Вы получите загадочный эффект: “скрипт точно вставил статьи, а endpoint возвращает пусто”. Это не магия, это транзакционная изоляция, и она ведёт себя ровно так, как должна.
Если вам всё же по какой-то причине нужно, чтобы @Sql всегда выполнялся в отдельной транзакции и коммитился независимо от тестовой, у Spring Test есть механизм @SqlConfig с режимом ISOLATED. Это полезно знать как “аварийный выход”, но в качестве повседневной привычки проще вообще не делать running-server тесты транзакционными.
import org.junit.jupiter.api.Test;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.context.jdbc.SqlConfig;
@Sql(
scripts = "/db/testdata/published-articles.sql",
config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED) // Делаем так, чтобы данные были закоммичены и видны серверу
)
@Test
void serverCanSeeDataInsertedBySql() {
restClient.get()
.uri("/api/public/articles")
.exchange()
.expectStatus().isOk(); // Если сервер "не видит" данные, часто всплывает именно здесь через пустой ответ/не тот статус
}
Мы ещё вернёмся к транзакционным границам ближе к концу лекции, но сейчас зафиксируйте практическую мысль: в live-server тестах данные должны быть не просто “подготовлены”, а подготовлены так, чтобы сервер реально их видел.
4. Проверки JSON-тела ответа
Проверка статуса — это как посмотреть на машину издалека и сказать: «ну вроде колёса есть». Иногда этого достаточно, но чаще хочется убедиться, что внутри не велосипед. Поэтому, как только мы стабилизировали данные, следующий шаг — научиться проверять тело ответа. И здесь важно не превратить тест в хрупкую историю про “случайные пробелы в JSON”.
Самый простой способ — взять ответ как текст и проверить, что он содержит нужный кусочек. Это быстрый старт, особенно когда вы ещё только знакомитесь с RestTestClient. Но нужно честно понимать границу: contains("\"slug\":\"existing-article\"") — это скорее “дымовой тест контента”, чем строгий контракт.
Вот пример такого стартового подхода:
import org.junit.jupiter.api.Test;
import org.springframework.test.context.jdbc.Sql;
import static org.assertj.core.api.Assertions.assertThat;
@Sql("/db/testdata/published-articles.sql") // Гарантируем, что slug точно существует в базе
@Test
void responseContainsKnownSlug() {
var exchange = restClient.get()
.uri("/api/public/articles?page=0&size=10")
.exchange(); // Выполняем запрос и получаем exchange-результат
String body = exchange.expectBody().returnResult().getResponseBodyAsString();
assertThat(body).contains("existing-article"); // Быстрая "дымовая" проверка контента
}
Этот тест уже намного полезнее, чем просто isOk(), потому что он проверяет, что сервер реально отдал какие-то данные, а не пустую заглушку. Но чтобы проверка была “про смысл”, а не про форматирование, лучше использовать JsonPath, который вы уже видели раньше в курсе. Плюс в том, что JsonPath позволяет вытащить значения по пути, и вам становится всё равно, в каком порядке поля сериализовались и сколько там пробелов.
Очень практичный приём для page-ответов, когда вы не хотите зависеть от конкретной структуры wrapper’а: использовать рекурсивный поиск $..slug. Он найдёт все поля slug где угодно внутри JSON-дерева, и вы сможете проверить, что нужный slug присутствует.
import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test;
import org.springframework.test.context.jdbc.Sql;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Sql("/db/testdata/published-articles.sql") // Поднимаем фиксированный dataset
@Test
void pageContainsExistingArticleSlug() {
var exchange = restClient.get()
.uri("/api/public/articles?page=0&size=10")
.exchange();
String body = exchange.expectBody().returnResult().getResponseBodyAsString();
// Рекурсивно ищем все поля slug в ответе (не привязываемся к конкретной JSON-обёртке страницы)
List<String> slugs = JsonPath.read(body, "$..slug");
assertThat(slugs).contains("existing-article"); // Проверяем именно данные, а не формат JSON-строки
}
Теперь тест говорит не “в строке встретилась подстрока”, а “в JSON-данных есть slug, который мы ожидали”. Это уже ближе к реальному контракту.
Точно так же полезно проверять не только happy path. Например, когда статьи по slug не существует, мы ожидаем 404 и хотим убедиться, что вернулся наш стабильный ApiProblem, где есть errorCode. Это великолепный running-server сценарий, потому что он детерминирован даже без данных: вы всегда можете запросить заведомо отсутствующий slug.
import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@Test
void returnsApiProblemForMissingArticle() {
var exchange = restClient.get()
// Нам не нужны данные в базе: slug заведомо отсутствует
.uri("/api/public/articles/missing-article")
.exchange();
exchange.expectStatus().isNotFound();
String body = exchange.expectBody().returnResult().getResponseBodyAsString();
// Контракт: в теле ApiProblem есть стабильный errorCode
String errorCode = JsonPath.read(body, "$.errorCode");
assertThat(errorCode).isEqualTo("ARTICLE_NOT_FOUND");
}
Даже если точные поля ApiProblem вам знакомы по JSON-тестам и MVC-тестам, здесь есть важная добавка: теперь мы проверяем это на живом сервере, то есть через реальный HTTP-вход, фильтры и “настоящую” обработку запроса. Это другой уровень доказательства, и он хорошо дополняет slices.
5. Живой сервер: потоки и транзакции
На этом этапе обычно случается один из самых “дорогих” по времени багов в голове: человек видит @SpringBootTest, видит слово “integration”, автоматически добавляет @Transactional и ожидает, что всё будет вести себя как в @DataJpaTest или как в некоторых MockMvc-сценариях. Но live-server режим принципиально другой: у вас реально есть клиент и реально есть сервер, и они живут в разных потоках исполнения.
Выглядит это примерно так (очень упрощённо, но честно по смыслу):
sequenceDiagram
participant T as "Test (клиент)"
participant S as "Server (контроллер/сервлет)"
participant DB as Database
T->>S: HTTP GET /api/public/articles
S->>DB: SELECT ...
DB-->>S: rows
S-->>T: 200 + JSON body
И вот почему это важно именно для тестов. Транзакция, которую вы открыли в тестовом методе (если вы вообще её открыли), не оборачивает обработку HTTP-запроса на стороне сервера. Сервер откроет свою транзакцию (или не откроет — зависит от вашего production-кода), выполнит работу и закоммитит изменения независимо от того, как живёт транзакция теста. Это означает две вещи, которые сначала кажутся несправедливыми, а потом становятся вашей суперсилой.
Первая вещь: если вы пытаетесь “откатить” изменения базы через @Transactional на тесте, это не гарантирует, что изменения, сделанные сервером, исчезнут. Тестовая транзакция живёт у клиента, а запись в базу делал сервер в своей транзакции. Поэтому “волшебный rollback” в live-server режиме не стоит воспринимать как инструмент уборки.
Вторая вещь (ещё более практичная): если вы подготавливаете данные внутри незакоммиченной транзакции теста, сервер может их не увидеть. Именно поэтому мы выше так аккуратно говорили про @Sql и про то, что иногда нужен ISOLATED режим или просто отказ от @Transactional в running-server тестах. Очень легко попасть в ситуацию: “я создал данные, но endpoint их не видит”, и потратить полдня на подозрения в адрес Jackson, контроллера и, конечно же, ретроградного Меркурия.
Чтобы держать suite стабильным, у running-server тестов обычно есть два спокойных пути. Первый путь — использовать read-only сценарии (как мы делали сегодня) и dataset, который каждый тест ставит заново через @Sql с “очисткой + вставкой”. Тогда состояние всегда предсказуемо. Второй путь — если тест всё же меняет данные (например, POST/PUT-flow), нужно явно договориться о cleanup: отдельный @Sql на AFTER_TEST_METHOD или скрипт, который очищает конкретные таблицы.
Вот пример “симметрии”: один скрипт для подготовки, второй для очистки после теста. Выглядит чуть длиннее, зато не надеется на магию.
import org.junit.jupiter.api.Test;
import org.springframework.test.context.jdbc.Sql;
@Sql("/db/testdata/published-articles.sql") // Подготовили состояние "до"
@Sql(value = "/db/testdata/cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) // Явно почистили состояние "после"
@Test
void usesRepeatableDatabaseState() {
restClient.get()
.uri("/api/public/articles?page=0&size=10")
.exchange()
.expectStatus().isOk(); // Проверяем только контракт, а не "магическое" состояние базы
}
Да, писать cleanup-скрипт звучит как “лишняя работа”. Но это тот случай, когда лишняя работа сегодня экономит вам часы дебага завтра. А ещё это делает интеграционные тесты повторяемыми локально и в CI, где “состояние на машине” не должно играть никакой роли.
6. Типичные ошибки в live-server тестах
Ошибка №1: писать абсолютные URL и хардкодить localhost:8080.
В running-server тестах это особенно болезненно, потому что RANDOM_PORT как раз создан, чтобы порт не был частью тестового контракта. Если вы пишете абсолютный URL, тест начинает зависеть от среды и теряет смысл “самодостаточного” запуска. По умолчанию держите стиль uri("/api/..."), а @LocalServerPort оставляйте как запасной инструмент, когда он действительно нужен.
Ошибка №2: прятать query-параметры в helper так, что тест перестаёт быть читаемым.
Иногда хочется сделать метод publicArticles(page, size, sort) и внутри построить URL, чтобы “не дублировать строки”. В итоге через месяц вы смотрите на тест и не понимаете, какие параметры реально отправились, и где был дефолт, а где нет. Для обучающего проекта и для большинства важных контрактных проверок лучше, чтобы query-строка была видна прямо в тесте.
Ошибка №3: пытаться сделать running-server тест “транзакционным и самоочищающимся” через @Transactional.
В live-server режиме клиент и сервер живут в разных потоках и обычно в разных транзакциях, поэтому тестовая транзакция не является магическим ластиком для серверных изменений. Более того, @Transactional может мешать подготовке данных: сервер не увидит незакоммиченные вставки. Если нужна уборка, делайте её явно (@Sql AFTER_TEST_METHOD) или через повторяемые seed-скрипты.
Ошибка №4: проверять тело ответа посимвольно или слишком “в лоб”.
Сравнение JSON как строки быстро ломается от перестановки полей, форматирования или появления нового поля. В результате тест начинает падать от шума, а не от реальной регрессии контракта. Для смысловых проверок используйте JsonPath (в том числе удобный $..field для поиска по дереву) или JSONassert в уместном режиме — так вы проверяете данные, а не косметику.
Ошибка №5: забывать, что без детерминированного data setup проверка тела ответа превращается в гадание.
Если тест зависит от того, какие статьи “случайно” лежат в базе, он будет нестабильным, а иногда ещё и будет проходить по неправильной причине. В running-server тестах особенно важно: либо вы проверяете детерминированный negative case (например, 404 для отсутствующего slug), либо вы подготавливаете состояние через @Sql/миграции так, чтобы тест всегда видел одинаковый мир.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ