1. embedded database иногда врёт
Пока вы учитесь писать @DataJpaTest, встроенная база выглядит как идеальный напарник: запускается быстро, ничего не нужно поднимать руками, тесты летят как реактивный самолёт. Но у этой идиллии есть побочный эффект: embedded database — это не «маленький PostgreSQL», а вообще другая база, со своим характером, привычками и пониманием слов «уникальность», «сортировка» и «дата».
И вот тут начинается классическая ситуация из жизни: тесты зелёные, вы довольны, а потом приложение подключается к настоящему PostgreSQL — и внезапно выясняется, что какой-то запрос возвращает результаты в другом порядке, миграция ведёт себя иначе, а constraint-ошибка всплывает не там, где вы её ожидали. Это не потому, что Spring «сломался». Это потому, что вы проверяли поведение на «тренажёре», а в реальности вышли на «живой ринг».
Embedded database — это честный инструмент для скорости и обратной связи. Просто важно помнить: она доказывает корректность репозиторной логики в целом, но не всегда доказывает корректность поведения именно PostgreSQL, с которым живёт ваш ContentHub в продакшне.
Здесь важно не промахнуться по масштабу: мы не меняем тип теста и не строим сейчас отдельный инфраструктурный трек. Это всё тот же @DataJpaTest для repository-layer; меняется только вопрос, какая база стоит под ним и когда сама среда становится частью риска.
2. @DataJpaTest и подмена DataSource
@DataJpaTest: откуда берётся база данных
Чтобы осознанно управлять выбором базы, полезно понимать: @DataJpaTest — это slice. Он поднимает JPA-инфраструктуру и репозитории, но при этом Spring Boot пытается сделать тест максимально «лёгким» и самостоятельным. Один из способов — автоматически подменить ваш DataSource на встроенный, если он доступен в classpath.
Практически это выглядит так: у вас в приложении в application.yml настроен PostgreSQL, но в тестах, если рядом лежит dependency на embedded DB (часто H2), Boot говорит: «О, у нас есть in-memory база, давай я подключу её, чтобы тесты не зависели от внешнего сервиса». И подключает.
Если представить это в виде небольшой схемы, получится примерно так:
flowchart TD
A["@DataJpaTest стартует"] --> B{Есть embedded DB driver в classpath?}
B -- "Да" --> C["Boot заменяет DataSource на embedded database"]
B -- "Нет" --> D["Boot использует DataSource из конфигурации теста/проекта"]
C --> E["Тесты бегут быстро, но база может отличаться от PostgreSQL"]
D --> F["Тесты ближе к прод-среде, но требуют реальной БД"]
И вот здесь появляется ключевая мысль лекции: иногда вам нужно не просто «какая-то база для теста», а именно ваша база (например PostgreSQL), потому что риск дефекта живёт в особенностях конкретного диалекта, типов и поведения.
@AutoConfigureTestDatabase: управление подменой DataSource
Аннотация @AutoConfigureTestDatabase — это не про JPA, не про репозитории и не про «супер-режим тестов». Это очень приземлённая штука: она управляет тем, будет ли Spring Boot пытаться заменить DataSource на embedded database.
Важный момент: @DataJpaTest обычно и так включает поведение «подменить базу, если можно». Поэтому, когда вы добавляете @AutoConfigureTestDatabase(replace = NONE), вы не «включаете real DB», вы запрещаете подмену.
У @AutoConfigureTestDatabase есть режимы Replace. Нам сейчас важны в первую очередь два: дефолтный (подмена разрешена) и NONE (подмена запрещена). Чтобы не держать это в голове как заклинание, удобно один раз увидеть это в таблице:
| Режим Replace | Что делает Boot в тесте | Простая интерпретация |
|---|---|---|
| ANY | Ставит embedded DB, если она доступна | Если можно упростить — упростим |
| NONE | Не заменяет DataSource | Тестируйся на той базе, что настроена |
На практике переход выглядит так: было (embedded path по умолчанию), стало (real DB path через конфигурацию).
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
@DataJpaTest // Поднимаем только JPA-срез: репозитории и инфраструктуру, без всего приложения
class ArticleRepositoryDataJpaTest {
// Здесь Boot может подменить DataSource на embedded БД (если она есть в classpath)
}
И вариант «не подменять»:
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
@DataJpaTest // Всё ещё JPA-slice, а не полноценный @SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // Запрещаем Boot подменять DataSource на embedded БД
class ArticleRepositoryRealDbTest {
// После этого DataSource будет взят из вашей конфигурации (например, test-профиля)
}
Обратите внимание на важную психологическую деталь: это всё ещё @DataJpaTest. Вы не превратили тест в @SpringBootTest. Вы всё ещё тестируете persistence-layer узко и относительно быстро. Вы просто поменяли «какая база стоит под ним».
И это не новый дефолт для всего suite. Большая часть repository-тестов по‑прежнему прекрасно живёт на embedded path и даёт быструю обратную связь; replace = NONE нужен там, где риск уже сидит именно в поведении конкретной базы.
3. replace = NONE на практике
Самая полезная мысль здесь немного скучная, но очень инженерная: при переходе на replace = NONE логика теста обычно не меняется. Вы не должны внезапно переписывать assertions, добавлять новые проверки или менять AAA-структуру. Вы меняете не тест, а среду, в которой он исполняется.
Если тест проверял «репозиторий находит опубликованную статью по slug», то он и дальше проверяет это же. Просто теперь вы получаете ответ не от H2/HSQLDB/Derby, а от реального PostgreSQL (или от той БД, которая у вас настроена как настоящая база проекта).
Вот пример в стиле ContentHub, максимально приземлённый. Пусть у нас есть метод, который читает статью по slug и статусу:
import java.util.Optional;
import org.springframework.data.repository.Repository;
interface ArticleRepository extends Repository<Article, Long> {
// Ищем статью по бизнес-ключу (slug) и статусу публикации
Optional<Article> findBySlugAndStatus(String slug, ArticleStatus status);
}
А теперь тест. Он одинаково выглядит и для embedded, и для real DB path:
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.assertj.core.api.Assertions.assertThat;
class ArticleRepositoryExampleTest {
@Autowired
ArticleRepository repository; // Репозиторий тестируем напрямую, без сервиса/контроллера
@Test
void findsPublishedArticleBySlug() {
// Действие: вызываем метод репозитория так, как это сделает реальный код
var result = repository.findBySlugAndStatus(
"spring-boot-testing",
ArticleStatus.PUBLISHED
);
// Проверка: для учебного примера достаточно убедиться, что статья найдена
assertThat(result).isPresent();
}
}
Да, в реальности вам нужно подготовить данные через TestEntityManager.persist(...), потом сделать flush() и clear(). Но смысл в другом: сам тест не становится «другим тестом». Он просто получает более сильное доказательство.
И ещё один момент, который часто удивляет новичков: тесты с replace = NONE в @DataJpaTest всё равно обычно транзакционные и откатываются. То есть вы не «засоряете» базу бесконечными данными после каждого теста — пока у вас нет намеренного коммита и специальных настроек.
4. Чем embedded DB отличается от PostgreSQL
Когда говорят «embedded database может врать», это не означает «она случайно возвращает неправильные данные». Обычно речь о том, что некоторые граничные нюансы ведут себя иначе. В реальном проекте именно эти нюансы и становятся регрессиями, потому что их трудно увидеть глазами в коде и легко пропустить в ревью.
Один из самых частых классов расхождений — это DDL и constraints. Ваши миграции (Flyway) и схема в PostgreSQL могут быть богаче: типы, ограничения, индексы, нюансы varchar-длин, поведение unique constraints. Embedded DB иногда прощает то, что PostgreSQL не простит, или наоборот: «ругается» на DDL, который PostgreSQL принимает спокойно. Итог — вы либо получаете ложную уверенность, либо ложную тревогу.
Вторая большая зона — типы даты/времени и их точность. PostgreSQL и embedded базы по-разному хранят timestamps, по-разному округляют микросекунды/наносекунды, по-разному относятся к timezone. Если в ContentHub порядок публикаций зависит от publishedAt, вы можете поймать очень неприятный баг: сортировка «плавает», потому что в одной базе время хранится с одной точностью, а в другой — с другой. Особенно коварно, когда вы создаёте много записей «почти одновременно».
Третья зона — сортировка, NULL и стабильность порядка. PostgreSQL имеет свои правила сортировки NULL значений (например, в некоторых режимах NULL может быть «самым большим» и уходить в конец/начало в зависимости от направления сортировки). Embedded DB может сортировать иначе. Если ваш query строит public feed и вы ожидаете стабильный порядок, различия в NULLS FIRST/LAST или в сравнении строк могут проявиться только на реальной базе.
Четвёртая зона — генерация ID и последовательности. JPA старается абстрагировать вас от того, как именно генерируются ключи, но на практике IDENTITY, SEQUENCE и «как база реально создаёт значения» иногда влияет на порядок вставки, на то, когда именно появляется ID, и на некоторые граничные сценарии вокруг batch-операций. Это редко «ломает всё», но иногда ломает тесты, которые ожидали один порядок действий, а получили другой.
И наконец, пятая зона — диалект и SQL-особенности. JPQL обычно переносим, но generated SQL (особенно для pagination, limit/offset, сортировки, некоторых функций) зависит от диалекта. В embedded DB вы могли получить один SQL и поведение, а в PostgreSQL — другой. Если вы тестируете важный запрос (например, публичную выдачу опубликованных статей), эти различия как раз и должны быть пойманы максимально близко к реальности.
Важно: не нужно запоминать этот абзац как список заклинаний. Достаточно одной идеи — у разных баз разные привычки, и replace = NONE позволяет проверить поведение на базе, которая важна именно вашему проекту.
5. Минимальная конфигурация real DB
Сама аннотация @AutoConfigureTestDatabase(replace = NONE) не создаёт вам PostgreSQL из воздуха. Она лишь говорит Boot: «не подменяй datasource». Поэтому вы должны понимать, откуда возьмётся DataSource, если embedded-замены больше нет.
Обычно в учебном проекте это решается через test-профиль, который вы уже проходили: application-test.yml в src/test/resources или src/main/resources (в зависимости от правил проекта), и явное включение профиля test. Здесь мы не обсуждаем, как поднять PostgreSQL (Docker, Compose и так далее) — мы просто фиксируем принцип: тест подключится к тому datasource, который вы настроили.
Условный пример src/test/resources/application-test.yml для PostgreSQL может выглядеть так:
spring:
datasource:
# JDBC URL тестовой БД: тесты будут подключаться именно сюда
url: jdbc:postgresql://localhost:5432/contenthub
username: contenthub
password: contenthub
jpa:
hibernate:
# В тестах полезно валидировать схему, чтобы ловить расхождения с миграциями
ddl-auto: validate
Если вы используете профили (а в курсе мы их используем), тесту часто нужен @ActiveProfiles("test"), чтобы этот YAML реально применился:
import org.springframework.test.context.ActiveProfiles;
@ActiveProfiles("test") // Включаем test-профиль, чтобы подхватился application-test.yml
class SomeDataTest {
// Здесь не показаны аннотации тестового среза — важен именно профиль
}
Ещё раз: сейчас важно не «всё настроить идеально», а понять, что replace = NONE делает тест более честным, но требует от вас более честной среды. Иначе контекст может просто не подняться, потому что он не нашёл рабочий datasource.
6. Типичные ошибки при replace = NONE
Ошибка №1: воспринимать replace = NONE как “сделай тест профессиональным”.
Иногда кажется, что достаточно добавить одну аннотацию — и всё, вы «взрослый разработчик». На практике это просто переключатель среды. Если тест сам по себе слабый (например, проверяет только hasSize(2) без проверки состава и порядка), то на PostgreSQL он останется таким же слабым — просто будет медленнее исполняться и громче падать.
Ошибка №2: путать “реальная база” с “полный контекст приложения”.
@DataJpaTest с replace = NONE — это всё ещё data-slice. Вы не тестируете контроллеры, сервисы и security. Если тест вдруг начинает «неожиданно требовать» половину приложения, значит вы случайно вышли за границы слоя (например, импортировали лишнюю конфигурацию или потянули зависимости не из persistence-мира).
Ошибка №3: менять assertions, чтобы тест “проходил” на новой базе.
Очень опасная привычка: переключились на PostgreSQL, тест упал из‑за реального поведения, и вы вместо анализа просто «подкрутили ожидание». Так легко легализовать баг. Если меняется результат — сначала сформулируйте, почему. Это дефект? Особенность? Неправильный order-by? Проблема с NULL? Только потом меняйте тест или код.
Ошибка №4: забыть, что теперь datasource берётся из конфигурации — и получить падение контекста.
На embedded path многое работало «из коробки», и вы могли не задумываться, откуда берётся база. С replace = NONE тест честно попытается подключиться к реальному datasource. Если конфигурации нет или база недоступна, тест упадёт ещё на старте. Это нормально: инструмент не сломан, просто вы попросили реальность.
Ошибка №5: переносить на real DB path всё подряд, включая самые простые запросы.
Даже если вы хотите «максимальной честности», цена тестов важна. Реальная база обычно медленнее, требует больше дисциплины окружения и может ухудшить скорость обратной связи. Гораздо разумнее сначала понять, что именно даёт риск, а потом уже решать, какие тесты действительно должны жить на replace = NONE. (Стоп — дальше мы сейчас не идём, чтобы не забежать вперёд темы дня.)
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ