JavaRush /Курсы /Spring Test /Выбор источника тестовых данных

Выбор источника тестовых данных

Spring Test
18 уровень , 4 лекция
Открыта

1. Смысл выбора источника данных

Если вы когда-нибудь писали data-тест и думали «да я сейчас быстро накидаю данных, лишь бы репозиторий что-то вернул», поздравляю: вы уже столкнулись с самой частой проблемой @DataJpaTest — тест вроде проверяет запрос, но на самом деле проверяет ваш талант в изобретении случайных фикстур. Выбор источника данных — это способ сделать тест предсказуемым и читаемым, а не просто “зелёным”.

В data-layer тестах в какой-то момент наступает взрослая боль: вы понимаете, что данные — это часть сценария, а не фон. И если фон каждый раз создаётся по-разному, то репозиторий может «работать» или «не работать» не из-за запроса, а из-за того, что вы случайно сделали две статьи с одинаковым publishedAt, или забыли выставить статус PUBLISHED, или создали категорию с кодом "Java" вместо "java".

С практической точки зрения выбор источника тестовых данных — это про два свойства: во-первых, тест должен читаться сверху вниз как маленькая история «какое состояние БД было → что мы сделали → что проверили». Во-вторых, тест должен быть устойчивым к случайностям: если вы запустите suite 100 раз, он должен 100 раз вести себя одинаково, а не «по вдохновению Hibernate».

Чтобы не превращать подготовку данных в магию, полезно держать в голове простую схему.

flowchart TD
    A[Схема БД] -->|Flyway| B[База готова]
    B --> C{Какие данные нужны тесту?}
    C -->|1-2 объекта, важна читаемость в Java| D[Java setup: TestEntityManager]
    C -->|важны точные строки, id, order, углы| E["@Sql: statements/scripts"]
    C -->|нужен общий фон для многих тестов| F[Test-only миграция]
    D --> G[Act: repository method]
    E --> G
    F --> G
    G --> H[Assert]

Эта диаграмма не про «какой инструмент моднее». Она про то, что у каждого инструмента есть роль, и если роль перепутать, тесты начинают воевать друг с другом, а вы — с собственным репозиторием.

2. Источники данных: baseline и сценарий

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

Если разложить эти роли по инструментам, картина такая. Test-only миграции чаще всего работают как baseline: небольшие стабильные справочники, которые нужны часто, должны быть всегда и должны быть детерминированными. @Sql обычно хорош как локальный сценарий: вы под один тест (или маленький набор тестов) загружаете ровно тот набор строк, который нужен для проверки. Java-setup через TestEntityManager удобен, когда сценарий небольшой и его проще описать объектами, чем SQL.

Полезно “на пальцах” зафиксировать, что каждый инструмент обещает вам как автору теста:

Источник данных Что даёт Где чаще всего уместен
Java-setup (TestEntityManager) Читаемость на Java-уровне, компилятор подскажет ошибки, удобно создавать граф сущностей 1–3 сущности, важна связь объектов, хочется видеть домен в коде
@Sql (statements / scripts) Точные строки, точные id, точные углы, никакой “магии” генерации сценарии, где важны fixed values, ordering, corner cases
Test-only миграции Flyway Общий устойчивый фон «как воздух», одинаковый для всех тестов справочники и стабильные reference data (категории и т.п.)

И теперь главное правило дня, которое звучит скучно, но экономит вам недели жизни: один набор данных — один источник правды. Если категории у вас «иногда миграцией, иногда SQL-скриптом, иногда Java-хелпером», то рано или поздно вы получите тест, который падает, потому что “в этой ветке категорий 3, а в другой 4”, и будете минут 40 убеждать себя, что проблема в JPA (нет, проблема в вашем зоопарке источников).

3. Java-setup через TestEntityManager

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

Для @DataJpaTest стандартный рабочий инструмент здесь — TestEntityManager. Он особенно хорош, когда нужно поднять 1–3 сущности, показать небольшой граф связей и сразу сделать persistAndFlush(). В таком сценарии тест читается на языке модели: вот категория, вот статья, вот связь между ними.

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

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

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

Есть ситуации, где SQL — это не «низкоуровневое зло», а самый честный способ сказать тесту правду. Когда сама строка в таблице и есть часть сценария, @Sql обычно выигрывает. Фиксированный id, конкретный slug, точный published_at, известный порядок данных, граничная комбинация статусов — всё это в SQL видно сразу и без споров с генераторами. Здесь как раз нормально жёстко задать id = 10: этот dataset принадлежит одному тесту и не притворяется общим baseline.

Если данных совсем мало, inline statements вполне уместны. Когда сценарий длиннее пары команд, честнее вынести его в scripts: файл получает имя, форматирование и читается как маленькая история состояния БД.

Но сила @Sql именно в локальности. Такой setup должен отвечать на вопрос «какое состояние нужно этому тесту», а не «что бы ещё на всякий случай сложить в базу». Как только один script начинает жить как общий воздух для половины suite, это уже не сценарий, а кандидат в baseline migration.

5. Test-only Flyway миграции

Иногда данные нужны не одному тесту, а многим. Тогда @Sql и Java-setup начинают дублировать друг друга, а suite постепенно превращается в сериал «ещё раз вставим категорию java». В такой ситуации test-only миграции — самый здравый источник правды: schema приходит из main migrations, общий справочный фон — из test-only migrations, и любой @DataJpaTest стартует из одного и того же состояния.

Для ContentHub классический кандидат на такой baseline — Category. Это справочник, на который удобно опираться в разных тестах. И здесь надёжнее держаться за естественный ключ: если тесту нужна категория java, лучше искать её по code — например, через findByCode("java") — чем пришивать ко всему suite неявное правило id = 1. Surrogate id может сместиться, а natural key делает зависимость явной.

Главное — не перепутать baseline со скрытым dataset. Как только в «общем фоне» начинают жить опубликованные статьи, вложения и куски workflow, это уже не baseline, а локальный сценарий, который нужно вернуть в @Sql или Java-setup.

6. Как смешивать подходы без каши

В реальном проекте почти никогда не бывает «только Java» или «только SQL». Нормальная стратегия — комбинированная: baseline лежит в test-only миграциях, а локальные сценарии создаются либо Java-setup’ом, либо @Sql. Но важно не смешивать “по настроению”, а смешивать “по роли”. Как только вы смешиваете подходы без ролей, вы получаете тесты, которые не читаются: часть данных “откуда-то пришла”, часть “где-то была вставлена”, и вы уже не понимаете, что именно гарантируется.

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

Пример такого “смешанного, но читаемого” теста: категория приходит из 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: baseline живёт в миграциях, поэтому берём категорию по естественному ключу
        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), и тест делает свою зависимость видимой. Для shared reference data это обычно означает lookup по natural key, а не молчаливую ставку на id=1.

Ошибка №2: дублирование одного и того же набора данных в разных источниках.
Если категория java есть и в baseline миграции, и в base-categories.sql, и ещё в хелпере TestCategories, рано или поздно кто-то исправит имя категории в одном месте, а во втором забудет. Тесты начнут падать “странно”, потому что разные тесты живут в разных версиях реальности. Здесь помогает правило: один тип данных — один источник правды. Даже если очень хочется “на всякий случай” продублировать, лучше не надо.

Ошибка №3: превращение test-only миграций в «свалку всего».
Миграции для тестов — отличная вещь, пока в них лежит маленький и понятный baseline. Но если туда начинают сносить данные под каждый новый тест (“ну это же удобно, оно всегда будет”), baseline перестаёт быть baseline’ом и превращается в скрытую зависимость. Через месяц вы получаете тест, который падает из-за того, что кто-то подправил «общие данные», даже не зная, что ваш тест на них завязан. Хорошая миграция для тестов похожа на справочник: короткая, стабильная и скучная (а скучное в тестах — это комплимент).

Ошибка №4: использование @Sql как универсальной загрузки “всё для всех”.
Большой SQL-файл, который «подходит всем тестам», обычно звучит как экономия времени. На практике он превращается в монолит, который невозможно поддерживать: любое изменение ломает десятки тестов, а вы боитесь удалить “лишнюю строку”, потому что не знаете, кому она нужна. @Sql сильнее всего именно в сценарности: небольшой файл, понятное имя, один сюжет.

Ошибка №5: слишком длинный Java-setup, который скрывает смысл теста.
Java-setup прекрасен, пока он короткий. Но как только половина теста — это заполнение обязательных полей сущности, смысл проверки начинает тонуть в boilerplate. В этот момент очень легко ошибиться и создать данные не такими, как вы думаете. Если вы видите, что подготовка в Java стала длиннее проверки, это повод остановиться и подумать: может, лучше @Sql (где вы явно зададите только нужные колонки), или может ваш сценарий нужно упростить.

1
Задача
Spring Test, 18 уровень, 4 лекция
Недоступна
Локальный Java-setup для одной валюты
Локальный Java-setup для одной валюты
1
Задача
Spring Test, 18 уровень, 4 лекция
Недоступна
Общий baseline для стран и локальный `@Sql` для городов
Общий baseline для стран и локальный `@Sql` для городов
1
Опрос
Схема и миграции, 18 уровень, 4 лекция
Недоступен
Схема и миграции
Data-layer тесты и Flyway
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ