JavaRush /Курсы /Spring Test /JSON‑тесты ApiProblem

JSON‑тесты ApiProblem и PageResponse

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

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 / пустой массив) и зафиксировать его тестом. Нельзя надеяться, что «как-нибудь само стабилизируется».

1
Задача
Spring Test, 8 уровень, 3 лекция
Недоступна
JSON-тест для `ApiProblem` с нарушением валидации
JSON-тест для `ApiProblem` с нарушением валидации
1
Задача
Spring Test, 8 уровень, 3 лекция
Недоступна
JSON-тест для page wrapper с метаданными
JSON-тест для page wrapper с метаданными
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ