1. Схема БД у data-layer-тестах
Коли ми чуємо «repository-тест», мозок іноді малює наївну картинку: «я перевіряю метод репозиторія, отже тестую лише Java-код». Але data-layer — це шар, який фізично живе на межі двох світів: світу об’єктів (Article, Category) і світу таблиць, колонок та обмежень. І тест, навіть найменший, неминуче проходить через цю межу.
У звичайних модульних тестах ми могли б сказати: «Ну, не підходить — підмінимо моками». У data-layer так не працює: репозиторій — це і є «розмова з базою». Якщо бази в тесті немає або якщо її схема не відповідає очікуванням, репозиторій стає як дипломат без перекладача: він ніби прийшов на зустріч, але домовитися не здатен.
У проєкті ContentHub це особливо помітно, тому що в нас є таблиці й пов’язані сутності articles, categories, article_attachments, є унікальність slug, є поля NOT NULL, є зв’язки та зовнішні ключі. Будь-який repository-тест спирається на те, що ці таблиці справді існують і влаштовані так, як ми думаємо. І ось тут відбувається важливий методичний поворот: схема БД — не «інфраструктура десь поруч», а частина контракту, який ми зобов’язані перевіряти тестами.
Схема БД простими словами
Слово «схема» іноді звучить як щось зі світу дорослих: DBA, діаграми, нудьга, кава без цукру. Насправді схема — це просто формалізований опис того, як база зберігає дані. Якщо спростити максимально, схема відповідає на запитання: «які коробки в нас є, які в них відділення і які правила зберігання».
Таблиця — це коробка. Колонка — відділення в коробці. Тип колонки — це розмір і форма відділення, у яке можна покласти Instant, а в яке — лише число. Обмеження (NOT NULL, UNIQUE, FOREIGN KEY) — це правила складу: сюди не можна класти порожню коробку, тут не можна зберігати два однакові slug, а ось цей запис не може посилатися на категорію, якої не існує.
Чому це важливо саме для тестів? Тому що JPA-сутність — це не схема. Сутність — це наша «версія реальності» на боці Java. А схема — це реальність на боці БД. І якщо ці дві реальності починають розходитись, data-тести стають тим самим моментом істини, коли застосунок перестає вдавати, що «все нормально».
Repository-тест як контракт Java та SQL
Дуже корисно навчитися дивитися на data-layer-тест як на перевірку контракту між трьома речами: вашим Java-кодом (entities), вашим ORM-мапінгом (анотації @Entity, @Column, @ManyToOne) та фактичною SQL-схемою (таблиці, колонки, constraints). Репозиторій сидить зверху й здається «просто інтерфейсом», але фактично він працює лише тоді, коли весь цей трикутник збігається.
Щоб це відчувалося не як філософія, а як інженерна реальність, уявімо шлях будь-якого @DataJpaTest у спрощеному вигляді:
flowchart TD
A["JUnit 6 запускає тест"] --> B["Підіймається зріз Spring Data JPA"]
B --> C["Створюється DataSource і підключення до БД"]
C --> D["Схема БД має існувати і збігатися з очікуваннями"]
D --> E["Hibernate/JPA мапить сутності на таблиці/колонки"]
E --> F["Repository виконує запити / persist"]
F --> G["Assertions: перевіряємо результат"]
Тут ключовий вузол — пункт про схему. Якщо схема не піднялася або піднялася не так, як треба, усе, що нижче, може навіть не початися. Тест може впасти «на вході», ще до виконання вашого методу @Test. І саме це місце найчастіше дивує новачків: «Чому мій тест навіть не почав виконуватися?!». Відповідь проста: для data-layer-тесту «запуск оточення» — уже частина перевірки.
Два типи поломок: запит і схема
Найкорисніша практична звичка сьогодні — навчитися розрізняти два класи проблем. Ззовні вони обидва виглядають як «тест упав», але діагноз і лікування в них різні. Один клас проблем означає, що наш Java/ORM-код неправильно звертається до бази. Другий — що база, тобто її структура, не відповідає тому, що ми про неї думаємо.
Нижче — табличка, яку зручно тримати в голові як міні-«рентген» падінь data-тестів:
| Що зламалося | Де зазвичай падає | Як це виглядає | Типова причина |
|---|---|---|---|
| Запит / мапінг | Під час виконання persistAndFlush(), find...(), repository.save() або конкретного запиту | Тест стартував, дійшов до методу, а потім упав | Неправильний JPQL, неправильний @JoinColumn, не той fetch, конфлікт типів, неправильне сортування |
| Схема | Під час підняття тестового контексту або вже на першому flush (коли БД починає перевіряти реальність) | «ApplicationContext не вдалося запустити» або «relation/column не знайдено» | Таблиці, колонки або constraints не створено, не збігся порядок змін, не збіглися імʼя чи тип колонки |
Зверніть увагу на тонкість: помилка схеми може проявитися і пізніше, під час flush. Наприклад, контекст піднявся, бо таблиці є, але конкретної колонки немає — і ви побачите це лише тоді, коли вперше спробуєте щось туди записати. Тому ми так часто в data-тестах використовуємо persistAndFlush: він змушує базу «сказати правду» раніше, ніж ми встигнемо побудувати хибну впевненість.
Schema drift у ContentHub
Schema drift — це красиве словосполучення, яке на практиці означає дуже буденну історію: «ми змінили код, але забули змінити базу» або «ми змінили базу, але забули змінити код». І так, це трапляється навіть у сильних команд, тому що це не питання інтелекту, а питання дисципліни та процесу.
Уявімо типовий день розробника. Він вирішив, що в Article потрібно зберігати дату публікації в колонці published_at. У Java він додав поле publishedAt і, можливо, навіть акуратно підписав @Column(name = "published_at"). Тести компілюються, IDE зелена, настрій хороший, життя прекрасне. А потім у репозиторії з’являється новий data-тест, який зберігає опубліковану статтю… і база раптово повідомляє: «яка ще published_at?».
У ContentHub drift особливо підступний, тому що в нас є важливі інваріанти в базі. slug має бути унікальним. title, summary, body та category є обов’язковими. Вкладення мають посилатися на статтю. Якщо міграція не додала unique constraint, тести можуть «випадково проходити» на невеликих даних, а потім у production база прийме два однакові slug — і ви отримаєте баг рівня «чому відкривається не та публічна стаття?». Якщо, навпаки, unique constraint є, а в Java-коді slug генерується без урахування колізій, база чесно почне падати — і добре, що це станеться в тестах, а не в користувачів.
Schema drift — це не «внутрішня проблема БД». Це продуктовий дефект data-layer. І data-тести потрібні не лише для того, щоб сказати «метод репозиторія працює», а для того, щоб сказати «наш data-layer узагалі живе в злагоді з тим, як влаштована база».
Падіння тесту на старті як сигнал
У новачка є природна реакція: якщо тест упав до виконання @Test-методу, отже «JUnit не запустився» або «щось зламалося в оточенні». Хочеться швидко вимкнути щось, поставити заглушку, вимкнути міграції, аби лише дійшло до «справжніх» assertions. Але в data-layer ця реакція — пастка.
Подумайте про це так: якщо data-slice-контекст не може піднятися через схему, це означає, що в реальному житті застосунок теж не зможе нормально працювати з базою. Так, production-оточення може відрізнятися, але сама ідея така: якщо таблиці, колонки або constraints не збігаються з очікуваннями, repository-шар не має шансів бути коректним.
Тому падіння на старті — це не шум. Це тест, який каже: «я навіть не почав перевіряти вашу бізнес-ідею, бо фундамент відсутній». Це приблизно як намагатися перевірити, чи зручно вам сидіти на стільці, коли стілець ще не зібрано і він лежить купою деталей. І ні, це не проблема тесту. Це проблема «стілець не зібрано».
У зрілій тестовій культурі такі падіння сприймаються як «швидко впіймали критичний дефект» — і це привід радіти, а не дратуватися. Тест заощадив вам час, нерви та кілька дописів у корпоративному чаті у стилі «у кого впало, у мене впало, а чому впало?».
2. Приклади на ContentHub
CategorySchemaTest і «не та колонка»
Зараз буде два короткі приклади, які спеціально виглядають майже смішно простими. Це зроблено навмисно: ми хочемо побачити, що навіть най«плоскіший» data-тест на одну сутність насправді залежить від схеми БД. Не від бізнес-логіки, не від контролера, а саме від таблиці, колонок і їхньої коректності.
Перший приклад — перевіряємо, що Category справді можна зберегти. Це не «ми тестуємо persist як фреймворк» (ми не фанати тестувати Spring Data заради Spring Data), а «ми перевіряємо, що в нас існує таблиця категорій і вона сумісна з мапінгом».
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest // Піднімаємо лише JPA-зріз контексту: DataSource, EntityManager, репозиторії тощо.
class CategorySchemaTest {
@Autowired
private TestEntityManager em; // Спрощений доступ до persist/flush у тестах
@Test
void shouldPersistCategoryWhenSchemaMatches() {
Category category = new Category();
// Важливо заповнювати поля, які в схемі позначені як обовʼязкові (NOT NULL)
category.setCode("java");
category.setName("Java");
// persistAndFlush змушує базу «сказати правду» одразу: перевіряються таблиці/колонки/constraints
em.persistAndFlush(category);
// Якщо id зʼявився — отже insert пройшов і схема не конфліктує з мапінгом
assertThat(category.getId()).isNotNull();
}
}
Тут важливо не те, що id став not null (хоча це теж корисно). Важливо те, що тест узагалі зміг виконати persistAndFlush(). Він уже довів: таблиця існує, колонки існують, типи колонок влаштовують БД, обмеження не суперечать даним. Інакше кажучи — схема й мапінг не посварилися.
Другий приклад — мікроскопічний фрагмент entity. Він показує місце, де drift найчастіше проявляється: невідповідність імені колонки.
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.time.Instant;
@Entity // Ця сутність має відповідати реальній таблиці в БД
class Article {
@Id
private Long id; // Ідентифікатор: очікуємо, що в таблиці є первинний ключ (або принаймні колонка для нього)
@Column(name = "published_at") // Імʼя колонки має 1-в-1 збігатися з тим, що справді створено міграціями
private Instant publishedAt; // Тип теж важливий: наприклад, для Instant зазвичай очікуємо timestamptz/timestamp
}
Тепер уявіть, що в реальній схемі колонка називається не published_at, а, скажімо, publishedAt або published_time. Для бази це абсолютно різні імена. Підсумок зазвичай дуже «дружній»: щось на кшталт «column not found» або «unknown column». І це не баг тесту. Це баг розсинхронізації.
Щоб відчути, що таке «схема — джерело правди», корисно побачити очима й SQL-уривок (навіть якщо ви поки що не любите SQL — нічого, він звикне). Такий фрагмент схеми міг би виглядати так:
create table articles (
id bigserial primary key, -- PK та автогенерація id (приблизно те, чого очікує багато мапінгів)
published_at timestamptz -- імʼя й тип мають збігатися з @Column і типом поля
);
Якщо в SQL немає published_at, а в entity він є, або навпаки, data-тести рано чи пізно це проявлять. І це, насправді, одна з головних цінностей набору data-layer-тестів: він працює як сигналізація від «ми вже розʼїхалися, і це починає бути небезпечно».
3. Типові помилки під час перевірки схеми
Помилка №1: вважати схему нечастиною тесту й намагатися лагодити падіння лише на рівні assertions.
Коли тест падає через відсутню таблицю або колонку, у нього немає шансів «пройти після правки assertThat». Це все одно що переклеювати наклейку «все добре» на панелі приладів, коли двигун димить. Правильна звичка тут — завжди ставити собі запитання: тест упав у момент виконання методу чи ще під час старту контексту? Якщо під час старту, це дуже часто історія про схему, а не про вашу перевірку.
Помилка №2: «тимчасово» увімкнути автогенерацію схеми Hibernate, щоб тести проходили, і забути про це.
Це особливо підступно. Ви вмикаєте автоматичне створення таблиць за entity, і тести справді починають «радісно зеленіти». Але ви щойно перестали тестувати реальну схему, яка живе через міграції. У результаті ви перевіряєте абстрактний світ «як би зробив Hibernate», а не реальний світ «як база справді влаштована». Data-layer suite перетворюється на гарний настрій без доказів.
Помилка №3: плутати «зламалася схема» і «зламався запит», а потім діагностувати не те.
Якщо проблема в схемі, ви можете годинами читати JPQL-запит і шукати зайву кому, хоча запит узагалі не дійшов до виконання: база не піднялася або таблиця не існує. І навпаки, якщо схема коректна, але query неправильний, можна нескінченно перескладати міграції, хоча проблема в логіці вибірки. Корисна дисципліна — спочатку зрозуміти, на якій стадії все впало: старт контексту, flush, виконання query чи assertion.
Помилка №4: думати, що «таблиці десь є», і не робити flush, бо «і так працює».
Без flush ви ризикуєте перевіряти не базу, а persistence context. Managed-об’єкт у пам’яті може виглядати ідеально, навіть якщо база під час запису впала б на constraint. persistAndFlush звучить трохи страшніше, але він чесніший: він змушує базу брати участь у розмові одразу, а не наприкінці, коли ви вже побудували пів тесту на піску.
Помилка №5: ставитися до schema drift як до дрібниці й відкладати виправлення «на потім».
Data-layer — це фундамент. Якщо drift з’явився, то будь-який наступний тест стає менш передбачуваним: один тест падає «дивно», інший «випадково проходить», третій перестає бути відтворюваним. Це той рідкісний випадок, коли «потім» часто перетворюється на «ніколи», а ціна зростає, як підписка на стримінг щороку.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ