JavaRush /Курси /Spring Test /Схема БД у data-layer-тестах

Схема БД у data-layer-тестах

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

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 з’явився, то будь-який наступний тест стає менш передбачуваним: один тест падає «дивно», інший «випадково проходить», третій перестає бути відтворюваним. Це той рідкісний випадок, коли «потім» часто перетворюється на «ніколи», а ціна зростає, як підписка на стримінг щороку.

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