JavaRush /Курси /Spring Test /Test-only міграції та baseline даних

Test-only міграції та baseline даних

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

1. Загальний фон для тестів замість локального @Sql

Коли ви пишете репозиторні тести, майже завжди хочеться, щоб база спочатку була «схожою на реальність»: якісь довідники вже є, обмеження увімкнені, базові сутності можна зв’язати. І тут виникає неприємна звичка: кожен тест починає тягнути за собою однакові підготовчі кроки, перетворюючи suite на серіал «Вставте категорію java — сезон 12». Щоб цього уникнути, ми вводимо ідею стабільного тестового baseline.

Уявіть, що у вас є два класи тестів. У першому ви перевіряєте різні запити до статей, сортування та пагінацію. У другому — зв’язки і вкладення. І там, і там вам потрібна хоча б одна-дві категорії, бо Article.category — обов’язкове поле, а коди бажано мати зрозумілі, щоб тест читався як історія, а не як бухгалтерський звіт: «categoryId = 17, бо так склалося історично».

@Sql чудово розв’язує задачу «у цьому конкретному тесті потрібна ось така таблиця в такому стані». Але якщо один і той самий набір рядків потрібен усюди, він починає жити як «прихована залежність»: ви не завжди пам’ятаєте, де його готують, а нові тести час від часу забувають його підключити й падають «на рівному місці».

Тому ми розділяємо два типи даних:

  • Сценарні дані — те, що унікальне для тесту або невеликої групи тестів (це найчастіше @Sql або Java-setup через TestEntityManager).
  • Довідкові, або базові дані — стабільний фон, на який спираються багато тестів (це хороший кандидат на test-only migrations).

Ідея проста: якщо тестовий baseline — це «гравітація», то @Sql — це «ми підкинули м’ячик у потрібну точку». Гравітація має бути одна й передбачувана.

2. Test-only migrations як шар даних для тестів

Test-only міграції звучать так, ніби ми збираємося «обдурити» систему: мовляв, у проді одне, у тестах — інше. Насправді це цілком чесний інженерний прийом: ми говоримо, що в тестовому середовищі нам потрібен додатковий шар даних, який допомагає писати тести простіше, швидше й стабільніше. І так — цей шар не має потрапляти до production JAR. Тому він живе в src/test/resources.

Важливий момент: test-only migration — це не альтернатива Flyway, а той самий Flyway, просто інший набір файлів на classpath. Коли запускається @DataJpaTest, Spring Boot підіймає тестовий ApplicationContext, створює DataSource, а Flyway застосовує міграції. Якщо на classpath лежать файли з src/main/resources, вони застосуються. Якщо там само лежать міграції з src/test/resources, вони теж застосуються — але тільки в тестах, бо production-збірка їх не включає.

У цього підходу є дуже приємна властивість: тестова міграція виконується до того, як JUnit 6 почне запускати методи тестів. А самі @DataJpaTest зазвичай транзакційні та з rollback за замовчуванням. Це означає, що базовий фон буде однаковим для всіх тестів у межах одного контексту, і окремий відкат транзакції його не «з’їсть».

Корисно тримати в голові таку схему:

flowchart TD
    %% Порядок важливий: міграції застосовуються до запуску тестових методів
    A["Старт класу @DataJpaTest"] --> B["Підіймається тестовий ApplicationContext"]
    B --> C["Створюється DataSource"]
    C --> D["Flyway застосовує основні міграції"]
    D --> E["Flyway застосовує test-only міграції (якщо є)"]
    E --> F["JUnit 6 запускає метод тесту"]
    F --> G["Тест працює в транзакції"]
    G --> H["Rollback після тесту (за замовчуванням)"]

Тобто test-only migrations — це спосіб зробити так, щоб початкова точка була однаковою й не залежала від того, чи згадав автор тесту додати @Sql, чи ні.

3. Зберігання test-only міграцій: структура

Поки ви навчаєтесь, краще тримати структуру максимально очевидною: якщо у Flyway за замовчуванням classpath:db/migration, то тестові міграції зручно покласти в src/test/resources/db/migration. Тоді під час тестів вони опиняться на classpath і Flyway знайде їх автоматично — без додаткових налаштувань.

Візуально це виглядає так:

src
├─ main/resources/db/migration
│  ├─ V1__create_categories.sql
│  └─ V2__create_articles.sql
└─ test/resources/db/migration
   ├─ V900__seed_reference_categories.sql
   └─ V901__seed_reference_admin_articles.sql

У production JAR потраплять тільки src/main/resources, а src/test/resources залишаться в тестовому classpath. Це чесно й безпечно: тести отримують додаткові міграції, а застосунок у проді про них навіть не здогадується.

Іноді команди обирають більш «акуратний» варіант: тестові міграції складають не в загальний db/migration, а в окрему папку на кшталт db/test-baseline, щоб візуально було видно: «це лише для тестів». Тоді треба явно додати location до конфігурації Flyway.

Наприклад, через test profile:

# src/test/resources/application-test.yml
spring:
  flyway:
    # Явно підключаємо обидва набори міграцій: основні та тестовий baseline
    locations: classpath:db/migration,classpath:db/test-baseline

Це не обов’язково краще — це просто інший компроміс. Перший варіант простіший: менше конфігурації й менше шансів забути підключення. Другий варіант явніший на рівні структури: ви не змішуєте «бойові» міграції й «тестовий фон» в одній папці.

Для навчального проєкту ContentHub я б почав із простого стандарту src/test/resources/db/migration і лише коли файлів стане справді багато — відокремив би test-baseline в окремий location. На старті важливіша передбачуваність, ніж архітектурна краса каталогів.

4. Нумерація та імена тестових міграцій

Міграції Flyway впорядковуються за версією з імені файла, і це одночасно суперсила та джерело драми. Якщо ви назвали тестову міграцію V3__seed_categories.sql, а в основному ланцюжку вже є V3__something.sql, Flyway влаштує вам дуже переконливе пояснення, чому так робити не треба. Тому тестові міграції прийнято виносити в окремий діапазон версій.

Найпопулярніший (і дуже практичний) прийом — використовувати великі номери на кшталт V900, V901, V999. Це працює, бо ви майже напевно не дійдете основними міграціями до 900 у навчальному проєкті. А головне — під час читання око відразу бачить: «ага, 900+ — це тестове».

У міграціях загалом корисно тримати одне правило: ім’я файла має пояснювати мету, а не просто факт існування. Файл V900__seed.sql — це як змінна x: технічно допустимо, але говорити з цим потім буде боляче. А ось V900__seed_reference_categories.sql уже пояснює, що це саме довідкові категорії й саме для baseline.

Невелика табличка, яка допомагає втримати порядок у голові:

Тип міграції Приклад імені файла Де живе Навіщо потрібна
Основна міграція схеми V2__create_articles.sql src/main/resources Створює або змінює структуру, потрібна застосунку
Основна міграція початкового наповнення (якщо є) V3__seed_categories.sql src/main/resources Наповнює бойову БД базовими даними
Test-only baseline migration V900__seed_reference_categories.sql src/test/resources Дає спільний фон для тестів, не потрапляє в прод
Test-only dataset migration V910__seed_published_articles_dataset.sql src/test/resources Дає тестовий набір для багатьох тестів, але не для одного

Зверніть увагу: test-only baseline і test-only dataset — це обидва test-only migrations, але baseline зазвичай стосується довідників і мінімального фону, а dataset може бути трохи більш «багатим», якщо багато тестів потребують одного й того самого набору записів.

5. Довідкові дані в ContentHub: baseline та сценарії

Довідкові дані — це записи, які не змінюються щохвилини, не живуть у workflow й на які зручно спиратися як на константи. У ContentHub це майже ідеальна роль для Category: у неї є стабільний code, який трапляється в API, у тестах, а іноді й у бізнес-логіці.

І тут важливо не переплутати два підходи.

Якщо ви в тестах спираєтеся на випадкові id, за кілька тижнів побачите, що читаєте тест як ребус: «чому категорія з id = 1 — це Java?». Так, ви можете зафіксувати id у міграції, і це навіть іноді зручно, але надійніший спосіб — спиратися на природний ключ (у нашому випадку code). Тоді тест виглядає як нормальний текст: «має існувати категорія з кодом java».

Друга важлива думка: baseline має бути маленьким. Він не має перетворюватися на «міні-прод». Щойно ви починаєте закидати туди «ну нехай будуть ще 50 статей, 20 вкладень і три сценарії публікації», ви втрачаєте контроль. Тести починають неявно залежати від даних, які вони самі не створюють, і з’являється класична ситуація: «тест упав, бо baseline змінили, але ніхто не зрозумів, хто на нього був зав’язаний».

Тому baseline краще проєктувати як мінімальний набір, який робить тести зручними, але не магічними. Для категорій це зазвичай 2–5 записів із читабельними кодами (java, spring, testing, architecture) і зрозумілими назвами.

Якщо вам потрібен «багатий» набір даних, то або це окрема dataset migration (і ви явно фіксуєте, що цей набір — домовленість для багатьох тестів), або це @Sql / Java-setup на рівні конкретного тесту. Але baseline, повторюся, має залишатися нудним. Нудний baseline — це комплімент, а не образа.

6. Приклади: міграція та тест-контракт

Міграція з довідковими категоріями

Тепер зберемо це в конкретний і дуже прикладний приклад для ContentHub. Ми хочемо, щоб у кожному @DataJpaTest у нас гарантовано були категорії зі зрозумілими кодами. Зробимо це через test-only міграцію.

Нехай файл лежить тут:

src/test/resources/db/migration/V900__seed_reference_categories.sql

І містить мінімум рядків, щоб його було легко читати:

-- V900__seed_reference_categories.sql

-- Довідкові категорії для тестового baseline: тести спираються на code, а не на id
insert into categories(code, name) values ('java', 'Java');
insert into categories(code, name) values ('spring', 'Spring');

Тут є два важливі нюанси.

Перший нюанс: ми вставляємо за code, а не за id. Це означає, що тести не зобов’язані знати, який numeric id у категорії, і не ламаються через зміну sequence. Другий нюанс: у канонічному baseline-прикладі тут звичайний insert, без on conflict. Для чистої тестової БД це чесніше: якщо запис раптом уже існує, значить в одного й того самого довідника з’явилося два власники, і це треба виправляти в міграціях, а не тихо обходити. Якщо проєкт свідомо живе лише на PostgreSQL, обережний варіант із on conflict (code) do nothing можливий, але це саме локальний захист, а не універсальний рецепт.

І, звісно, сама ідея опори на code все одно передбачає унікальність цього поля. Якщо в проєкті немає унікальності для Category.code, довідкові дані стають «мутними» — і тести, і API починають страждати. Тому unique constraint на Category.code — це не забаганка тестів, а нормальний інваріант домену.

Тест-контракт на baseline

Найчастіша проблема з довідковими даними — люди починають вважати їх «само собою зрозумілими». Поки проєкт маленький — усе нормально. Потім хтось змінює seed, хтось перейменовує код, хтось вирішує «давайте замість java буде java-core», і раптом половина data-тестів падає.

Щоб baseline не був прихованою магією, корисно мати хоча б один маленький тест, який прямо каже: «наше тестове середовище передбачає, що категорія java існує». Це не означає, що треба щоразу перевіряти всі категорії, але один test-контракт на baseline — це як табличка «Не годуйте тести випадковими даними».

Якщо у вас є CategoryRepository з методом findByCode, тест виглядає дуже компактно й читабельно:

package com.example.contenthub.repository;

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 CategoryReferenceDataDataJpaTest {

    @Autowired
    private CategoryRepository categoryRepository; // Репозиторій потрібен, щоб шукати за природним ключем (code)

    @Test
    void shouldContainJavaCategoryInTestBaseline() {
        // Цей тест — контракт на baseline: категорія з code="java" має існувати в усіх @DataJpaTest
        Category category = categoryRepository.findByCode("java").orElseThrow(); // Якщо baseline зламано — падаємо відразу й чесно

        // Перевіряємо не «в базі рівно N категорій», а конкретну домовленість, на яку спираються тести
        assertThat(category.getName()).isEqualTo("Java");
    }
}

Зверніть увагу на стиль: тест не перевіряє «в базі рівно дві категорії» (це занадто жорстко), а перевіряє ключову домовленість: категорія з кодом java є й має очікувану назву. Якщо ви потім додасте третю категорію в baseline — тесту байдуже. А якщо випадково зламаєте код або міграцію — він упаде одразу й чесно.

Якщо репозиторія немає (або ви хочете підкреслити, що йдеться саме про стан БД), можна зробити те саме через TestEntityManager, але тоді доведеться знати id. Тому для довідкових речей findByCode зазвичай зручніший і ближчий до домену.

7. Типові помилки при test-only migrations і довідкових даних

Помилка № 1: перетворювати baseline на «смітник усього підряд».
Спочатку ви додаєте в test-only migration дві категорії, потім одну опубліковану статтю «для зручності», потім пару вкладень «щоб було», а через місяць там лежить пів БД, і ніхто не розуміє, що від чого залежить. Якщо тесту потрібен багатий сценарій, краще зробити його локальним через @Sql або окремий сценарний dataset, а baseline тримати нудним і маленьким.

Помилка № 2: зав’язувати тести на числові id, бо «так швидше».
Числові ідентифікатори в БД — зручна техніка, але як тестова залежність вони часто крихкі: змінюється порядок вставок, sequence, з’являється ще один запис — і ось уже «Category id=1» раптом не Java. Для довідкових таблиць майже завжди краще спиратися на природний ключ (code) і мати репозиторний метод findByCode.

Помилка № 3: змінювати загальний baseline заради одного тесту, що впав.
Це класична пастка: тест упав, ви подумали «додам ще один запис у baseline, і все», а насправді ви змінили початкову точку для десятків тестів, і тепер налагодження перетворюється на пригоду. Правильний підхід — якщо кейс специфічний, ізолювати його локальним @Sql або підготовкою даних саме в цьому тесті.

Помилка № 4: беззмістовні імена міграцій.
Файли на кшталт V900__data.sql виглядають невинно, поки їх один-два. Потім з’являється V901__data2.sql, і ви самі починаєте підозрювати, що вас тестує не Spring, а стародавній єгипетський сфінкс. Ім’я міграції має відповідати на питання «що і навіщо», інакше підтримка suite стає дорожчою, ніж написання тестів.

Помилка № 5: неявна залежність від baseline без тест-контракту.
Коли baseline існує, але ніде явно не перевіряється, ви дізнаєтеся про його поломку через десяток «випадкових» падінь у різних тестах. Один маленький тест, який перевіряє ключові довідкові записи, робить цю залежність явною та різко покращує діагностику.

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