1. JSON‑контракт — обличчя API
Коли ми говоримо «наш сервіс віддає JSON», це звучить так, ніби йдеться про другорядну упаковку. Насправді для клієнта JSON і є API. Клієнту неважливо, як називається Java-клас, який у нього конструктор і скільки там шарів сервісів. Він бачить лише байти у відповіді та набір полів у JSON. Якщо ці поля несподівано змінилися, для клієнта це рівно те саме, що «API зламався».
Ще один важливий момент: у Spring MVC JSON зʼявляється не тому, що хтось вручну склеює рядки, а тому, що Spring використовує механізм конвертації запитів і відповідей через HttpMessageConverter і за замовчуванням уміє автоматично перетворювати обʼєкти в JSON — зазвичай через Jackson. Це зручно, але водночас означає: невелика зміна в DTO або налаштуваннях мапера може різко змінити зовнішній JSON, навіть якщо бізнес-логіку взагалі не чіпали.
Уявіть собі, що ви показуєте паспорт на кордоні. Прикордоннику не важливо, яка ви людина всередині — добра, розумна чи вмієте Java. Йому важливо, що написано в документі: імʼя, дата, номер. Для клієнта JSON‑контракт — такий самий «документ». Він має бути стабільним і передбачуваним.
У межах ContentHub це особливо помітно: API публікує статті назовні та приймає дані від редактора. Будь-який фронтенд або мобільний застосунок привʼязаний до полів title, slug, status, дат публікації та формату помилок. Якщо ми випадково «зсунули» контракт, клієнт почне падати — навіть якщо наш сервіс і далі вважає себе молодцем.
2. JSON‑контракт і великі тести
Дуже людське бажання — написати один «великий» тест і сподіватися, що він прикриє все: і серіалізацію, і десеріалізацію, і бізнес-правила, і маршрутизацію. Психологічно це зрозуміло: один тест — одна кнопка “Run”, і можна йти пити чай. Інженерно це зазвичай закінчується тим, що чай ви питимете вже з продакшен-інцидентом.
Spring Boot спеціально виділяє автоналаштовані JSON-тести через @JsonTest, тобто сам фреймворк каже нам: «Серіалізація і десеріалізація — окремий ризик, його можна і треба тестувати ізольовано». Це важливий сигнал: якщо в платформі є окремий slice для JSON, значить проблема реальна й повторювана.
Чому не варто ховати JSON усередині controller-тесту, якщо вам потрібна надійність і користь:
По-перше, швидкість зворотного звʼязку. Великий тест підіймає більше оточення, довше запускається та повільніше показує результат. А поломка JSON‑контракту — річ часта: хтось перейменував поле, змінив тип дати, додав @JsonInclude, і привіт. Якщо такі зміни ловитимуться лише великими тестами, ви почнете запускати їх рідше, бо вони довго працюють, а баг житиме довше.
По-друге, якість сигналу. Якщо у вас падає controller-тест, там одночасно можуть бути замішані біндинг запиту, валідація, @ControllerAdvice, security-фільтри, моки сервісів і, десь усередині, ще серіалізація. Повідомлення про помилку буде чесним, але не обовʼязково корисним. А JSON-тест відповідає на дуже пряме запитання: «Цей DTO перетворюється в JSON як треба? І назад читається як треба?».
По-третє, діагностика. Уявіть падіння тесту: очікували 200 OK, отримали 400. Що це? Контракт зламався? Спрацювала валідація? Десеріалізація не прочитала enum? У великому тесті вам доводиться розплутувати клубок причин. У вузькому JSON-тесті причина майже завжди локальна: поле, формат, enum або null-handling.
І є ще один психологічний ефект. Великі тести часто дають хибне відчуття безпеки: «Ну в нас же є integration tests, значить усе стабільно». А потім раптово виявляється, що integration tests були тільки для позитивного сценарію й узагалі не перевіряли, що publishedAt віддається саме в ISO-форматі, а errorCode не перейменували в code.
Саме тому ми ставимося до JSON‑контракту як до окремої полиці в стратегії тестування, а не як до побічного ефекту інших перевірок.
3. Типові поломки JSON‑контракту
Якщо подивитися на реальні регресії в API, то JSON ламається не «тому, що JSON поганий», а тому, що зміни часто виглядають маленькими й невинними. У Java ви трохи підправили поле, а назовні пішов інший контракт, і клієнт на іншому кінці дроту вже плаче в логах.
Нижче — компактна таблиця, яка показує типові класи поломок і чому окремі JSON-тести корисні:
| Що змінили | Як це виглядає в JSON | Чому клієнту боляче | Чому вузький JSON-тест ловить краще |
|---|---|---|---|
| Перейменували поле | status → state | Клієнт шукає стару назву й отримує null/помилку | Тест цілиться прямо в поле й падає миттєво |
| Поле «зникло» | поле більше не серіалізується | Клієнт втрачає дані або ламається десеріалізація | Тест фіксує наявність ключа або структури |
| Enum-значення змінилося | "PUBLISHED" → "Published" | Клієнт часто очікує точні значення | Вузький тест перевіряє точне значення рядкового контракту |
| Формат дати змінився | ISO → локальний формат | Клієнт не може розпарсити дату | JSON-тест фіксує рядковий формат |
| null-поведінка змінилася | поле стало відсутнім замість null або навпаки | Клієнтська логіка часто розрізняє missing і null | Тест фіксує очікувану форму |
| Зламали error payload | errorCode зник або перейменований | Клієнт не розуміє тип помилки програмно | Окремий тест на загальний контракт помилок |
Зверніть увагу: частина змін може взагалі не зачіпати бізнес-логіку. Стаття як публікувалася, так і публікується. Але відповідь назовні вже інша, і для клієнта це несумісна зміна.
У ContentHub ми заздалегідь знаємо зони підвищеного ризику. Наприклад, ApiProblem — наш error payload — і PageResponse — обгортка для сторінок — використовуються в різних місцях і стають «спільними деталями» зовнішнього інтерфейсу. Тому їх особливо небезпечно залишати без прямого захисту: якщо хтось «трохи підправив» поле в загальній помилці, у клієнтів почнуть ламатися не один endpoint, а половина застосунку.
4. ContentHub: дорогі JSON‑контракти
Коли проєкт маленький і живе «для себе», можна дозволити собі хаос: «ну змінили JSON — і гаразд». Але ContentHub за задумом схожий на нормальний backend, у якого є клієнти та ролі (public/editor/admin). Тому контрактні DTO — це не внутрішнє сміття, а публічна частина продукту.
Сьогодні у фокусі чотири контракти, тому що вони або часто використовуються, або задають форму для багатьох відповідей:
Таблиця для орієнтиру:
| Контракт | Напрямок | Сенс для клієнта | Чому критичний |
|---|---|---|---|
| CreateArticleRequest | вхідний JSON → об’єкт | як редактор створює або редагує статтю | помилка в десеріалізації = клієнт не може надіслати запит |
| ArticleDetailsResponse | об’єкт → вихідний JSON | картка статті (поля, статус, дати) | помилка в серіалізації = клієнт не може показати дані |
| ApiProblem | об’єкт → вихідний JSON | єдиний формат помилки | помилка = клієнт не може обробити помилку програмно |
| PageResponse | об’єкт → вихідний JSON | список + метадані сторінки | помилка = клієнт «втрачає» пагінацію й навігацію |
Дуже важливо правильно поставити акцент: у цій лекції ми не обговорюємо, чому в системі виникла помилка ARTICLE_NOT_FOUND або звідки береться статус статті. Це не JSON-рівень. JSON-рівень відповідає за інше: «Якщо помилка вже є, як вона виглядає у відповіді? Які поля там є? Як вони називаються?».
Щоб відчути це руками, досить подивитися на мініприклади очікуваної форми. Не як «ідеальну специфікацію», а як орієнтир того, що для клієнта справді важливо.
{
// Унікальний ідентифікатор статті для маршрутизації/посилань на клієнті
"slug": "spring-testing-basics",
// Машиночитний статус (клієнт часто порівнює точний рядок)
"status": "PUBLISHED",
// Формат дати — частина контракту (зазвичай ISO-8601)
"publishedAt": "2026-03-18T10:15:30Z"
}
Приклад очікуваного фрагмента помилки (умовно):
{
// HTTP-статус помилки як число
"status": 404,
// Машиночитний код помилки (під нього часто пишуть switch/if на фронті)
"errorCode": "ARTICLE_NOT_FOUND",
// Людиночитний опис для відображення
"detail": "Статтю не знайдено"
}
Тут немає жодного рядка про вашу базу даних, сервіси та транзакції. І це нормально. Ми зараз захищаємо «зовнішній вигляд» даних, тобто контракт.
5. Приклади поломок контракту
Зараз буде один навмисно простий приклад. Він «грубий» спеціально: наше завдання — не навчитися красиво порівнювати JSON, а побачити ключову думку: навіть маленька “косметика” — це для клієнта інший API.
Приклад. Перейменування поля — це не косметика
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class JsonContractExamplesTest {
@Test
void fieldRenameBreaksContract() {
// Це «очікування клієнта»: він шукає конкретний ключ `status`
String expected = """
{"status":"PUBLISHED"}
""";
// Це «те, що ми випадково віддали»: ключ перейменували, контракт змінився
String actual = """
{"state":"PUBLISHED"}
""";
// Порівняння навмисно просте: показуємо, що для клієнта це інший API
assertThat(actual).isNotEqualTo(expected);
}
}
Нехай вас не бентежить, що це просто рядки. Сенс у тому, що клієнт, який читає status, раптово перестає бачити це поле. А ви можете навіть не помітити, бо бізнес-логіка не змінювалася, а тести лежали в іншому місці.
Той самий механізм працює і в інших крихких місцях контракту. Формат дати 2026-03-18T10:15:30Z і рядок 18.03.2026 10:15:30 для клієнта не «майже одне й те саме», а дві різні форми даних. Поле errorCode і поле code схожі лише для людини: клієнтський код, який орієнтується на конкретний ключ, побачить два різні API. І окремо важливо розрізняти {"rejectionReason":null} та {}: наявність поля з null і повна відсутність поля часто означають різні сценарії. На рівні рядків це вже видно, а в робочому тесті такі речі зручніше фіксувати вузькими @JsonTest-перевірками та JsonPath — по конкретних полях, форматах і значеннях, а не на око в суцільному тексті.
Цього простого порівняння достатньо як дорожнього знака: воно показує, що навіть невелика «косметика» змінює API.
6. JSON‑шар у стратегії тестів
Коли ми будуємо тестову стратегію, нам важливо не лише «що перевірити», а й «яким видом тесту». JSON‑контракт — ідеальний кандидат на вузький slice-тест: нам не потрібен web-сервер, не потрібна база, не потрібні сервіси. Нам потрібен лише коректно налаштований JSON-мапер і DTO.
Spring Boot прямо підтримує цю ідею: @JsonTest підіймає вже налаштований JSON-мапер і дає test-helpers на кшталт JacksonTester, а також готові помічники AssertJ, які працюють разом із JSONassert і JsonPath. Це означає, що ми можемо тестувати серіалізацію та десеріалізацію швидко й ізольовано, не тягнучи в тест половину застосунку.
Щоб закріпити місце цього шару в голові, зручно уявити таку схему:
flowchart TD
%% Шари йдуть знизу вгору: від чистої логіки до HTTP та інтеграції
A["Юніт-тести: бізнес-правила"] --> B["JSON-тести: DTO ↔ JSON"]
%% JSON-тести перевіряють форму даних, але не підіймають веб-оточення
B --> C["Web/controller-тести: HTTP-границя"]
%% Інтеграційні тести — найважчі, але перевіряють звʼязок компонентів
C --> D["Integration-тести: звʼязка шарів"]
JSON‑шар тут — не «заміна» controller-тестам і не «підготовка» до них, а самостійна перевірка. Він відповідає на запитання: «Якщо DTO уже отримано або створено, чи буде його JSON виглядати так, як очікує клієнт?».
І ще одне важливе обмеження. JSON-тест не повинен перетворюватися на тест бізнес-логіки. Якщо ви зловили себе на думці «а давайте тут перевіримо, що опублікована стаття завжди має publishedAt» — стоп. Це вже бізнес-інваріант, і його місце або в unit-шарі, або в інтеграційному сценарії. JSON-тест має перевіряти форму даних, а не сенс переходів статусів.
7. Типові помилки під час роботи з JSON‑контрактом
Помилка № 1: вважати JSON «просто текстом у відповіді».
Зазвичай це проявляється так: DTO змінюють «за відчуттям», не думаючи про клієнта. Усередині команди це здається нешкідливим рефакторингом: “status звучить дивно, давайте state”. Але для клієнта це несумісна зміна. Лікується простою звичкою: думати про JSON як про публічний контракт і фіксувати його вузькими тестами.
Помилка № 2: намагатися ловити поломки контракту лише великими тестами.
Controller- і integration-тести справді можуть упіймати частину проблем, але ціна у них вища, а причина падіння — менш очевидна. У підсумку контрактні регресії або живуть довше, або діагностика займає більше часу. Правильний шлях — окремі JSON‑тести, які падають швидко й по суті.
Помилка № 3: змішувати в JSON-тестах бізнес-правила та серіалізацію.
Щойно в JSON-тесті зʼявляється логіка «якщо статус такий-то — то publishedAt обовʼязково має бути таким-то», тест починає перевіряти не лише контракт, а й доменні правила. Він стає крихким і починає ламатися з причин, не повʼязаних із JSON. У результаті ви втрачаєте сенс шару: швидкий сигнал саме про контракт.
Помилка № 4: ігнорувати спільні payload-обʼєкти (помилки та сторінки).
Багато хто починає тестувати «красиві» response DTO і забуває про ApiProblem і PageResponse, бо це здається нудною обвʼязкою. На практиці саме такі обвʼязки ламають клієнтів масово: помилка однакова для десятка endpoint-ів, і якщо ви її випадково змінили, ви зламали десяток місць одразу. Тому спільні payload-обʼєкти мають мати власні тести та власний захист.
Помилка № 5: вважати, що «якщо JSON читається людиною, значить усе нормально».
Людина прочитає і «18.03.2026 10:15:30», і «2026-03-18T10:15:30Z». Машина — ні. Клієнтський код зазвичай дуже строгий: він парсить за конкретними правилами. Тому для дат, enum і машиночитних error codes потрібна дисципліна: фіксувати точні значення та формати як частину контракту.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ