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 появился, то любой следующий тест становится менее предсказуемым: один тест падает “странно”, другой “случайно проходит”, третий перестаёт быть воспроизводимым. Это тот редкий случай, когда “потом” часто превращается в “никогда”, а цена растёт как подписка на стриминг каждый год.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ