JavaRush /Курсы /Spring Test /Relative URL, query и тело ответа

Relative URL, query и тело ответа

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

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/миграции так, чтобы тест всегда видел одинаковый мир.

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