1. Зачем отдельные JSON‑тесты
Если в API есть DTO, которые встречаются «иногда», то их поломка бьёт по одному endpoint-у и одному сценарию. Но ApiProblem и PageResponse — это как болты в самолёте: никто не обсуждает их «фичи», пока они на месте, но если кто-то один болт «случайно переименовал», внезапно страдают все. Поэтому их JSON-контракт — кандидат №1 на отдельные узкие тесты.
В ContentHub эти два payload-а обычно участвуют сразу в десятках ответов. ApiProblem возвращается, когда клиент получает ошибку (и он почти всегда хочет понять её машинно: по errorCode, по violations, по status). PageResponse появляется в любом «списочном» endpoint-е и становится частью привычки клиента: он ждёт content и метаданные страницы в стабильном формате.
Представим «зону поражения» в виде простой схемы. Она не про Spring, не про контроллеры и не про базы данных — просто про то, какие DTO являются общими строительными блоками API:
flowchart TD
Client[Клиент API] --> PublicList[GET /api/public/articles]
Client --> PublicDetails["GET /api/public/articles/{slug}"]
Client --> EditorList[GET /api/editor/articles]
Client --> AdminList[GET /api/admin/articles]
PublicList --> PageResponse[PageResponse]
EditorList --> PageResponse
AdminList --> PageResponse
PublicDetails --> ArticleDetails[ArticleDetailsResponse]
PublicList -->|ошибки| ApiProblem[ApiProblem]
PublicDetails -->|ошибки| ApiProblem
EditorList -->|ошибки| ApiProblem
AdminList -->|ошибки| ApiProblem
Логика простая: если у вас сломался ArticleDetailsResponse, клиент ломается, когда читает карточку статьи. Если вы сломали ApiProblem — клиент ломается «везде, где бывают ошибки», то есть почти везде. А ошибки в реальности бывают чаще, чем мы любим признавать (особенно в ночь перед релизом).
Из этого следует практическое правило для тестов: общие payload-ы должны иметь собственные @JsonTest-классы, чтобы их контракт проверялся быстро и изолированно, без шума web-слоя и бизнес-логики.
2. Контракт ApiProblem
Когда разработчик говорит «ошибка», клиент обычно слышит «а можно мне нормальные поля, чтобы я не парсил вашу строку detail регулярками?». Поэтому ApiProblem в нашем курсе — не «как получится», а стабильный контракт с машиночитаемыми полями. В JSON-тестах важно зафиксировать то, что действительно будет читаться клиентом автоматически, и то, что чаще всего ломают при «невинном рефакторинге».
Ниже — удобная табличка с типичным смыслом полей ApiProblem. Это не «догма на века», а модель того, что вы должны защищать тестами. Даже если ваш реальный класс чуть отличается, логика проверки будет той же: поля, на которые завязан клиент, должны быть стабильны по имени и типу.
| Поле в JSON | Тип в JSON | Зачем клиенту | Что чаще ломают |
|---|---|---|---|
| status | number | Быстро понять класс ошибки | превращают в строку "404" или переименовывают |
| errorCode | string | Машиночитаемая причина (ARTICLE_NOT_FOUND, ACCESS_DENIED) | меняют имя поля (code) или формат значения |
| title | string | Короткое описание | начинают «локализовать» без согласования |
| detail | string / null | Текст для человека | внезапно делают обязательным/необязательным |
| type | string | Категория/URI типа ошибки | переименовывают или выкидывают |
| instance | string | Ссылка/идентификатор конкретного случая | меняют формат или убирают |
| violations | array / null / absent | Ошибки валидации по полям | ломают структуру элементов массива |
Особенно коварное поле — violations. Его легко «улучшать» (добавить поле, переименовать field в path, сделать message объектом…), и каждый раз это ломает клиентскую обработку форм ошибок. JSON-тесты хороши тем, что они превращают такие «улучшения» в явное решение: тест падает, и вы вынуждены честно ответить себе «мы правда меняем публичный контракт?».
Ещё один нюанс: JSON-тест ApiProblem не должен зависеть от того, как именно ошибка появляется. В этой лекции мы не тестируем исключения, обработчики ошибок и преобразование exceptions в ответы. Мы тестируем DTO как объект JSON-контракта: что при сериализации он даёт правильный JSON, и что этот JSON можно обратно прочитать без сюрпризов.
3. Тест ApiProblemJsonTest
Сериализация
Сериализация ApiProblem — самый частый и полезный сценарий для узкого JSON-теста. Мы буквально говорим: «вот Java-объект, который мы считаем нашей ошибкой; покажи мне JSON, и я проверю, что ключевые поля на месте, с правильными именами и типами». Это быстрый тест, и он падает раньше, чем вы успеете запустить приложение и тыкнуть Postman.
Начнём с минимального каркаса теста. Заметьте: мы не поднимаем web-слой, не заводим контроллеры, не мокируем сервисы. Только JSON slice.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.JsonTest;
import org.springframework.boot.test.json.JacksonTester;
@JsonTest // Поднимаем только JSON-срез: ObjectMapper и Jackson-конфигурацию
class ApiProblemJsonTest {
@Autowired
private JacksonTester<ApiProblem> json; // Утилита для сериализации/парсинга DTO в тестах
}
Теперь добавим тест, который фиксирует самое несущие поле — errorCode. Если вы случайно переименуете его в code, клиент, который ориентировался на errorCode, не «чуть-чуть расстроится», он просто перестанет понимать, что произошло.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@Test
void writesErrorCodeAsStableField() throws Exception {
// Готовим DTO руками: в JSON-тесте мы фиксируем контракт, а не источник ошибки
ApiProblem problem = sampleArticleNotFoundProblem();
// Проверяем конкретный ключ JSON, чтобы тест не был хрупким из-за порядка полей
assertThat(json.write(problem))
.extractingJsonPathStringValue("@.errorCode")
.isEqualTo("ARTICLE_NOT_FOUND");
}
Здесь я специально делаю точечную проверку: она короткая, ясная и доказывает именно контракт. При этом полезно дополнительно зафиксировать status как число, потому что это классическая ловушка: кто-то приводит к строке «для единообразия», а затем фронтенд начинает сравнивать "404" с числом 404 и молча проигрывает.
@Test
void writesStatusAsNumber() throws Exception {
// Используем тот же «эталонный» пример ошибки
ApiProblem problem = sampleArticleNotFoundProblem();
assertThat(json.write(problem))
.extractingJsonPathNumberValue("@.status")
// Сравниваем через intValue(), потому что JsonPath возвращает абстрактный Number
.satisfies(n -> assertThat(n.intValue()).isEqualTo(404));
}
Обратите внимание на satisfies(...). Метод extractingJsonPathNumberValue возвращает Number, а Number бывает Integer, Long, BigDecimal — зависит от маппера и контекста. Если вы сделаете наивное isEqualTo(404), иногда получите неожиданные падения из-за несовпадения классов. Так что проверять через intValue() или longValue() — спокойнее.
Теперь самое «интересное» — violations. Обычно это массив объектов (например, { "field": "title", "message": "must not be blank" }). Нам важно зафиксировать, что это именно массив, что элементы имеют ожидаемую структуру, и что имена полей внутри элементов тоже стабильны.
@Test
void writesViolationsAsArrayOfObjects() throws Exception {
// Специальный пример, где violations точно присутствуют
ApiProblem problem = sampleValidationProblem();
var content = json.write(problem); // Сериализуем и работаем с JsonContent
// Фиксируем структуру элемента массива violations: field + message
assertThat(content).extractingJsonPathStringValue("@.violations[0].field")
.isEqualTo("title");
assertThat(content).extractingJsonPathStringValue("@.violations[0].message")
.isEqualTo("must not be blank");
}
Заметьте стиль: я не сравниваю весь JSON «в лоб» огромной строкой. Я фиксирую смысл: форма массива, структура элемента. Полное сравнение через fixture-файл — тоже хороший инструмент, но в этой лекции нам важно научиться держать фокус на несущих элементах контракта.
Чтобы примеры были самодостаточными, покажу вариант простого фабричного метода внутри теста. Да, это не идеально «архитектурно», но для JSON-теста это часто нормально: DTO маленький, тест локальный, а смысл читается сразу.
private static ApiProblem sampleArticleNotFoundProblem() {
ApiProblem problem = new ApiProblem();
problem.setStatus(404); // HTTP-статус должен сериализоваться числом
problem.setErrorCode("ARTICLE_NOT_FOUND"); // Машиночитаемый код для клиента
problem.setTitle("Article not found"); // Короткое описание для человека
return problem;
}
Если у вас ApiProblem сделан иначе (например, через конструктор или builder), адаптируйте — тестовая идея не меняется: создать DTO → сериализовать → проверить ключевые поля.
Десериализация
Десериализация ошибок кажется странной: «мы же сами отдаём ошибку, зачем нам её читать обратно?». Но на практике эта проверка даёт два бонуса. Во‑первых, она фиксирует, что JSON-контракт не только «красиво пишется», но и корректно читается тем же маппером (а значит, вероятно, и клиентским). Во‑вторых, она ловит сюрпризы, связанные с null, отсутствующими полями и enum-значениями.
Простейший тест: берём JSON-строку, парсим её в ApiProblem, проверяем пару самых важных полей. Здесь мы сознательно не привязываемся к тому, как эта ошибка сформировалась. Мы тестируем «паспорт» ошибки, а не «кто его выдал».
@Test
void parsesApiProblemFromJson() throws Exception {
// Минимальный JSON: только поля, которые критичны для клиента
String content = """
{"status":404,"errorCode":"ARTICLE_NOT_FOUND","title":"Article not found"}
""";
ApiProblem problem = json.parseObject(content); // Десериализация JSON -> DTO
// Фиксируем, что ключевые поля корректно читаются
assertThat(problem.getStatus()).isEqualTo(404);
assertThat(problem.getErrorCode()).isEqualTo("ARTICLE_NOT_FOUND");
}
Почему это полезно именно в контексте violations? Потому что violations часто бывает null, пустым массивом или вообще отсутствует — и это три разные ситуации, которые клиент может трактовать по-разному. Если вы не зафиксировали правило, оно «само выберется» (в зависимости от Jackson-настроек, аннотаций и случайных изменений), и вы получите нестабильный контракт.
Например, если вы решили, что при отсутствии нарушений поле violations не должно присутствовать, то ваш JSON-тест должен это подтвердить. В JacksonTester удобно сделать это через полное сравнение с небольшим ожидаемым JSON, где поля нет. Но даже без fixture-файлов можно проверять точечно: попытаться извлечь @.violations и ожидать null. Я покажу мягкий, но понятный вариант: сериализуем проблему без нарушений и проверяем, что violations не появляется как массив.
@Test
void omitsViolationsWhenThereAreNone() throws Exception {
ApiProblem problem = sampleArticleNotFoundProblem(); // Без violations
assertThat(json.write(problem))
// Явно фиксируем выбранную политику: поля нет вообще
.doesNotHaveJsonPathValue("@.violations");
}
Такой тест в будущем спасает вас от «невинного улучшения», когда кто-то решит всегда писать violations: []. Это может быть нормальным решением, но тогда оно должно быть осознанным, потому что клиентский код мог отличать «валидационных ошибок нет» от «поле исчезло».
4. Контракт PageResponse
PageResponse кажется скучным объектом: массив content и несколько чисел. Именно поэтому он опасен. Его часто меняют без должной осторожности: «переименуем totalElements в total, потому что короче», «давайте вместо page сделаем offset», «метаданные вынесем в meta». Для клиента же это не косметика, а реальный breaking change: у него UI пагинации может перестать работать молча.
В ContentHub PageResponse — это ещё и архитектурный маркер: мы не возвращаем наружу фреймворковый объект (например, Page<T>). Мы возвращаем наш собственный контракт. Это полезно не только для клиентов, но и для тестов: вам проще фиксировать стабильную форму, не думая о внутренних деталях Spring Data и о том, что у Page сегодня одно поле, а завтра — другое.
Типичная форма PageResponse (упрощённая) может выглядеть так:
| Поле в JSON | Тип в JSON | Смысл |
|---|---|---|
| content | array | Элементы страницы (DTO, не entity) |
| page | number | Номер текущей страницы (обычно 0-based или 1-based — важно зафиксировать) |
| size | number | Размер страницы |
| totalElements | number | Общее число элементов |
| totalPages | number | Общее число страниц |
И вот здесь снова важная «мелочь»: числа должны оставаться числами, а не строками. Это звучит как «ну кто так делает?», но поверьте, один раз кто-то «для красоты» превратит totalElements в "25", и где-то на фронте появится очень загадочный баг, который будет жить до тех пор, пока вы не добавите тест.
5. Тест PageResponseJsonTest
Сериализация
Сделаем отдельный тестовый класс под PageResponse. Даже если у вас есть JSON-тесты конкретных элементов страницы (например, ArticleSummaryResponseJsonTest), wrapper всё равно заслуживает отдельного внимания: именно он общий и именно он задаёт форму списка.
Каркас класса (аналогично ApiProblemJsonTest):
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.JsonTest;
import org.springframework.boot.test.json.JacksonTester;
@JsonTest // Только Jackson + JSON-конфигурация, без web-слоя и контроллеров
class PageResponseJsonTest {
@Autowired
private JacksonTester<PageResponse> json; // Пишем/читаем PageResponse как JSON
}
Да, здесь я использую raw PageResponse, чтобы не усложнять жизнь дженериками прямо сейчас. На уровне JSON-контракта wrapper важнее, чем параметр типа. Мы всё равно проверяем структуру JSON.
Теперь — тест на метаданные, где мы фиксируем числа как числа. Это важнее, чем кажется, потому что «ошибка типов» — это баг, который не видно глазами в Postman, но который ломает клиентскую логику.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@Test
void writesTotalElementsAsNumber() throws Exception {
PageResponse page = samplePublicArticlesPage(); // Готовим страницу с тестовыми данными
assertThat(json.write(page))
.extractingJsonPathNumberValue("@.totalElements")
// longValue(), потому что totalElements часто хранится как long
.satisfies(n -> assertThat(n.longValue()).isEqualTo(25L));
}
Почему longValue()? Потому что totalElements часто живёт как long (и это разумно), а JSON-число Jackson может прочитать/написать так, что вы получите разные подтипы Number. Такой стиль делает тест устойчивее.
Дальше — минимальная проверка content. Мы не хотим превращать этот тест в «проверку всех полей всех элементов» — это быстро раздувает тест и делает его хрупким. Но 1–2 несущих поля внутри content зафиксировать полезно, потому что это доказывает, что content действительно массив объектов ожидаемой формы.
@Test
void writesContentAsArray() throws Exception {
PageResponse page = samplePublicArticlesPage();
assertThat(json.write(page))
// Проверяем, что content — массив объектов, и у первого элемента есть ожидаемое поле
.extractingJsonPathStringValue("@.content[0].slug")
.isEqualTo("spring-testing-basics");
}
Обратите внимание: мы проверяем slug, потому что это смысловое поле для публичного чтения статьи. Если slug внезапно переедет в articleSlug или начнёт сериализоваться иначе, тест упадёт и скажет: «контракт списка поломан».
И маленький helper, чтобы было понятно, откуда берётся объект:
private static PageResponse samplePublicArticlesPage() {
return PageResponse.of(
0, 10, 25L, 3,
// Внутри content лежат DTO, которые клиент реально читает
java.util.List.of(new ArticleSummaryResponse("spring-testing-basics", "Spring Testing Basics"))
);
}
Не зацикливайтесь на точной реализации of(...): в вашем проекте это может быть конструктор, builder или просто new PageResponse(...). Важно, что тест создаёт понятное состояние страницы и проверяет его JSON-форму.
Десериализация
Десериализация PageResponse — это ещё один способ защититься от сюрпризов контрактных изменений. Особенно полезно проверить, что объект читается при отсутствии каких-то второстепенных полей (если вы допускаете backward compatibility) и что числа читаются в ожидаемые типы. Но здесь мы держим фокус: не делаем огромных тестов, а подтверждаем пару ключевых вещей.
Минимальный пример: парсим JSON-строку, проверяем page, size и то, что content не пустой.
@Test
void parsesPageResponse() throws Exception {
// JSON-строка, максимально похожая на то, что реально уйдёт клиенту
String content = """
{"page":0,"size":10,"totalElements":25,"totalPages":3,
"content":[{"slug":"spring-testing-basics","title":"Spring Testing Basics"}]}
""";
PageResponse page = json.parseObject(content); // Десериализация JSON -> DTO
// Фиксируем ключевые поля пагинации
assertThat(page.getPage()).isEqualTo(0);
assertThat(page.getSize()).isEqualTo(10);
}
Если вы хотите проверить totalElements, делайте это осторожно и без наивного сравнения «long против int». Например, если totalElements в вашем DTO — long, то проверяйте 25L. Если это Long (nullable) — учитывайте null. Мы уже обсуждали в предыдущей лекции, что null — это часть контракта, а не «неприятность».
Ещё один момент: когда вы пишете такие тесты, не поддавайтесь желанию «а давайте тут проверим ещё сортировку, фильтры и всё на свете». PageResponse — это про форму данных, а не про то, как эти данные получены. Как только вы начинаете тащить сюда смысл поиска, вы уже тестируете не JSON-контракт, а бизнес/репозиторий/web-слой, и тест перестаёт быть узким и быстрым.
6. Типичные ошибки JSON‑тестов
Ошибка №1: тестировать источник ошибки вместо формы ApiProblem.
Очень легко скатиться в мысль: «а давайте мы сейчас вызовем какую-нибудь логику, она бросит исключение, и мы посмотрим, что вернулось». Это уже не JSON test, а что-то другое и гораздо более дорогое. В JSON-слое вы проверяете DTO как контракт: создаёте ApiProblem руками и фиксируете его JSON-форму без участия остального приложения.
Ошибка №2: проверять у ApiProblem только status, забывая про errorCode.
status — полезен, но почти всегда недостаточен. Клиенту нужно отличать ARTICLE_NOT_FOUND от ACCESS_DENIED даже если оба могут быть 403/404 в разных системах. Если вы не фиксируете errorCode отдельной проверкой, именно это поле «случайно» ломают в первую очередь (переименование, изменение формата, локализация).
Ошибка №3: сравнивать числовые поля страницы как строки.
Иногда тест делает что-то вроде «достаём JSON строкой и ищем "totalElements":"25"». Это одновременно и хрупко, и опасно: вы можете пропустить то, что число стало строкой, или наоборот. Правильнее извлекать Number через JsonPath и сравнивать через intValue/longValue, чтобы зафиксировать именно типовую семантику «это число».
Ошибка №4: превращать PageResponse-тест в проверку всех полей каждого элемента content.
PageResponse — общий wrapper. Его тест должен доказывать, что wrapper стабилен и что content действительно массив элементов ожидаемой формы. Но если вы начнёте детально проверять все поля статьи внутри content, тест станет длинным, хрупким и начнёт ломаться от любого изменения ArticleSummaryResponse, даже если wrapper не менялся.
Ошибка №5: не определиться с политикой null/absence для violations.
Если сегодня вы отдаёте violations: [], завтра случайно получаете violations: null, а послезавтра поле вообще пропадает — клиентский код начинает жить в режиме «угадай поведение сервера». Нужно выбрать правило (поле отсутствует / поле null / пустой массив) и зафиксировать его тестом. Нельзя надеяться, что «как-нибудь само стабилизируется».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ