JavaRush /Курси /Spring Test /JSON‑контракт не можна ховати в тестах

JSON‑контракт не можна ховати в тестах

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

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-тест ловить краще
Перейменували поле statusstate Клієнт шукає стару назву й отримує 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 потрібна дисципліна: фіксувати точні значення та формати як частину контракту.

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