JavaRush /Курсы /Spring Test /Схема БД в data-layer тестах

Схема БД в data-layer тестах

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

1. Схема БД в data-layer тестах

Когда мы слышим «repository-тест», мозг иногда рисует наивную картинку: “я проверяю метод репозитория, значит тестирую только Java-код”. Но data-layer — это слой, который физически живёт на границе двух миров: мира объектов (Article, Category) и мира таблиц/колонок/ограничений. И тест, даже самый маленький, неизбежно проходит через эту границу.

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

В проекте ContentHub это особенно очевидно, потому что у нас есть таблицы (и связанные сущности) articles, categories, article_attachments, есть уникальность slug, есть not-null поля, есть связи и внешние ключи. Любой repository-тест опирается на то, что эти таблицы реально существуют и устроены так, как мы думаем. И вот тут происходит важный методический поворот: схема БД — не «инфраструктура где-то рядом», а часть контракта, который мы обязаны проверять тестами.

Схема БД простыми словами

Слово «схема» иногда звучит как что-то из мира взрослых: DBA, диаграммы, скука, кофе без сахара. На самом деле схема — это просто формализованное описание того, как база хранит данные. Если упростить максимально, схема отвечает на вопрос: “какие коробки у нас есть, какие у них отделения, и какие правила хранения”.

Таблица — это коробка. Колонка — отделение в коробке. Тип колонки — это размер и форма отделения (в какое можно положить Instant, а в какое — только число). Ограничения (NOT NULL, UNIQUE, FOREIGN KEY) — это правила склада: сюда нельзя класть пустую коробку, здесь нельзя хранить два одинаковых слога, а вот эта запись не может ссылаться на категорию, которой не существует.

Почему это важно именно для тестов? Потому что JPA-сущность — это не схема. Сущность — это наша «версия реальности» на стороне Java. А схема — это реальность на стороне БД. И если эти две реальности начинают расходиться, data-тесты становятся тем самым моментом истины, когда приложение перестаёт притворяться, что «всё нормально».

Repository-тест как контракт Java и SQL

Очень полезно научиться смотреть на data-layer тест как на проверку контракта между тремя вещами: вашим Java-кодом (entities), вашим ORM-маппингом (аннотации @Entity, @Column, @ManyToOne) и фактической SQL-схемой (таблицы, колонки, constraints). Репозиторий сидит сверху и кажется «просто интерфейсом», но по факту он работает только если весь этот треугольник совпадает.

Чтобы это ощущалось не как философия, а как инженерная реальность, представим путь любого @DataJpaTest в упрощённом виде:

flowchart TD
    A["JUnit 6 запускает тест"] --> B["Поднимается Spring Data JPA slice"] 
    B --> C["Создаётся DataSource и подключение к БД"]
    C --> D["Схема БД должна существовать и совпадать с ожиданиями"]
    D --> E["Hibernate/JPA маппит сущности на таблицы/колонки"]
    E --> F["Repository выполняет запросы / persist"]
    F --> G["Assertions: мы проверяем результат"]

Здесь ключевой узел — пункт про схему. Если схема не поднялась, или поднялась не так, как нужно, всё, что ниже, может даже не начаться. Тест может упасть «на входе», ещё до выполнения вашего @Test метода. И вот это место чаще всего удивляет новичков: «Почему мой тест даже не начал выполняться?!». Ответ простой: потому что для data-layer теста “запуск окружения” — уже часть проверки.

Два типа поломок: запрос и схема

Самая полезная практическая привычка сегодня — научиться различать два класса проблем. Внешне они оба выглядят как «упал тест», но диагноз и лечение у них разные. Один класс проблем про то, что наш Java/ORM-код неправильно обращается к базе. Второй — про то, что база (её структура) не соответствует тому, что мы про неё думаем.

Ниже — табличка, которую удобно держать в голове как мини-«рентген» падений data-тестов:

Что сломалось Где обычно падает Как выглядит ощущение Типовая причина
Запрос / маппинг Во время выполнения persistAndFlush(), find...(), repository.save() или конкретного query Тест стартовал, дошёл до метода, а потом свалился Неверный JPQL, неправильный @JoinColumn, не тот fetch, конфликт типов, неверная сортировка
Схема При поднятии тестового контекста или на самом первом flush (когда БД начинает проверять реальность) “ApplicationContext failed to start” или “relation/column not found” Таблицы/колонки/constraints не созданы, не тот порядок изменений, не совпало имя/тип колонки

Обратите внимание на тонкость: ошибка схемы может проявиться и “позже”, при flush. Например, контекст поднялся, потому что таблицы есть, но конкретной колонки нет — и вы увидите это только когда впервые попытаетесь туда что-то записать. Поэтому мы так часто в data-тестах используем persistAndFlush: он заставляет базу «сказать правду» раньше, чем мы успеем построить ложную уверенность.

Schema drift в ContentHub

Schema drift — это красивое словосочетание, которое на практике означает очень бытовую историю: “мы поменяли код, но забыли поменять базу” или “мы поменяли базу, но забыли поменять код”. И да, это происходит даже у сильных команд, потому что это не вопрос интеллекта, а вопрос дисциплины и процесса.

Представим типичный день разработчика. Он решил, что в Article нужно хранить дату публикации в колонке published_at. В Java он добавил поле publishedAt и, возможно, даже аккуратно подписал @Column(name = "published_at"). Тесты компилируются, IDE зелёная, настроение хорошее, жизнь прекрасна. А потом в репозитории появляется новый data-тест, который сохраняет опубликованную статью… и база внезапно сообщает: «какая ещё published_at?».

В ContentHub drift особенно коварен, потому что у нас есть важные инварианты в базе. slug обязан быть уникальным. title, summary, body и category обязательны. Вложения должны ссылаться на статью. Если миграция не добавила unique constraint, то тесты могут “случайно проходить” на небольших данных, а потом в production база примет два одинаковых slug — и вы получите баг уровня “почему публичная статья открывается не та?”. Если наоборот unique constraint есть, а в Java-коде slug генерируется без учёта коллизий, то база честно начнёт падать — и хорошо, что это случится в тестах, а не у пользователей.

Schema drift — это не «внутренняя проблема БД». Это продуктовый дефект data-layer. И data-тесты нужны не только чтобы сказать “метод репозитория работает”, а чтобы сказать “наш data-layer вообще живёт в согласии с тем, как устроена база”.

Падение теста на старте как сигнал

У новичка есть естественная реакция: если тест упал до выполнения @Test метода, значит “JUnit не запустился” или “что-то сломалось в окружении”. Хочется быстро выключить что-то, поставить заглушку, отключить миграции, лишь бы дошло до “настоящих” assertions. Но в data-layer эта реакция — ловушка.

Подумайте об этом так: если контекст data-slice не может подняться из-за схемы, то это означает, что в реальной жизни приложение тоже не сможет нормально работать с базой. Да, production-окружение может отличаться, но сама идея такая: если таблицы/колонки/constraints не совпадают с ожиданиями, repository-слой не имеет шансов быть корректным.

Поэтому падение на старте — это не шум. Это тест, который говорит: «я даже не начал проверять вашу бизнес-идею, потому что фундамент отсутствует». Это примерно как пытаться проверить, удобно ли вам сидеть на стуле, когда стул ещё не собран и лежит кучей деталей. И нет, это не проблема теста. Это проблема “стул не собран”.

В зрелой тестовой культуре такие падения воспринимаются как “быстро поймали критический дефект” — и это повод радоваться, а не раздражаться. Тест сэкономил вам время, нервы и пару постов в корпоративном чате в стиле “у кого упало, у меня упало, а почему упало?”.

2. Примеры на ContentHub

CategorySchemaTest и «не та колонка»

Сейчас будет два коротких примера, которые специально выглядят почти смешно простыми. Это сделано намеренно: мы хотим увидеть, что даже самый «плоский» data-тест на одну сущность на самом деле зависит от схемы БД. Не от бизнес-логики, не от контроллера, а именно от таблицы, колонок и их корректности.

Первый пример — проверяем, что Category действительно может быть сохранена. Это не “мы тестируем persist как фреймворк” (мы не фанаты тестировать Spring Data ради Spring Data), а “мы проверяем, что у нас существует таблица категорий и она совместима с маппингом”.

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 // Поднимаем только JPA-срез контекста: DataSource, EntityManager, репозитории и т.п.
class CategorySchemaTest {

    @Autowired
    private TestEntityManager em; // Упрощённый доступ к persist/flush в тестах

    @Test
    void shouldPersistCategoryWhenSchemaMatches() {
        Category category = new Category();
        // Важно заполнять поля, которые в схеме помечены как обязательные (NOT NULL)
        category.setCode("java");
        category.setName("Java");

        // persistAndFlush заставляет базу "сказать правду" сразу: проверяются таблицы/колонки/constraints
        em.persistAndFlush(category);

        // Если id появился — значит insert прошёл и схема не конфликтует с маппингом
        assertThat(category.getId()).isNotNull();
    }
}

Здесь важно не то, что “id стал not null” (хотя это тоже полезно). Важно то, что тест вообще смог сделать persistAndFlush(). Он уже доказал: таблица существует, колонки существуют, типы колонок устраивают БД, ограничения не противоречат данным. Иными словами — схема и маппинг не поссорились.

Второй пример — микроскопический фрагмент entity. Он показывает место, где drift чаще всего проявляется: несоответствие имени колонки.

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;

import java.time.Instant;

@Entity // Эта сущность должна быть сопоставима с реальной таблицей в БД
class Article {

    @Id
    private Long id; // Идентификатор: ожидаем, что в таблице есть первичный ключ (или хотя бы колонка под него)

    @Column(name = "published_at") // Имя колонки должно 1-в-1 совпадать с тем, что реально создано миграциями
    private Instant publishedAt; // Тип тоже важен: например, под Instant обычно ожидаем timestamptz/timestamp
}

Теперь представьте, что в реальной схеме колонка называется не published_at, а, скажем, publishedAt или published_time. Для базы это абсолютно разные имена. Итог обычно очень “дружелюбный”: что-то вроде “column not found” или “unknown column”. И это не баг теста. Это баг рассинхронизации.

Чтобы ощутить, что такое “схема — источник правды”, полезно увидеть глазами и SQL-кусочек (даже если вы пока не любите SQL — ничего, он привыкнет). Такой фрагмент схемы мог бы выглядеть так:

create table articles (
  id bigserial primary key,          -- PK + автогенерация id (примерно то, что ожидают многие маппинги)
  published_at timestamptz            -- имя и тип должны совпадать с @Column и типом поля
);

Если в SQL нет published_at, а в entity он есть (или наоборот), data-тесты рано или поздно это проявят. И это, на самом деле, одна из главных ценностей data-layer suite: он работает как сигнализация от “мы уже разъехались, и это начинает быть опасно”.

3. Типичные ошибки при проверке схемы

Ошибка №1: считать схему «не частью теста» и пытаться чинить падение только на уровне assertions.
Когда тест падает из-за отсутствующей таблицы или колонки, у него нет шансов “пройти после правки assertThat”. Это всё равно что переклеивать наклейку “всё хорошо” на приборной панели, когда двигатель дымит. Правильная привычка здесь — всегда задавать себе вопрос: тест упал в момент выполнения метода или ещё при старте контекста? Если при старте, это очень часто история про схему, а не про вашу проверку.

Ошибка №2: «временно» включить автогенерацию схемы Hibernate, чтобы тесты проходили, и забыть.
Это особенно коварно. Вы включаете автоматическое создание таблиц по entity, и тесты действительно начинают “радостно зеленеть”. Но вы только что перестали тестировать реальную схему, которая живёт через миграции. В результате вы проверяете абстрактный мир “как Hibernate бы сделал”, а не реальный мир “как база реально устроена”. Data-layer suite превращается в хорошее настроение без доказательств.

Ошибка №3: путать “сломалась схема” и “сломался запрос”, а потом диагностировать не то.
Если проблема в схеме, вы можете часами читать JPQL-запрос и искать лишнюю запятую, хотя запрос вообще не дошёл до выполнения: база не поднялась или таблица не существует. И наоборот, если схема корректна, но query неверный, можно бесконечно пересобирать миграции, хотя проблема в логике выборки. Полезная дисциплина — сначала понять, на какой стадии упали: старт контекста, flush, выполнение query или assertion.

Ошибка №4: думать, что “таблицы где-то есть”, и не делать flush, потому что “и так же работает”.
Без flush вы рискуете проверять не базу, а persistence context. Managed-объект в памяти может выглядеть идеально, даже если база при записи упала бы на constraint. persistAndFlush звучит чуть страшнее, но он честнее: он заставляет базу участвовать в разговоре сразу, а не в конце, когда вы уже построили полтеста на песке.

Ошибка №5: относиться к schema drift как к мелочи и откладывать исправление “на потом”.
Data-layer — это фундамент. Если drift появился, то любой следующий тест становится менее предсказуемым: один тест падает “странно”, другой “случайно проходит”, третий перестаёт быть воспроизводимым. Это тот редкий случай, когда “потом” часто превращается в “никогда”, а цена растёт как подписка на стриминг каждый год.

1
Задача
Spring Test, 18 уровень, 0 лекция
Недоступна
Сохранение `TrainingTopic` через `TestEntityManager`
Сохранение `TrainingTopic` через `TestEntityManager`
1
Задача
Spring Test, 18 уровень, 0 лекция
Недоступна
Чтение `WarehouseCell` через репозиторий после `flush` и `clear`
Чтение `WarehouseCell` через репозиторий после `flush` и `clear`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ