JavaRush /Курси /Spring Test /@Sql для даних у тес...

@Sql для даних у тестах

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

1. Дані в тестах: ціна ручної підготовки

Коли ви пишете @DataJpaTest, дуже хочеться робити підготовку лише через Java: створити Category, створити Article, пов’язати їх, persistAndFlush() — і все, ніби у вас невеликий світ, де ніхто не пише SQL вручну. На практиці це часто працює, і TestEntityManager справді робить життя простішим. Але досить швидко ви натрапляєте на ситуацію, де «зібрати дані через Java» починає займати більше рядків, ніж сам тест, а інколи ще й приховує важливі деталі.

Наприклад, вам потрібно створити категорію з фіксованим id = 1, тому що далі в тесті ви перевіряєте зв’язок Article.category_id = 1. Через JPA це теж можна зробити, але ви почнете сперечатися з генерацією ідентифікаторів, стратегіями, послідовностями та іншими «веселощами», які в тесті взагалі не мають бути головними. Або вам потрібно вставити три статті з конкретними published_at, щоб перевірка сортування була детермінованою. Через Java це перетворюється на багато коду, а в SQL — на три рядки, які читаються як сценарій.

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

2. @Sql у Spring Test і @DataJpaTest

Анотація @Sql — це механізм Spring Test, який дає змогу виконати SQL до або після тесту. Головне тут навіть не слово «SQL», а слово «керовано». Ви не відкриваєте з’єднання вручну, не пишете DriverManager.getConnection(...), не думаєте, куди подіти транзакцію. Spring сам бере ваш DataSource із тестового контексту і виконує команди в потрібний момент життєвого циклу тесту.

У @DataJpaTest це особливо зручно, тому що такий тест за замовчуванням транзакційний і відкочується (rollback) після виконання. Це означає, що @Sql, виконаний до тесту, найчастіше теж буде частиною цієї ж тестової транзакції, а отже — дані автоматично «приберуться» відкатом. Краса, як не дивно, саме в буденності: вам не треба щоразу вручну чистити базу, щоб тести були незалежними.

Щоб не тримати це абстрактно в голові, корисно уявити собі життєвий цикл одного тесту з даними як маленьку п’єсу:

flowchart TD
    A["Spring створив тестовий ApplicationContext"] --> B["@Sql BEFORE_TEST_METHOD: підготовка даних"]
    B --> C["Метод тесту: repository/EntityManager читають і записують"]
    C --> D["@Sql AFTER_TEST_METHOD: опціональне очищення"]
    D --> E["Відкат тестової транзакції (за замовчуванням у @DataJpaTest)"]

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

3. Вбудований SQL: @Sql(statements = ...)

Коли даних потрібно зовсім трохи — один-два записи, один insert, один delete — тягнути окремий SQL-файл буває просто лінь. Причому це хороша, інженерна лінь: окремий файл треба назвати, покласти, а потім ще й знайти очима. Іноді простіше та чесніше тримати мінімальну підготовку прямо поруч із тестом, щоб він читався зверху вниз: «вставили — прочитали — перевірили».

Для цього в @Sql є атрибут statements. Він приймає SQL-рядки, які будуть виконані перед (або після) тестом. У найпростішому варіанті це виглядає так:

import com.example.contenthub.entity.Category;
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 org.springframework.test.context.jdbc.Sql;

import static org.assertj.core.api.Assertions.assertThat;

@DataJpaTest
class CategoryInlineSqlTest {

    @Autowired
    TestEntityManager entityManager;

    @Test
    // Дані готуємо на рівні БД просто перед тестом: це і швидше, і чесніше.
    @Sql(statements = "insert into categories(id, code, name) values (1, 'java', 'Java')")
    void shouldReadPreparedCategory() {
        // Шукаємо за фіксованим id: він заданий саме в SQL-скрипті вище.
        Category category = entityManager.find(Category.class, 1L);

        // Перевіряємо ключове поле, яке важливе для сценарію.
        assertThat(category.getCode()).isEqualTo("java");
    }
}

Зверніть увагу на «терапевтичний ефект» такого тесту: він не приховує, що категорія лежить саме в таблиці categories, що в неї є id, code, name, а code = 'java' — важливе стабільне значення. Це особливо корисно, коли ви налагоджуєте дивний збій репозиторію і хочете переконатися, що проблема не в «магії JPA», а в самому SQL-стані.

Якщо SQL-команд трохи більше, statements можна передати масивом. Тільки не перетворюйте це на роман із 30 рядків усередині анотації: Java-анотація — погане місце для довгого тексту, це як намагатися написати технічне завдання в назві змінної.

4. SQL-файли: @Sql(scripts = ...)

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

Для цього використовується @Sql(scripts = ...). Скрипт — це ресурс на classpath. Найзвичніший для тестів шлях — src/test/resources. Наприклад, так може виглядати структура:

src/test/resources
└─ sql
   ├─ base-categories.sql
   ├─ three-published-articles.sql
   └─ cleanup-articles.sql

У Java ви посилаєтесь на файл як на classpath-ресурс. Часто використовують абсолютний шлях усередині classpath, починаючи з /:

import com.example.contenthub.repository.ArticleRepository;
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.test.context.jdbc.Sql;

import static org.assertj.core.api.Assertions.assertThat;

@DataJpaTest
// Загальний «фон» для всіх тестів у класі: базові довідкові категорії.
@Sql(scripts = "/sql/base-categories.sql")
class ArticleRepositoryWithSeedScriptTest {

    @Autowired
    ArticleRepository articleRepository;

    @Test
    // Локальний сценарій для конкретного тесту: рівно 3 опубліковані статті.
    @Sql(scripts = "/sql/three-published-articles.sql")
    void shouldUseSqlForExactPreparedSet() {
        // Перевіряємо, що seed-скрипт справді підготував очікуваний стан.
        assertThat(articleRepository.count()).isEqualTo(3);
    }
}

А ось приклад вмісту файлу base-categories.sql (дуже короткий, але вже корисний):

-- Базові категорії, які лежать в основі багатьох тестів.
insert into categories(id, code, name) values (1, 'java', 'Java');
insert into categories(id, code, name) values (2, 'spring', 'Spring');

І файл three-published-articles.sql (спрощено, з ідеєю «фіксовані slug і статуси»):

-- Важливо: фіксуємо id/slug/status, щоб тести були детермінованими.
insert into articles(id, title, slug, summary, body, status, author_username, category_id)
values (10, 'Intro to Java', 'intro-to-java', '...', '...', 'PUBLISHED', 'alice', 1);

insert into articles(id, title, slug, summary, body, status, author_username, category_id)
values (11, 'Spring MVC', 'spring-mvc', '...', '...', 'PUBLISHED', 'alice', 2);

insert into articles(id, title, slug, summary, body, status, author_username, category_id)
values (12, 'JPA basics', 'jpa-basics', '...', '...', 'PUBLISHED', 'bob', 1);

Так, у реальній схемі в Article майже напевно більше not-null полів (час, версії, можливо created_at), і тоді вам доведеться заповнити їх теж. Це неприємно рівно до моменту, поки ви не зрозумієте, що саме так SQL робить тест чеснішим: він змушує вас поважати реальні обмеження схеми.

5. executionPhase: підготовка й очищення

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

Для цього в @Sql є executionPhase. Він дає змогу виконати SQL після методу тесту. Зазвичай це виглядає так:

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.jdbc.Sql;

@DataJpaTest
class ArticleWithAttachmentSqlTest {

    @Test
    // Підготовка: готуємо пов’язаний набір даних перед тестом.
    @Sql(scripts = "/sql/article-with-attachment.sql")
    @Sql(
        scripts = "/sql/cleanup-article-with-attachment.sql",
        // Очищення: виконуємо його одразу після тесту, якщо хочемо явно показати сценарій.
        executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD
    )
    void shouldReadArticleWithAttachment() {
        // Тіло тесту тут неважливе: важливий сам шаблон "підготовка + очищення".
        // У реальному тесті тут зазвичай ідуть виклики репозиторію та assert-перевірки.
    }
}

Файл cleanup-article-with-attachment.sql може бути таким:

-- Видаляємо тільки те, що створив сценарій: фіксовані id, без «килимових» delete.
delete from article_attachments where id in (100);
delete from articles where id in (10);

Чому це може бути корисним, навіть якщо є rollback? Тому що тест — це не лише машина перевірки, а й документація. Іноді ви робите тест «показовим»: він демонструє сценарій підготовки і показує, які таблиці реально беруть участь. У цей момент cleanup допомагає не сподіватися на «магічний відкат», а тримати сценарій у явному вигляді.

Тільки важливо не перетворити cleanup на окрему гру «а що б ще видалити». Якщо ви видаляєте пів таблиці без прив’язки до конкретних id — це погане очищення, тому що воно перетворює тест на потенційно небезпечний «бомбардувальник» (особливо коли тестова БД стає трохи схожішою на реальну).

6. Seed scripts: SQL як сценарій

SQL-файл для тесту — це не «дамп бази», а маленька сцена. Хороший seed script зазвичай відповідає на запитання: «який мінімальний стан потрібен, щоб тест був осмисленим?» І якщо ви дотримуєтесь цього правила, SQL стає читабельнішим навіть для тих, хто його не любить (так, такі люди існують, і деякі з них пишуть на Java).

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

Що ви хочете Як виглядає «добре» Як виглядає «погано»
Зрозуміти сценарій очима Файл названо за змістом: three-published-articles.sql Файл названо абияк: data1.sql, init.sql
Мінімальний набір даних 3–10 вставок, лише потрібні колонки 200 рядків «про всяк випадок»
Детермінованість Фіксовані id, code, slug, зрозумілі значення Випадкові значення, які не пов’язані з перевіркою
Діагностика падінь Читабельні значення, легко зрозуміти, що вставляли Усе однакове: test, test1, aaa

Ще один хороший прийом — групувати дані всередині файлу логічно. Не обов’язково писати коментарі на кожному кроці (ми не пишемо «поставив чайник, налив воду»), але іноді короткий коментар -- categories або -- published articles різко підвищує швидкість розуміння.

І, будь ласка, не перетворюйте SQL на «чорну скриньку». Якщо в seed script є магічне значення на кшталт status = 'PUBLISHED', це чудово: студент бачить, що статус зберігається як рядок або значення enum, і пов’язує це з доменною моделлю ContentHub.

7. @Sql на класі та методі

Часто хочеться розділити тестові дані на два шари: «фон» і «сценарій». Фон — це щось, що потрібно майже всім тестам у класі. Наприклад, категорії: вони довідкові, стабільні, і дуже зручно мати їх завжди. Сценарій — це те, що потрібно конкретному тесту: конкретна стаття, конкретне вкладення, конкретний стан.

На рівні Spring Test це легко виражається комбінацією @Sql на класі та на методі. Клас задає фон, метод додає деталі:

import com.example.contenthub.repository.ArticleRepository;
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.test.context.jdbc.Sql;

import static org.assertj.core.api.Assertions.assertThat;

@DataJpaTest
// Загальні дані для всіх тестів цього класу.
@Sql(scripts = "/sql/base-categories.sql")
class PublishedArticleQuerySqlTest {

    @Autowired
    ArticleRepository articleRepository;

    @Test
    // Дані, специфічні для конкретного тестового сценарію.
    @Sql(scripts = "/sql/one-published-article.sql")
    void shouldSeeSeededArticle() {
        // Перевіряємо, що в БД рівно одна стаття з поточного seed-скрипту.
        assertThat(articleRepository.count()).isEqualTo(1);
    }
}

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

Єдиний нюанс, про який варто пам’ятати: @Sql-анотації виконуються в певному порядку, і порядок зазвичай відповідає їх оголошенню. Тому намагайтеся тримати «загальний фон» раніше, а «сценарій» ближче до тесту. І не намагайтеся розв’язати всі проблеми в одному файлі base.sql: це зазвичай закінчується тим, що половина ваших тестів починає залежати від «випадкового» рядка, який там опинився колись давно.

8. Типові помилки під час роботи з @Sql

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

Помилка №2: використовувати беззмістовні назви файлів і перетворювати SQL на загадку.
data.sql, test.sql, init2.sql — це назви, які не допомагають ні вам, ні вашому майбутньому вам. За два тижні ви дивитиметеся на це як на чужий код (бо це і буде чужий код, написаний вами в минулому). Сценарна назва на кшталт article-with-attachment.sql одразу відповідає на запитання «що всередині» і економить час діагностики.

Помилка №3: писати inline statements на 20 рядків і страждати під час читання.
statements хороші, доки їх справді мало. Коли SQL стає більшим за пару команд, анотація перетворюється на незручну стіну тексту, де важко побачити структуру і легко пропустити помилку. У цей момент краще чесно винести SQL у scripts, дати йому назву і форматувати як нормальний код.

Помилка №4: забувати, що SQL працює з таблицями та колонками, а не з вашими Java-полями.
У Java у вас authorUsername, а в базі може бути author_username. У Java у вас publishedAt, а в базі — published_at. І @Sql буде безжальною чесною: якщо ви помилилися колонкою, ви отримаєте падіння. Це не «погана анотація», а корисний сигнал: тест показує реальність схеми. Допомагає тримати під рукою міграції Flyway і не гадати, як воно має називатися.

Помилка №5: робити очищення «килимовим бомбардуванням» без прив’язки до сценарію.
Скрипт виду delete from articles; delete from categories; виглядає як швидкий спосіб навести лад, але насправді він робить тест небезпечним і погано пояснюваним. Нормальне очищення видаляє рівно те, що створив сценарій, зазвичай за фіксованими id/slug/code. Тоді cleanup не ламає сусідні тести і залишається частиною історії, а не окремим актом руйнування.

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