1. Flyway у data-тестах: схема і дисципліна
Якщо ви хоч раз переїжджали, то знаєте: найнеприємніший момент — коли коробки підписані «різне» і «важливе». Із базою даних — те саме: коли схема «якось зʼявилася», і ніхто точно не розуміє, звідки взялися таблиці та обмеження, тести починають жити на удачу. Flyway у data-тестах потрібен саме для того, щоб схема піднімалася передбачувано: із версіонованих SQL-міграцій, а не з випадкових побічних ефектів.
У проєкті ContentHub ми тестуємо репозиторії для Article, Category, ArticleAttachment. І навіть найпростіший тест, який зберігає категорію та зчитує її назад, передбачає, що в базі вже є таблиця categories, у ній є колонки потрібних типів, а обмеження працюють так, як ми очікуємо. Flyway перетворює «передбачається» на «задокументовано міграціями».
Важлива думка: Flyway — це не лише «для продакшну». У тестах він так само цінний, тому що робить початковий стан бази повторюваним. Саме на цьому місці зникає половина загадкових проблем на кшталт «у мене локально все зелено, а в CI — червоно». Не вся, бо друга половина — про час, оточення та flaky-тести, але це вже помітна частина.
Щоб закріпити mental model, можна уявити старт @DataJpaTest так:
flowchart TD
%% Спрощена схема: що відбувається ДО виконання @Test
A["Початок тесту (@DataJpaTest)"] --> B["Створюється DataSource (тестова БД)"]
B --> C["Flyway застосовує міграції"]
C --> D["JPA/Hibernate починає працювати з уже готовою схемою"]
D --> E["Виконується метод @Test"]
Якщо Flyway не зміг зібрати схему, тест не провалився — він просто не стартував. І це нормально: ви не можете перевіряти репозиторій проти схеми, якої немає.
2. Запуск Flyway у @DataJpaTest
У тестах на Spring Boot багато речей виглядають як магія: «я написав анотацію — і воно само». Але магія підступна: поки все зелено, вона здається зручною, а коли червоно — перетворюється на квест «знайди причину серед сотні рядків винятку». Тому зараз ми акуратно проговоримо, що саме відбувається під час старту @DataJpaTest у проєкті, де на classpath є Flyway.
Коли JUnit запускає тест, Spring TestContext починає піднімати ApplicationContext. Для @DataJpaTest цей контекст вузький: лише data-частина. Але він усе одно вмикає авто-конфігурації, які створюють DataSource, налаштовують JPA, транзакції та — якщо Flyway увімкнено — виконують міграції. І ось ключовий момент: міграції виконуються до того, як ви дійдете до методу @Test. Тому ситуація «тест падає на старті» найчастіше означає не помилку у ваших перевірках, а проблему на рівні схеми чи міграцій.
Це добре видно на прикладі «тесту, який не встигне початися». Ми можемо навіть не писати тіло тесту — і все одно отримати падіння, якщо міграція зламана. Для розуміння корисний мінімальний клас:
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
// Слайс-тест: піднімається лише data-шар, але міграції Flyway усе одно виконуються на старті контексту.
@DataJpaTest
class ContentHubDataJpaSmokeTest {
// Якщо Flyway впаде на міграції — цей тестовий клас «червоний» ще до @Test.
}
Сам по собі це не «корисний тест», але чудова ілюстрація: Flyway — частина процесу старту слайс-контексту.
Тут є ще один практичний висновок. Якщо ви часто робите різні @DataJpaTest(properties = ...) з різними spring.flyway.locations, ви змушуєте Spring створювати кілька різних контекстів, а набір тестів починає помітно гальмувати. Ми ще раніше говорили про кешування контекстів: це та сама причина, чому конфігурацію Flyway вигідно тримати однаковою через application-test.yml і @ActiveProfiles("test"), а не робити «у кожного тесту свій маленький всесвіт».
3. Імена міграцій: V<VERSION>__<NAME>.sql
Коли мова заходить про домовленості, у розробників часто вмикається внутрішній бунтар: «ну я ж і так розумію, що це міграція». Проблема в тому, що міграції читає не лише ви сьогоднішній, а й ви через три місяці, який уже не памʼятає, навіщо це поле додавали, а ще колеги, CI та сам Flyway. Тому формат імені файлу — це не косметика, а спосіб задати порядок застосування й сенс зміни.
Flyway у типовому режимі очікує імʼя міграції формату:
V<VERSION>__<DESCRIPTION>.sql
Два підкреслення тут не тому, що хтось дуже любить нижнє підкреслення, а тому, що так Flyway відділяє версію від опису, а опис може містити підкреслення, слова й бути читабельним для людини.
Для ContentHub добре виглядають такі приклади:
| Файл міграції | Що робить | Чому це важливо для тестів |
|---|---|---|
| V1__create_categories.sql | створює таблицю categories | репозиторії та зв’язки залежать від неї |
| V2__create_articles.sql | створює таблицю articles | тести ArticleRepository без цього не мають сенсу |
| V3__create_article_attachments.sql | створює article_attachments | сценарії з вкладеннями потребують окремої таблиці |
| V4__add_unique_slug_constraint.sql | додає унікальне обмеження для slug | тести на унікальність стають коректними |
Зверніть увагу на тонкість: міграція на кшталт «add constraint» часто буває окремою. Так, можна було б помістити все в create_articles.sql. Але окремий файл допомагає побачити історію змін схеми й спрощує діагностику: якщо впав constraint, ви знатимете, де його додавали.
Невеликий приклад вмісту міграції — сильно спрощений, але змістовний:
-- V1__create_categories.sql
-- Мінімальна таблиця для Category: важливо, щоб назви колонок збігалися з очікуваннями JPA-мепінгу.
create table categories (
id bigserial primary key,
code varchar(50) not null,
name varchar(200) not null
);
Якщо у вашому entity Category анотації очікують code і name, а міграція створює колонку category_code, ви отримуєте ту саму невідповідність схеми, про яку говорили в минулій лекції. Flyway робить цю невідповідність видимою: схема створюється з SQL, і JPA вже мусить або збігтися з нею, або впасти.
Ще один маленький нюанс про версії. Flyway порівнює версії як числа або сегменти (1, 2, 10, 1.1 тощо), а не просто як рядок. Але людям усе одно простіше, коли версії ростуть лінійно та передбачувано. Тому в навчальному проєкті краще тримати просту послідовність: V1, V2, V3, без екзотики.
4. Шляхи міграцій: db/migration і locations
У цей момент зазвичай виникає питання: «Окей, з іменами зрозуміло. А куди складати ці файли?» Відповідь складається з двох частин: є дефолт Flyway, і є керування цим дефолтом через налаштування. Важливо розуміти обидва варіанти, тому що в тестах у нас зʼявляється додатковий шар — test resources, які теж потрапляють у classpath.
За замовчуванням Flyway шукає міграції в classpath:db/migration. У термінах Gradle/Spring Boot це означає, що файли з src/main/resources/db/migration опиняться там у classpath, і Flyway їх побачить.
У тестах classpath включає ще й src/test/resources. Тобто ви потенційно можете покласти тестові міграції туди ж шляхом db/migration — і Flyway теж їх побачить. Ми детально обговоримо, які міграції мають право бути лише для тестів, трохи пізніше; зараз нам важливо вловити механізм: classpath у тестах багатший, ніж у production.
Якщо ж ви хочете явно додати додаткові папки, наприклад відокремити основний migration-path від тестового baseline, у Flyway є налаштування spring.flyway.locations. Воно приймає список шляхів. У YAML це виглядає так:
spring:
flyway:
# Список шляхів через кому: Flyway пройде по обох наборах міграцій.
locations: classpath:db/migration,classpath:db/test-baseline
Читається буквально: «застосовуй міграції і з основного набору, і з тестового набору».
Іноді корисно зробити перевизначення прямо на рівні тестового класу, щоб показати ідею, але памʼятаємо про ціну додаткових контекстів. Приклад суто демонстраційний:
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
@DataJpaTest(properties = {
// Унікальні properties часто призводять до створення окремого ApplicationContext і сповільнюють набір тестів.
"spring.flyway.locations=classpath:db/migration,classpath:db/test-baseline"
})
class FlywayLocationDataJpaTest {
// Тут ми не тестуємо репозиторій, а демонструємо: locations можна розширювати.
}
Таке налаштування особливо зручне, коли у вас є спільний «скелет» схеми в db/migration, а тестам іноді потрібен додатковий baseline, не плутайте його з локальними сценарними даними — це вже територія @Sql, і ми туди поки не заходимо.
5. Flyway і test profile: єдина конфігурація
Коли у вас зʼявляється більше ніж пара тестів, починає свербіти рука зробити «потужніше та красивіше»: прописати потрібні властивості прямо в анотації кожного класу, де це потрібно. І ось тут Spring Boot делікатно нагадує: кожен унікальний набір properties — це потенційно новий ApplicationContext, а значить, запуск набору тестів буде повільнішим, а діагностика — шумнішою.
Тому в проєкті рівня ContentHub здоровіша практика — увімкнути test profile і тримати в ньому налаштування Flyway однаковими. Це ще й робить тести менш магічними: ви відкриваєте application-test.yml і бачите, звідки береться схема.
Мінімальний каркас тестового класу тоді виглядає так:
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.ActiveProfiles;
@DataJpaTest
@ActiveProfiles("test") // Профіль підвантажить налаштування Flyway з application-test.yml
class ArticleRepositoryFlywayEnabledTest {
// Налаштування Flyway беремо з application-test.yml
}
А в src/test/resources/application-test.yml (або src/main/resources/application-test.yml, якщо ви так домовилися в проєкті) у вас може бути, наприклад:
spring:
flyway:
# Явно фіксуємо, що в тестах схема створюється міграціями, а не "Hibernate якось сам".
enabled: true
locations: classpath:db/migration
Так, enabled: true часто і так «за замовчуванням», якщо Flyway є в classpath, але явність тут корисна з навчальної точки зору: ви одразу бачите, що тестова схема — міграційна, а не «Hibernate щось згенерував сам».
І ще одне місце, де профілі дають користь: якщо ви хочете в тестах увімкнути докладніші логи Flyway, робити це краще централізовано, а не локальними перевизначеннями.
6. Читання падінь Flyway до @Test
Найтиповіший симптом проблем із Flyway у тестах виглядає так: ви запускаєте тест, а він падає з чимось на кшталт «Failed to load ApplicationContext». Новачок у цей момент робить три речі: перелякується, гортає stack trace донизу, плутається ще більше і йде пити чай. Чай — гарна ідея, але давайте додамо до нього методику.
Правильна стратегія читання падіння така: ми не намагаємося зрозуміти весь виняток цілком, ми шукаємо «першу корисну ланку». Для Flyway це майже завжди буде FlywayException або її причина, де вказано, яка міграція впала, а інколи навіть номер рядка.
Ось як зазвичай виглядає ланцюжок, спрощено, без кілометра тексту:
IllegalStateException: Failed to load ApplicationContext
...
Caused by: org.flywaydb.core.api.FlywayException: Migration V2__create_articles.sql failed
...
Caused by: org.postgresql.util.PSQLException: ERROR: syntax error at or near "table"
Якщо ви бачите назву міграції (V2__create_articles.sql) — ви вже виграли половину бою. Далі відкриваєте цей файл і перевіряєте: синтаксис, порядок, залежності. І ось тут корисно памʼятати, що різні причини дають різні підказки.
Якщо помилка синтаксична, база скаже щось на кшталт «syntax error at or near …» і інколи навіть вкаже позицію. Якщо проблема в порядку міграцій, ви можете побачити «relation does not exist» або «cannot add foreign key constraint», тому що ви намагаєтеся додати FK до таблиці, якої ще не створено. Якщо ви додаєте constraint, який конфліктує з уже наявними даними, база може сказати «duplicate key value violates unique constraint».
І головне: не намагайтеся «лікувати» це правкою тесту. Якщо Flyway упав — тест навіть не стартував, і ваша проблема міститься в міграціях, схемі чи порядку, а не в репозиторії.
7. Логи Flyway: мінімум для діагностики
Є спокуса увімкнути всі логи всього на світі, щоб «точно побачити». Це працює, але перетворює вивід на роман. Нам потрібен компроміс: достатньо інформації, щоб швидко знайти причину, але без інформаційного шуму.
Найпростіший важіль — рівень логування Flyway. У test profile можна додати:
logging:
level:
# Мінімально корисний рівень: видно, які міграції запускаються та застосовуються.
org.flywaydb: info
Якщо вам потрібні докладніші відомості, наприклад ви підозрюєте, що міграції взагалі не знаходяться, тимчасово піднімайте до debug. Лише памʼятайте: debug-логів буває багато, і потім вони починають заважати у щоденній роботі.
Другий важіль — побачити, які міграції застосувалися. Іноді зручно, особливо в навчальних цілях, подивитися на Flyway як на об’єкт і переконатися, що він справді виконав роботу. Якщо в контексті доступний bean Flyway, можна зробити крихітний діагностичний тест:
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
class FlywayAppliedMigrationsTest {
@Autowired
private Flyway flyway; // Інжектуємо Flyway з контексту, щоб перевірити факт застосування міграцій.
@Test
void shouldHaveAppliedAtLeastOneMigration() {
// Якщо список порожній — це означає, що міграції не знайшли або не застосували, і далі тести репозиторіїв будуть хибно показувати причину.
assertThat(flyway.info().applied()).isNotEmpty();
}
}
Це не «бізнес-тест» і не заміна тестам репозиторіїв. Але він чудово допомагає зловити ситуацію «міграції чомусь не застосовуються», коли ви очікували протилежного. У реальному проєкті такий тест зазвичай не потрібен постійно, але як навчальний ліхтар — дуже корисний.
8. Типові помилки при роботі з Flyway у @DataJpaTest
Помилка № 1: неправильне очікування «тест упав — значить репозиторій поганий».
На практиці дуже багато падінь у data-тестах відбувається ще до виконання @Test-методу. У цей момент репозиторій навіть не встиг нічого зробити, а отже, проблема в старті контексту: міграція, порядок, синтаксис, конфлікт обмежень. Лікується це тим, що ви спершу перевіряєте, де саме впав тест: усередині методу чи на етапі завантаження ApplicationContext.
Помилка № 2: міграції лежать «не там», а ви впевнені, що Flyway їх бачить.
Flyway не телепат. Він шукає файли в locations. Якщо ви переїхали з db/migration на db/migrations (зайва літера s — класика), тести можуть несподівано почати жити «іншим шляхом»: Hibernate створить схему сам, або частина таблиць не зʼявиться, або ви отримаєте загадкові помилки. Гарна звичка — один раз побачити в логах Flyway повідомлення про застосовані міграції, а не просто вірити.
Помилка № 3: імена міграцій не дотримуються формату, і порядок стає непередбачуваним.
Якщо файл називається create_articles.sql, Flyway може просто не вважати його міграцією. Якщо ви пишете V1_create_articles.sql (одне підкреслення), Flyway теж не зобовʼязаний вгадати, що ви мали на увазі. Так, це виглядає як дрібниця, але саме така дрібниця потім забирає пів дня. Формат V<VERSION>__<NAME>.sql потрібно сприймати як контракт, а не як рекомендацію.
Помилка № 4: «гігантська міграція на все» і неможливість діагностувати падіння.
Коли в одному файлі і таблиці, і constraints, і seed data, і індекси, будь-яке падіння перетворюється на розкопки. Flyway вам скаже «упала міграція V1», а далі ви шукаєте голку в копиці SQL. Міграції краще тримати відносно вузькими та змістовними: тоді імʼя файлу саме підказує, де шукати.
Помилка № 5: локальні перевизначення spring.flyway.locations на кожному тесті та вибух кількості контекстів.
Іноді тести «нібито правильні», але набір тестів стає повільним, а машина починає грітися так, ніби ви майните біткоїн. Часта причина — кожен тестовий клас має унікальні properties, через що Spring не перевикористовує контекст. Конфігурацію Flyway вигідніше централізувати в application-test.yml і вмикати через @ActiveProfiles("test"), а точкові експерименти робити рідко й усвідомлено.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ