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 або порожній масив — і зафіксувати його тестом. Не можна сподіватися, що «якось само стабілізується».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ