JavaRush /Курси /Spring Test /Вибір джерела тестових даних

Вибір джерела тестових даних

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

1. Сенс вибору джерела даних

Якщо ви коли-небудь писали data-тест і думали: «Та я зараз швидко накидаю даних, аби репозиторій щось повернув», — вітаю: ви вже зіткнулися з найпоширенішою проблемою @DataJpaTest. Такий тест начебто перевіряє запит, а насправді перевіряє ваш талант вигадувати випадкові фікстури. Вибір джерела даних — це спосіб зробити тест передбачуваним і читабельним, а не просто «зеленим».

У тестах рівня даних у певний момент настає доросла проблема: ви розумієте, що дані — це частина сценарію, а не фон. І якщо фон щоразу створюється по-різному, то репозиторій може «працювати» або «не працювати» не через запит, а тому, що ви випадково створили дві статті з однаковим publishedAt, забули виставити статус PUBLISHED або створили категорію з кодом "Java" замість "java".

З практичного погляду вибір джерела тестових даних — це про дві властивості: по-перше, тест має читатися згори вниз як маленька історія «який стан БД був → що ми зробили → що перевірили». По-друге, тест має бути стійким до випадковостей: якщо ви запустите набір тестів 100 разів, він має 100 разів поводитися однаково, а не «за натхненням Hibernate».

Щоб не перетворювати підготовку даних на магію, корисно тримати в голові просту схему.

flowchart TD
    A[Схема БД] -->|Flyway| B[База готова]
    B --> C{Які дані потрібні тесту?}
    C -->|1-2 обʼєкти, важлива читабельність у Java| D[Java-налаштування: TestEntityManager]
    C -->|важливі точні рядки, id, order, крайні випадки| E["@Sql: statements/scripts"]
    C -->|потрібен загальний фон для багатьох тестів| F[Test-only міграція]
    D --> G[Дія: метод репозиторію]
    E --> G
    F --> G
    G --> H[Перевірка]

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

2. Джерела даних: baseline і сценарій

Коли ми говоримо «тестові дані», багато хто уявляє один великий мішок даних. На практиці зручніше подумки розділити їх на два шари: загальний baseline (фон, який потрібен багатьом тестам) і локальний сценарій (те, що потрібно конкретному тесту). Цей трюк напрочуд зменшує хаос: ви перестаєте намагатися запхати все в один універсальний data.sql, якого потім бояться торкатися навіть найсміливіші розробники.

Якщо розкласти ці ролі по інструментах, картина така: test-only міграції найчастіше працюють як baseline — невеликі стабільні довідники, які потрібні часто, мають бути завжди й мають бути детермінованими. @Sql зазвичай добре підходить як локальний сценарій: ви під один тест або маленький набір тестів завантажуєте рівно той набір рядків, який потрібен для перевірки. Java-setup через TestEntityManager зручний, коли сценарій невеликий і його простіше описати об’єктами, ніж SQL.

Корисно простими словами зафіксувати, що кожен інструмент обіцяє вам як автору теста:

Джерело даних Що дає Де найчастіше доречне
Java-setup (TestEntityManager) Читабельність на рівні Java, компілятор підкаже помилки, зручно створювати граф сутностей 1–3 сутності, важливий зв’язок об’єктів, хочеться бачити домен у коді
@Sql (statements / scripts) Точні рядки, точні id, точні граничні випадки, жодної «магії» генерації сценарії, де важливі фіксовані значення, порядок, граничні випадки
Test-only міграції Flyway Загальний стійкий фон «як повітря», однаковий для всіх тестів довідники та стабільні reference data, наприклад категорії

І тепер головне правило дня, яке звучить нудно, але економить вам тижні життя: один набір даних — одне джерело правди. Якщо категорії у вас «іноді з міграції, іноді з SQL-скрипта, іноді з Java-хелпера», то рано чи пізно ви отримаєте тест, який падає, бо «у цій гілці категорій 3, а в іншій 4», і будете хвилин 40 переконувати себе, що проблема в JPA. Ні, проблема у вашому зоопарку джерел.

3. Java-setup через TestEntityManager

Java-setup зазвичай недооцінюють, бо здається: «Ну це ж довго, у SQL швидше». А потім виявляється, що в SQL ви випадково вставили не той category_id, забули обов’язкове поле або почали тримати в голові числовий id, який був стійким лише до першого зайвого запису. Java-setup добрий тим, що тримає вас у світі типів і компілятора: IDE підказує поля, ви менше помиляєтеся в назвах і простіше бачите сам домен, а не лише таблиці.

Для @DataJpaTest тут стандартний інструмент — TestEntityManager. Він особливо добрий, коли потрібно підняти 1–3 сутності, показати невеликий граф зв’язків і одразу зробити persistAndFlush(). У такому сценарії тест читається мовою моделі: ось категорія, ось стаття, ось зв’язок між ними.

Але Java-setup швидко починає програвати, коли ви боретеся вже не зі сценарієм, а з інфраструктурою: потрібні фіксовані id, точні timestamp, детермінований порядок рядків, sequenceʼи та довгий список обов’язкових полів «просто щоб тест запустився». Це добрий сигнал переключитися на @Sql.

І ще одне правило: shared baseline через Java-хелпери краще не роздувати. Загальний фон зручніше тримати в test-only міграціях, а з тесту брати його за природним ключем на кшталт code, а не сподіватися, що в категорії завжди один і той самий числовий id.

4. @Sql: сценарний SQL-setup

Є ситуації, де SQL — це не «низькорівневе зло», а найчесніший спосіб сказати тесту правду. Коли сам рядок у таблиці і є частиною сценарію, @Sql зазвичай виграє. Фіксований id, конкретний slug, точний published_at, відомий порядок даних, гранична комбінація статусів — усе це в SQL видно одразу і без суперечок із генераторами. Тут якраз нормально жорстко задати id = 10: цей набір даних належить одному тесту і не прикидається спільним базовим набором.

Якщо даних зовсім мало, інлайн statements цілком доречні. Коли сценарій довший за пару команд, чесніше винести його в scripts: файл отримує ім’я, форматування і читається як маленька історія стану БД.

Але сила @Sql саме в локальності. Такий setup має відповідати на питання «який стан потрібен цьому тесту», а не «що б іще про всяк випадок покласти в базу». Щойно один script починає жити як спільний фон для половини набору тестів, це вже не сценарій, а кандидат на baseline-міграцію.

5. Test-only Flyway міграції

Іноді дані потрібні не одному тесту, а багатьом. Тоді @Sql і Java-setup починають дублювати одне одного, а набір тестів поступово перетворюється на серіал «ще раз вставимо категорію java». У такій ситуації test-only міграції — найздоровіше джерело правди: схема приходить з основних міграцій, загальний довідковий фон — з test-only міграцій, і будь-який @DataJpaTest стартує з одного й того самого стану.

Для ContentHub класичний кандидат на такий baseline — Category. Це довідник, на який зручно спиратися в різних тестах. І тут надійніше триматися за природний ключ: якщо тесту потрібна категорія java, краще шукати її за code — наприклад, через findByCode("java") — ніж пришивати до всього набору тестів неявне правило id = 1. Сурогатний id може зміститися, а природний ключ робить залежність явною.

Головне — не переплутати baseline із прихованим набором даних. Щойно в «загальному фоні» починають жити опубліковані статті, вкладення і шматки робочого процесу, це вже не baseline, а локальний сценарій, який потрібно повернути в @Sql або Java-setup.

6. Як змішувати підходи без каші

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

Добра читабельність data-тесту часто виглядає як одна й та сама структура: спочатку ви спираєтеся на базовий фон (наприклад, категорія java вже є), потім додаєте локальний сценарій (наприклад, створюєте одну статтю), потім викликаєте репозиторій і робите перевірку.

Приклад такого «змішаного, але читабельного» тесту: категорія приходить із baseline, а стаття створюється локально через Java-setup, бо нам важливий зв’язок ArticleCategory саме як об’єкт.

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
class MixedSetupDataJpaTest {

    @Autowired
    TestEntityManager em;

    @Autowired
    CategoryRepository categoryRepository;

    @Test
    void shouldPersistDraftInReferenceCategory() {
        // Arrange: базовий фон живе в міграціях, тому беремо категорію за природним ключем
        Category category = categoryRepository.findByCode("java").orElseThrow();

        // Arrange: локальний сценарій створюємо в Java, бо нам важливий звʼязок сутностей
        Article article = new Article("Intro", "intro", ArticleStatus.DRAFT, category);

        // Act: зберігаємо й фіксуємо в БД, щоб далі репозиторій працював із реальним станом
        em.persistAndFlush(article);

        // Assert: перевіряємо, що стаття справді збереглася
        assertThat(article.getId()).isNotNull();
    }
}

Зверніть увагу, що baseline-категорію тут не пришито до id=1: загальний фон беремо за code, а локальну статтю створюємо там, де живе сам сценарій.

Якщо ж вам потрібен «рівно такий набір рядків» (наприклад, три опубліковані статті із заздалегідь визначеними slug і publishedAt), Java-setup може стати занадто багатослівним, і тоді локальний сценарій краще зробити @Sql. Але baseline при цьому не має дублювати ці статті: baseline залишається довідником, а сценарій — сценарієм.

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

7. Шпаргалка вибору

Коли ви сидите над тестом, мозок зазвичай хоче просту відповідь: «що обрати?» Але правильна відповідь не в інструменті, а в питанні «який стан БД потрібен цьому тесту». Щоб не перетворювати вибір на філософію, зручно користуватися маленькою шпаргалкою: вона не догма, але для звичайних data-тестів працює напрочуд добре.

Починайте з найпрактичнішого: скільки даних і наскільки вони «особливі»? Якщо це 1–2 сутності, і вони просто потрібні, щоб метод репозиторію не працював у порожнечі, Java-setup — майже завжди найкращий варіант. Якщо дані «особливі» і ви хочете фіксовані id/slug/часові мітки/порядок, @Sql зазвичай чесніше. Якщо дані потрібні багатьом тестам і є довідником — test-only міграція. І це правило не зникає, коли тест стає ширшим і дорожчим: змінюється ціна запуску, але не сам принцип розмежування між схемою, загальним baseline і локальним сценарієм.

Ось компактна таблиця рішень:

Питання до тесту Якщо відповідь «так» Зазвичай обираємо
Потрібно лише кілька сутностей, важлива читабельність на Java-рівні? Так Java-setup (TestEntityManager)
Важливі фіксовані значення (id/slug/timestamp), і ви хочете бачити рядки явно? Так @Sql (scripts/statements)
Ці дані потрібні багатьом тестам як незмінний довідник? Так test-only міграція Flyway
Сценарій має бути локальним і зрозумілим прямо в тесті? Так Java-setup або @Sql (не міграція)
Ви помічаєте, що «ті самі дані» задані в трьох місцях? Так, помічаю Терміново залишити одне джерело правди

А тепер те саме маленьке «дерево» питань, яке можна тримати в голові:

flowchart TD
    Q1[Дані потрібні багатьом тестам?] -->|Так| Q2[Це довідник, майже не змінюється?]
    Q2 -->|Так| MIG[Test-only міграція]
    Q2 -->|Ні| SQL["@Sql, але обережно й локально"]

    Q1 -->|Ні| Q3[Потрібне точне значення колонки?]
    Q3 -->|Так| SQL
    Q3 -->|Ні| JAVA[Java-setup через TestEntityManager]

Зверніть увагу, у дереві немає варіанта «завжди @Sql» або «завжди Java». Бо «завжди» — це зазвичай шлях до тестів, які або занадто довгі, або занадто крихкі.

8. Типові помилки під час вибору джерела даних

Помилка №1: змішування трьох підходів без ролей.
Найчастіша картина в проєкті, що «швидко зростав»: в одному тесті категорії створюються через Java-setup, в іншому підтягуються @Sql, а в третьому раптом з’являються з test-only міграції. У підсумку ви не певні, які дані гарантовані завжди, а які локальні. Лікується це не новою абстракцією, а простим рішенням: довідник живе в одному місці, зазвичай у міграціях, сценарій живе локально (Java або @Sql), і тест робить свою залежність видимою. Для спільних довідникових даних це зазвичай означає пошук за природним ключем, а не мовчазну ставку на id=1.

Помилка №2: дублювання одного й того самого набору даних у різних джерелах.
Якщо категорія java є і в baseline-міграції, і в base-categories.sql, і ще в хелпері TestCategories, рано чи пізно хтось виправить назву категорії в одному місці, а в другому забуде. Тести почнуть падати «дивно», бо різні тести живуть у різних версіях реальності. Тут допомагає правило: один тип даних — одне джерело правди. Навіть якщо дуже хочеться «про всяк випадок» продублювати, краще не треба.

Помилка №3: перетворення test-only міграцій на «звалище всього».
Міграції для тестів — чудова річ, поки в них лежить маленький і зрозумілий baseline. Але якщо туди починають зносити дані під кожен новий тест («ну це ж зручно, воно завжди буде»), baseline перестає бути базовим набором і перетворюється на приховану залежність. За місяць ви отримуєте тест, який падає через те, що хтось підправив «загальні дані», навіть не знаючи, що ваш тест на них зав’язаний. Хороша міграція для тестів схожа на довідник: коротка, стабільна й нудна. А нудне в тестах — це комплімент.

Помилка №4: використання @Sql як універсального завантаження «все для всіх».
Великий SQL-файл, який «підходить усім тестам», зазвичай звучить як економія часу. На практиці він перетворюється на моноліт, який неможливо підтримувати: будь-яка зміна ламає десятки тестів, а ви боїтеся видалити «зайвий рядок», бо не знаєте, кому він потрібен. @Sql найсильніше саме у сценарності: невеликий файл, зрозуміла назва, один сюжет.

Помилка №5: занадто довгий Java-setup, який приховує сенс тесту.
Java-setup чудовий, поки він короткий. Але щойно половина тесту — це заповнення обов’язкових полів сутності, сенс перевірки починає тонути в boilerplate. У цей момент дуже легко помилитися і створити дані не такими, як ви думаєте. Якщо ви бачите, що підготовка в Java стала довшою за перевірку, це привід зупинитися і подумати: можливо, краще @Sql — там ви явно задасте лише потрібні колонки, — або, можливо, ваш сценарій потрібно спростити.

1
Опитування
Схема та міграції, рівень 18, лекція 4
Недоступний
Схема та міграції
Data-layer тести та Flyway
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ