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

JSON‑тести ApiProblem і PageResponse

Spring Test
Рівень 8 , Лекція 3
Відкрита

1. Навіщо потрібні окремі JSON‑тести

Якщо в API є DTO, які трапляються лише іноді, то їхня поломка вдаряє по одній кінцевій точці й одному сценарію. Але ApiProblem і PageResponse — це як болти в літаку: ніхто не обговорює їхні «фічі», доки вони на місці, але якщо хтось раптом «перейменував» один болт, наслідки відчують усі. Тому їхній JSON-контракт — перший кандидат на окремі вузькі тести.

У ContentHub ці два DTO зазвичай беруть участь у десятках відповідей. ApiProblem повертається, коли клієнт отримує помилку, і майже завжди хоче зрозуміти її машинно: за errorCode, за violations, за status. PageResponse з’являється в будь-якій «списковій» кінцевій точці й стає частиною звички клієнта: він очікує 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, клієнт ламається всюди, де трапляються помилки, а це майже всюди. А помилки в реальності виникають частіше, ніж нам хочеться визнавати, особливо в ніч перед релізом.

Звідси випливає практичне правило для тестів: спільні DTO мають мати власні класи @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": "не має бути порожнім" }. Нам важливо зафіксувати, що це саме масив, що елементи мають очікувану структуру і що назви полів усередині елементів теж стабільні.


@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("не має бути порожнім");
}

Зверніть увагу на стиль: я не порівнюю весь JSON «в лоб» величезним рядком. Я фіксую зміст: форму масиву, структуру елемента. Повне порівняння через fixture-файл — теж хороший інструмент, але в цій лекції нам важливо навчитися тримати фокус на несучих елементах контракту.

Щоб приклади були самодостатніми, покажу варіант простого фабричного методу всередині тесту. Так, це не ідеально «архітектурно», але для JSON-тесту це часто нормально: DTO невеликий, тест локальний, а зміст читається одразу.


private static ApiProblem sampleArticleNotFoundProblem() {
    ApiProblem problem = new ApiProblem();
    problem.setStatus(404); // HTTP-статус має серіалізуватися числом
    problem.setErrorCode("ARTICLE_NOT_FOUND"); // Машиночитний код для клієнта
    problem.setTitle("Статтю не знайдено"); // Короткий опис для людини
    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":"Статтю не знайдено"}
        """;

    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». Для клієнта це не косметика, а реальна несумісна зміна: у нього 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"))
    );
}

Не зациклюйтеся на точній реалізації of(...): у вашому проєкті це може бути конструктор, builder або просто new PageResponse(...). Важливо, що тест створює зрозумілий стан сторінки й перевіряє його JSON-форму.

Десеріалізація

Десеріалізація PageResponse — це ще один спосіб захиститися від сюрпризів контрактних змін. Особливо корисно перевірити, що об’єкт читається за відсутності другорядних полів, якщо ви допускаєте зворотну сумісність, і що числа читаються в очікувані типи. Але тут ми тримаємо фокус: не робимо величезних тестів, а підтверджуємо кілька ключових речей.

Мінімальний приклад: парсимо 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"}]}
        """;

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

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ