JavaRush /Курси /Spring Test /JSON fixtures та golden files

JSON fixtures та golden files

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

1. Fixtures і golden files

Якщо чесно, майже всі JSON-тести ламаються не через складність, а через побутові дрібниці. Спочатку ви записуєте невеликий JSON просто як рядок — і це виглядає зручно. Потім у DTO додається поле, потім ще два, потім зʼявляється вкладений обʼєкт, а потім ви ловите себе на тому, що тест — це вже 40 рядків JSON у лапках, і серед них потрібно помітити, що змінилося одне-єдине імʼя поля. Fixtures і golden files розвʼязують саме цю проблему: вони виносять очікування в окремі файли, які можна нормально читати, порівнювати та переглядати як «документ контракту».

У тестуванні слово fixture загалом означає «підготовлений стан» або «підготовлені дані». Але в межах JSON-тестів нам зручно говорити «JSON fixture» як про файл з очікуваним JSON. А golden file (він же golden master) — це «еталонний знімок» контракту: ми порівнюємо результат серіалізації цілком з еталоном і вважаємо будь-яку відмінність значущою, доки не доведено протилежне.

Давайте порівняємо три типові підходи — не як догму, а як інженерний вибір. Тут немає єдиного правильного стилю, але є стиль, із яким можна жити з тестами місяцями.

Підхід Як виглядає Сильні сторони Слабкі сторони
Inline JSON-рядки в тесті String expected = "{...}" Швидко почати на маленькому JSON Швидко перетворюється на нечитабельне «полотно»
Лише JsonPath-перевірки @.status == "PUBLISHED" Точково, зрозуміло, добрі повідомлення під час падіння Легко пропустити «тихі» зміни поруч: перейменування поля, зникнення поля
Golden file (fixture) isEqualToJson("...json") Фіксує контракт цілком, добре переглядається Потребує дисципліни: файл потрібно оновлювати свідомо

Хитрість у тому, що golden file — це не «заміна всьому», а базова страховка. Він допомагає впіймати тихі зміни, а точкові JsonPath-перевірки — підкреслити найважливіші поля й дати зрозумілішу діагностику. В ідеалі ми використовуємо обидва способи разом, але без фанатизму та без дублювання всього двічі.

2. Зберігання JSON-еталонів у Gradle

Коли ми говоримо «файл лежить у src/test/resources», це звучить як магічне заклинання, але насправді це просто домовленість між Gradle і JVM. Усе, що лежить у src/test/resources, під час збирання потрапляє в test classpath, і тести можуть читати ці файли як ресурси. Зручність у тому, що ресурс можна дістати за шляхом, не залежачи від поточного каталогу запуску. А це часто ламає життя новачкам: «у мене локально працює, а на CI — ні».

У ContentHub ми хочемо, щоб fixtures були «першокласними громадянами» тестового коду. Практично це означає одну зрозумілу точку входу, наприклад теку src/test/resources/json/, а вже всередині — групування за змістом. Можна групувати за DTO, за сценаріями, за «зонами API» (public/editor/admin), але ключовий критерій простий: за шляхом має бути зрозуміло, який саме контракт лежить у цьому файлі.

Ось приклад структури, яка зазвичай добре переживає зростання проєкту:

src/test/resources/
└── json/
    ├── article/
    │   ├── article-details-draft.json
    │   └── article-details-published.json
    ├── page/
    │   ├── public-articles-page.json
    │   └── public-articles-page-empty.json
    └── problem/
        ├── article-not-found-problem.json
        └── validation-problem.json

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

Якщо хочеться переконатися, що ресурс справді доступний у classpath, можна зробити маленьку налагоджувальну перевірку прямо в тесті. Вона не зобовʼязана жити в наборі продакшн-тестів, але добре пояснює механіку:

import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;

import static org.assertj.core.api.Assertions.assertThat;

class ClasspathSanityTest {

    @Test
    void fixtureFileIsOnClasspath() {
        // Перевіряємо, що fixture справді потрапив у test classpath, а не читається «з диска»
        var resource = new ClassPathResource("json/article/article-details-published.json");

        // Якщо ресурс не знайдено, тест упаде тут — це зручний індикатор проблем зі структурою ресурсів
        assertThat(resource.exists()).isTrue();
    }
}

Цей приклад показує важливу думку: ми не читаємо файли «з файлової системи», ми читаємо ресурси. У світі JVM це стабільніший шлях, і тести від цього стають відтворюванішими.

3. Golden file у @JsonTest

Golden file підхід особливо приємний тим, що тест перестає бути «архівом тексту» і знову стає тестом. У тесті ви читаєте сценарій: «беремо DTO → серіалізуємо → порівнюємо з еталоном». А сам еталон лежить окремо, у форматі JSON, який можна відкрити, відформатувати й переглянути як звичайний файл. Плюс diff у Git по JSON зазвичай значно зрозуміліший, ніж diff по Java-рядку з екранованими лапками.

Схематично це виглядає так:

flowchart TD
    A[DTO-обʼєкт] -->|JacksonTester.write| B[JSON на виході]
    C[Еталонний fixture-файл .json] --> D{Порівняння JSON}
    B --> D
    D -->|збіглося| E[Тест зелений]
    D -->|відрізняється| F[Тест червоний + diff]

Тепер — мінімальний, «людський» приклад на ArticleDetailsResponse. Зверніть увагу: тест не намагається бізнес-логікою довести, що стаття опублікована. Він доводить форму JSON, яку отримає клієнт.

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.JsonTest;
import org.springframework.boot.test.json.JacksonTester;

import static org.assertj.core.api.Assertions.assertThat;

@JsonTest
class ArticleDetailsResponseJsonTest {

    @Autowired
    private JacksonTester<ArticleDetailsResponse> json; // Інструмент для серіалізації/десеріалізації в JSON-тестах

    @Test
    void writesPublishedArticle_usingGoldenFile() throws Exception {
        // Готуємо DTO з детермінованими даними — тестуємо контракт, а не генератори часу чи ID
        ArticleDetailsResponse response = samplePublishedArticleResponse();

        // Серіалізуємо DTO і порівнюємо результат цілком з еталонним JSON-файлом (golden file)
        assertThat(json.write(response))
                .isEqualToJson("json/article/article-details-published.json");
    }
}

Сам fixture-файл може виглядати приблизно так — і так, його приємно читати:

{
  "slug": "spring-testing-basics",
  "title": "Spring Testing Basics",
  "status": "PUBLISHED",
  "publishedAt": "2026-03-18T10:15:30Z"
}

Порядок полів у JSON як стандарту зазвичай неважливий, але для читання він важливий психологічно. Тому просто домовтеся про стиль форматування, наприклад 2 пробіли, перенесення рядків і зрозумілий порядок полів, — і дотримуйтеся його. Це не «краса заради краси», а зменшення шуму в diffʼах.

4. Сценарні fixtures: один файл — один зміст

Є два типи поганих fixture-наборів. Перший — це коли на кожен DTO заводять один-єдиний expected.json, а потім намагаються використовувати його для всіх статусів, усіх гілок і всіх випадків. Другий — це коли fixtures розмножуються хаотично: expected2.json, expected-final.json, expected-final-final.json — і десь у кутку тихо плаче Git. Обидва варіанти вбивають сенс еталонів: замість «знімка контракту» ви отримуєте «кладовище спроб».

Здорова модель простіша: один fixture — один сценарій. Сценарій має бути читабельним із назви файла без здогадок. Для ArticleDetailsResponse сценарії в ContentHub природно привʼязані до статусу статті, тому що статус впливає на набір полів і на null-поведінку. Для ApiProblem сценарії привʼязані до errorCode і наявності violations. Для PageResponse — до порожньої або непорожньої сторінки та до метаданих.

Нижче — приклад «карти» fixtures у форматі таблиці. Це не список для галочки, а спосіб мислити: який контракт ми фіксуємо і чому він окремий.

Контракт Сценарій Приклад fixture-файла
ArticleDetailsResponse опублікована стаття json/article/article-details-published.json
ArticleDetailsResponse чернетка (немає publishedAt) json/article/article-details-draft.json
ApiProblem статтю не знайдено json/problem/article-not-found-problem.json
ApiProblem помилка валідації (є violations) json/problem/validation-problem.json
PageResponse сторінка зі статтями json/page/public-articles-page.json
PageResponse порожня сторінка json/page/public-articles-page-empty.json

Головне — не намагайтеся запхати весь світ в один файл. У тестах краще 2–3 невеликі fixtures, ніж один величезний «універсальний». Маленькі файли простіше оновлювати, простіше розуміти й простіше переглядати. І, що приємно, під час падіння тесту ви одразу розумієте, який сценарій зламався.

Комбінація: golden file + JsonPath

Golden file добре ловить «тихі» зміни, але інколи він дає занадто загальний сигнал: «JSON відрізняється». Так, він покаже diff, але студенту — і майбутньому вам через місяць — часто корисно мати одну-дві точкові перевірки, які підкреслюють сенс тесту. Це як коментувати код: не все підряд, а те, що справді пояснює намір.

Є ще одна причина комбінувати. Іноді ми свідомо не хочемо фіксувати все цілком, тому що в контракті є поле, яке в межах цього тесту нестабільне або неважливе. Тоді golden file може бути «занадто строгим», і точкові перевірки дають більш керований фокус.

Практичний компроміс виглядає так: спочатку порівнюємо з fixture цілком, потім робимо 1–2 JsonPath-перевірки на найважливіші поля. Зверніть увагу: ми не перевіряємо все вдруге — це б перетворилося на дублювання.

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

@Test
void writesPublishedArticle_contractAndMeaning() throws Exception {
    ArticleDetailsResponse response = samplePublishedArticleResponse();

    // Пишемо JSON один раз і повторно використовуємо результат у кількох перевірках
    var content = json.write(response);

    // Базова страховка: контракт цілком має збігтися з fixture-файлом
    assertThat(content).isEqualToJson("json/article/article-details-published.json");

    // Точкова перевірка «сенсу»: підкреслюємо ключове поле, заради якого взагалі потрібен цей сценарій
    assertThat(content).extractingJsonPathStringValue("@.status")
            .isEqualTo("PUBLISHED");
}

Тут content — це один результат серіалізації. Ми не серіалізуємо обʼєкт двічі, і тест читається як сценарій: «фіксуємо контракт цілком» і «явно підкреслюємо ключове бізнес-значення, яке клієнт читатиме машинно».

Той самий підхід добре працює і для PageResponse, де інколи важливо підкреслити, що метадані справді числа, а не рядки. Це буває неочікувано корисно, коли хтось «покращив» DTO і все перетворилося на рядки заради «зручності фронтенду».

5. Строгість порівняння JSON і контракт

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

У Spring Boot-середовищі порівняння JSON зазвичай ґрунтується на логіці на кшталт JSONassert: порівнюються структури JSON-обʼєктів, порядок полів в обʼєкті неважливий, але порядок елементів у масиві може бути важливим, залежно від режиму. І ось тут починається інженерія: якщо для вашого контракту порядок масиву є частиною обіцянки API, тоді golden file має фіксувати порядок. Якщо порядок не обіцяється, тоді не треба будувати тести так, ніби він обіцяється.

Щоб це не звучало абстрактно, уявіть violations у ApiProblem. Клієнту зазвичай важлива наявність конкретних порушень, але порядок порушень рідко є контрактом. Тому для такого поля або обирають мʼякший режим порівняння, або перевіряють масив точково, наприклад що він містить потрібні елементи. Ми не йдемо сьогодні в складні матчери масивів, але важливо зафіксувати думку: строгість порівняння має відповідати обіцянкам контракту.

Якщо ваш JsonContentAssert/JacksonTester підтримує режими порівняння, можна зробити це явно. Наприклад, для «контрактних» payloadʼів можна використовувати строгий режим:

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.json.JsonCompareMode;

import static org.assertj.core.api.Assertions.assertThat;

@Test
void writesProblem_strictContract() throws Exception {
    ApiProblem problem = sampleArticleNotFoundProblem();

    // STRICT: будь-які відмінності, зокрема зайві або відсутні поля, вважаємо порушенням контракту
    assertThat(json.write(problem))
            .isEqualToJson("json/problem/article-not-found-problem.json", JsonCompareMode.STRICT);
}

Якщо у вашому проєкті виявиться, що строгий режим занадто часто дає шум саме через порядок масивів, це не привід відмовитися від fixtures взагалі. Це привід або зробити порядок детермінованим у тестових даних, або вибрати режим порівняння, який відповідає реальним очікуванням клієнтів. Важливо, що рішення приймається свідомо, а не «аби зелено».

6. Нестабільні поля в golden files

Golden files працюють найкраще там, де дані детерміновані. А в реальному застосунку дуже багато недетермінізму: поточний час, випадкові ідентифікатори, автогенерація slug, URI в полі instance, значення, що залежать від оточення. І тут легко зробити два неправильні рухи. Перший — намагатися «заморозити» весь світ: мокати все підряд навіть у JSON-тестах. Другий — махнути рукою й піти в перевірки на кшталт not null, які майже нічого не доводять.

Правильне рішення зазвичай простіше: у JSON-тестах ми не зобовʼязані відтворювати реальні генератори ID і часу. Ми тестуємо контракт DTO, а отже можемо створити DTO із заздалегідь заданими значеннями. Це не «обман», а свідоме спрощення: ми фіксуємо формат поля, імʼя поля, null-поведінку, enum-значення, а не перевіряємо генерацію цих значень.

Наприклад, якщо в ArticleDetailsResponse є publishedAt, то для fixture нам вигідно використовувати фіксований Instant, який ви вже бачили раніше і який чудово читається очима:

import java.time.Instant;

private static Instant fixedPublishedAt() {
    // Фіксуємо час, щоб fixture не залежав від годинників і «погоди» в CI
    return Instant.parse("2026-03-18T10:15:30Z");
}

Так само з instance у ApiProblem. Якщо у вашому дизайні instance має бути URI запиту, то в JSON-тесті ви або задаєте його фіксованим значенням, наприклад "/api/public/articles/spring-testing-basics", або не використовуєте golden file для повного порівняння саме цього сценарію, а фіксуєте лише несучі поля (status, errorCode, title, violations). Важливо не «ховати проблему», а чесно вибрати: що є частиною контракту, а що є «даними конкретного запиту».

Коли ви відчуваєте спокусу написати fixture з полем на кшталт "id": 123456789 і потім дивуватися, що воно «інколи не сходиться», зупиніться й запитайте себе: а звідки взагалі в JSON-тесті береться це значення? Якщо ви самі створюєте DTO, то воно завжди буде таким, яким ви його задали. Якщо воно зʼявляється «само», значить ви вже тестуєте не лише DTO, а якийсь шматок бізнес-логіки або інфраструктури. А це нам сьогодні не потрібно.

І ще одна побутова порада. Якщо ви бачите, що fixture починає ламатися через зміну форматування, наприклад хтось змінив pretty print, то це часто сигнал, що ви порівнюєте JSON як рядки, а не як JSON-структури. Golden file підхід хороший саме тим, що зазвичай порівняння йде за структурою JSON, і форматування не повинно мати значення. В ідеалі різниця має зʼявлятися лише тоді, коли змінюється структура або значення контракту.

Коли JSON-форма вже зафіксована fixture-файлами та точковими JsonPath-перевірками, контролерному тесту не потрібно заново доводити серіалізацію кожного DTO. Там важливіші HTTP-статуси, заголовки, маршрутизація, валідація і поведінка веб-границі. І це не дає @WebMvcTest перетворитися на повтор @JsonTest.

7. Типові помилки під час роботи з fixtures і golden files

Помилка №1: тримати очікування в тесті у вигляді величезного рядка «на 200 рядків».
Перші кілька днів здається, що так швидше. Через тиждень ви отримуєте тест, який неможливо читати, бо він більше схожий на мініфайл JSON, але без підсвітки, без форматування та з екранованими лапками. Golden file повертає тесту читабельність: тест знову описує сценарій, а JSON живе там, де йому й місце — у .json.

Помилка №2: один «універсальний» fixture на всі випадки.
Коли ArticleDetailsResponse може бути і для DRAFT, і для PUBLISHED, і для REJECTED, один спільний еталон перетворюється на компроміс, який не описує жоден сценарій чесно. У результаті тест або постійно ламається від «зайвих» полів, або стає занадто мʼяким і перестає захищати контракт. Робіть сценарні файли: один файл — один зміст.

Помилка №3: оновлювати golden file за принципом «аби тест став зеленим».
Це найпідступніший анти-патерн. Golden file — не кнопка «заглушити тест». Це контракт. Якщо тест упав, ви спочатку зʼясовуєте, що змінилося і чому. І лише якщо зміна усвідомлена і справді має потрапити в API, ви оновлюєте fixture. Інакше ви перетворюєте тест на ритуал: «оновив еталон — значить, усе добре», хоча насправді могли поховати регресію.

Помилка №4: намагатися тестувати через golden files недетерміновані поля, не стабілізувавши дані.
Якщо ви в sample-обʼєкті використовуєте Instant.now() або UUID.randomUUID(), fixtures будуть ламатися «залежно від погоди». Це не проблема Jackson і не проблема @JsonTest — це проблема тестових даних. У JSON-тестах задавайте фіксовані значення: конкретний Instant.parse(...), конкретний id, конкретний slug. JSON-тести — про форму, а не про генерацію.

Помилка №5: дублювати перевірки: і повний golden file, і десятки JsonPath на кожне поле.
Такий тест стає дорогим для підтримки: будь-яка зміна контракту вимагає правити і fixture, і десять перевірок. В ідеалі golden file фіксує більшу частину, а JsonPath підкреслює 1–2 ключові місця, наприклад status, errorCode або важливу дату. Це робить тест одночасно строгим і читабельним.

1
Задача
Spring Test, 8 рівень, 4 лекція
Недоступна
Golden file для DTO відповіді
Golden file для DTO відповіді
1
Задача
Spring Test, 8 рівень, 4 лекція
Недоступна
Два сценарні fixture-файли для page response
Два сценарні fixture-файли для page response
1
Опитування
JSON-контракт, рівень 8, лекція 4
Недоступний
JSON-контракт
Тестування відповідей і полів
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ