Модульные тесты проверяют логику в изоляции, но не говорят, правильно ли приложение работает с базой данных, кешем или внешними сервисами. Для этого нужны интеграционные тесты — с реальными зависимостями.
Модульный тест держит в рамке один сервис: репозиторий подменён, SQL не выполняется. Как только вместо заглушки встаёт настоящий PostgreSQL в контейнере, рамка растягивается на всю цепочку — это уже интеграционный тест. Он проверяет ровно то, чего не видит заглушка: сам запрос, раскладку колонок по полям объекта и ограничения базы.
Проблема H2 и моков
Самый простой способ протестировать репозиторий — подключить встроенную базу данных H2. Это быстро, не требует Docker, тесты запускаются везде. Но у подхода есть серьёзный изъян: H2 — не PostgreSQL.
Различия накапливаются незаметно: синтаксис SQL-функций, поведение при конфликтах уникальности, работа JSON-типов, оконные функции. Тест проходит на H2, а на продакшне падает — потому что диалекты отличаются.
То же с моками базы данных: мок проверяет, что метод был вызван с нужными аргументами, но не проверяет сам SQL-запрос, маппинг результата и поведение транзакции. Интеграционный тест поднимает тот же PostgreSQL, что работает в продакшне, и убирает целый класс ошибок.
@SpringBootTest: полный контекст
@SpringBootTest поднимает весь контекст приложения — точно так же, как при запуске. Это наиболее тяжёлый вариант теста, зато самый близкий к реальной работе.
@SpringBootTest
@AutoConfigureMockMvc
class OrderApiTest {
@Autowired
MockMvc mockMvc;
@Test
void createsOrder() throws Exception {
mockMvc.perform(post("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"productId": "abc", "quantity": 2}
"""))
.andExpect(status().isCreated());
}
}
По умолчанию сервер не стартует — вместо этого используется MockMvc. Если нужен реальный HTTP-порт, добавьте webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT.
Полный контекст оправдан, когда важно проверить сквозной путь: HTTP → сервис → база данных → ответ. Для изолированной проверки одного слоя есть срезы.
Срезы: @DataJpaTest и @WebMvcTest
Срез (slice) — это урезанный контекст Spring, в котором поднимается только нужный слой. Остальные бины не загружаются, поэтому срез стартует значительно быстрее полного контекста.
Один и тот же путь вызова в трёх контекстах: в срезе контроллера он упирается в заглушку и проходит через фильтр Security, в срезе репозитория веба нет вовсе, и только полный контекст доводит запрос до настоящей базы.
@DataJpaTest поднимает только слой работы с данными: репозитории, EntityManager, транзакции. По умолчанию он подменяет datasource приложения на встроенную базу — обычно H2, если она лежит в тестовых зависимостях.
Подставить вместо неё контейнер с PostgreSQL можно, но здесь есть ступенька, о которую спотыкаются почти все. Подменой заведует @AutoConfigureTestDatabase, и до Spring Boot 3.4 он срабатывал всегда: контейнер честно стартовал, а тест всё равно уходил на H2 — спасал только явный @AutoConfigureTestDatabase(replace = Replace.NONE). С 3.4 режим по умолчанию NON_TEST: datasource, пришедший от @ServiceConnection, @DynamicPropertySource или URL вида jdbc:tc:, больше не подменяется, и дописывать ничего не нужно.
@DataJpaTest
class ProductRepositoryTest {
@Autowired
ProductRepository repository;
@Test
void findsActiveProducts() {
var saved = repository.save(new Product("Widget", true));
var found = repository.findAllActive();
assertThat(found).contains(saved);
}
}
@WebMvcTest поднимает только слой контроллеров: @Controller, @ControllerAdvice, фильтры, MockMvc. Сервисы и репозитории в этот контекст не входят — их нужно мокировать через @MockitoBean.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
MockMvc mockMvc;
@MockitoBean
OrderService orderService;
@Test
void returnsBadRequestOnMissingBody() throws Exception {
mockMvc.perform(post("/orders"))
.andExpect(status().isBadRequest());
}
}
Оговорка, из-за которой этот пример у многих не повторяется: срез тянет за собой и автоконфигурацию Spring Security вместе с её фильтрами. В проекте с spring-boot-starter-security тот же post("/orders") вернёт не 400, а 403 — не хватило CSRF-токена (или 401, если запрос не аутентифицирован). Чтобы проверять именно контроллер, запрос подписывают: .with(csrf()) на запросе и @WithMockUser на тесте.
Testcontainers: реальный PostgreSQL в Docker
Testcontainers — это Java-библиотека, которая запускает Docker-контейнеры прямо из кода теста. Контейнер стартует перед тестовым классом и останавливается после него — а при желании живёт и дольше, об этом ниже. Никакой настройки окружения вручную — достаточно установленного Docker.
// build.gradle.kts
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:postgresql")
Третья строка нужна не для красоты: аннотации @Testcontainers и @Container из примеров ниже живут именно в junit-jupiter. spring-boot-testcontainers даёт @ServiceConnection, но связку с JUnit не приносит — без неё оба листинга просто не соберутся.
@SpringBootTest
@Testcontainers
class OrderRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
OrderRepository repository;
@Test
void savesAndReadsOrder() {
var order = repository.save(new Order(UUID.randomUUID(), "PENDING"));
assertThat(repository.findById(order.id())).isPresent();
}
}
@Container + static означает, что контейнер создаётся один раз на класс. @DynamicPropertySource передаёт URL, логин и пароль контейнера в контекст Spring до его старта.
Работают эти аннотации только в паре: запускает контейнеры расширение JUnit, которое включает @Testcontainers на классе. Забыли её — контейнер в поле просто не стартует, и тест упадёт на попытке достать у него порт. Чаще всего так и происходит при выносе контейнера в базовый класс: аннотацию оставляют на старом месте.
Чтобы не платить временем, контейнер поднимают один раз на весь прогон: статическое поле в базовом тестовом классе (контейнер живёт, пока живёт JVM) или переиспользуемый контейнер (withReuse(true) с testcontainers.reuse.enable=true в ~/.testcontainers.properties) — он переживает и перезапуски тестов на машине разработчика.
Схема накатывается миграциями один раз, а тесты чистят за собой данные или выполняются в откатываемой транзакции. Старт — секунды, и платят их один раз.
Запросы к PostgreSQL в контейнере всё же дороже, чем к H2: та живёт внутри той же JVM, а здесь каждый запрос идёт через сокет к отдельному процессу, и на тесте из сотни мелких запросов это видно. Но рядом со стартом контейнера разница теряется — и окупается тем, что тест проверяет настоящий SQL, а не его пересказ на диалекте H2.
Откуда в пустом контейнере таблицы
Контейнер поднимается с пустой базой: в образе postgres:16-alpine нет ни одной вашей таблицы. Вопрос, который здесь возникает у каждого и почти нигде не отвечен: кто их создаёт?
Ответ: те же миграции, что и в проде. Инструмент миграций (Liquibase или Flyway) — часть приложения, и при старте контекста Spring Boot запускает его до всего остального. То есть ничего специально настраивать не нужно: контейнер стартовал, контекст поднялся, миграции накатились, тест пошёл. И побочная польза огромна: каждый интеграционный тест заодно проверяет, что миграции применяются на чистой базе.
А вот что нужно проверить в тестовом профиле — одну настройку, которая всё это тихо отменяет:
# src/test/resources/application.yml
spring:
jpa:
hibernate:
ddl-auto: validate # НЕ create и не create-drop
flyway:
enabled: true
ddl-auto: create — самая дорогая ловушка темы. С ней схему создаёт не миграция, а сам Hibernate, по вашим сущностям: таблицы появляются, тесты зелёные, миграции не запускаются вовсе. Расхождение между сущностью и миграцией становится невидимым до прода, где схему создавали миграции, и первый же запрос падает на отсутствующей колонке. С validate наоборот: Hibernate сверяет сущности с тем, что создали миграции, и тест не стартует при расхождении — это единственная бесплатная проверка соответствия кода и схемы до выката.
Отдельно: справочные данные (статусы, типы, тарифы), которые в проде вставляет миграция, появятся в тестовой базе сами и должны там остаться. Это важно помнить, когда дойдёте до очистки данных между тестами: если вычистить всё подряд, следующий тест не найдёт справочника, который в проде есть всегда.
Как проверять сами миграции всерьёз — на непустой базе, со временем и блокировками — разбирает раздел «Глубже» ниже.
Изоляция между тестами при одном контейнере
Контейнер один на весь прогон — значит, база одна на все тесты. Первый тест создал заказ, второй посчитал заказы и получил не ту цифру; третий проходит один и падает в наборе. Фраза «тесты чистят за собой» отвечает только «что», и стоит разобрать «как», потому что у трёх способов разные границы.
Откат транзакции. @Transactional на тестовом классе: Spring открывает транзакцию перед тестом и откатывает после, база остаётся чистой. Быстро, ничего не надо писать, и это выбор по умолчанию для тестов репозиториев. Ограничений три, и все заметные:
- Не проверяет поведение после фиксации. Отложенные ограничения, срабатывания на фиксации, всё, что происходит в момент
COMMIT, в таком тесте не случается вовсе. Тест зелёный, прод падает на нарушении уникальности. - Не работает через настоящий HTTP. Тест с реальным портом (
RANDOM_PORTиTestRestTemplate) обрабатывает запрос в другом потоке, со своей транзакцией: откат тестовой транзакции к этим данным отношения не имеет, и они остаются в базе. - Прячет ошибки сохранения. Пока транзакция не зафиксирована,
save()может вообще не дойти до базы, потому что запись отложена до сброса. Тест, который «сохранил и прочитал», читает из памяти, а не из базы. Лечится принудительным сбросом и очисткой перед проверкой.
Очистка таблиц после теста. Честный способ для всего, что проверяет поведение после фиксации: тест пишет по-настоящему, а уборщик возвращает базу в исходное состояние. Наивный вариант — repository.deleteAll() в @AfterEach — ломается сразу: порядок удаления важен из-за внешних ключей, о дочерних таблицах легко забыть, и это десятки запросов. Правильный вариант — один запрос на все таблицы:
@Component
public class DatabaseCleaner {
private final JdbcTemplate jdbc;
private List<String> tables;
public DatabaseCleaner(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public void clean() {
if (tables == null) {
tables = jdbc.queryForList("""
SELECT quote_ident(tablename)
FROM pg_tables
WHERE schemaname = 'public'
AND tablename NOT IN ('flyway_schema_history', 'order_status_ref')
""", String.class);
}
jdbc.execute("TRUNCATE TABLE " + String.join(", ", tables)
+ " RESTART IDENTITY CASCADE");
}
}
Что здесь важно по частям. TRUNCATE перечисляет все таблицы в одном запросе — тогда внешние ключи между ними не мешают, и CASCADE нужен лишь на случай ссылок извне списка. RESTART IDENTITY сбрасывает счётчики, иначе идентификаторы растут через весь прогон и тесты, ожидающие id = 1, ведут себя загадочно. Таблица истории миграций и справочники исключены — их наполняет миграция, и вычистить их означает сломать всё следующее. Список таблиц читается из базы один раз: новая таблица попадает в уборку сама, без правки кода.
Уникальные данные вместо очистки. Третий способ: ничего не удалять, а делать каждый тест независимым по данным — свой идентификатор, свой номер заказа, свой адрес почты. Тогда порядок тестов не важен в принципе, а прогон можно смело распараллеливать: тесты друг друга не видят. Это единственный способ, который выживает при параллельном прогоне в несколько потоков на одной базе. Цена: любая проверка «а сколько всего записей» становится неверной, поэтому считать надо всегда с условием по своему ключу, а не count() по таблице.
Как выбрать: репозиторий и запросы — откат транзакции; всё, что проходит через настоящий HTTP или проверяет поведение после фиксации — очистка таблиц; параллельный прогон — уникальные данные. Смешивать первое и второе в одном классе не стоит: тест с @Transactional внутри класса с уборщиком создаёт путаницу, в которой непонятно, откуда взялись или куда пропали данные.
@ServiceConnection: без ручной настройки URL
Spring Boot 3.1 ввёл @ServiceConnection — аннотацию, которая убирает шаблонный @DynamicPropertySource. Spring сам распознаёт тип контейнера и настраивает нужные свойства.
@SpringBootTest
@Testcontainers
class OrderRepositorySharedContainerTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
// @DynamicPropertySource больше не нужен
@Autowired
OrderRepository repository;
}
Без неё три строки registry.add(...) пишут руками в каждом тестовом классе, и стоит контейнеру сменить имя свойства или порт, как они тихо расходятся. @ServiceConnection убирает это: Spring Boot сам настраивает datasource, Redis, RabbitMQ и другие поддерживаемые типы контейнеров по самому контейнеру.
Для Redis это работает точно так же:
@Container
@ServiceConnection
static GenericContainer<?> redis =
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
Здесь стоит остановиться, потому что слово «поддерживаемый тип» решает, заработает ваш пример или нет. Spring Boot определяет, какие свойства настраивать, двумя путями. Первый — по классу контейнера: увидел PostgreSQLContainer, MongoDBContainer, KafkaContainer — и знает, что это за служба. Второй — по имени образа: у GenericContainer класс ничего не говорит, поэтому в дело идёт образ, и redis:7-alpine распознаётся именно так, по имени redis.
Отсюда понятно, когда это ломается: свой образ с непонятным именем (registry.company.ru/infra/cache-redis:3) не распознаётся ни по классу, ни по имени, и @ServiceConnection молча ничего не настроит. Тогда имя службы указывают руками:
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> cache =
new GenericContainer<>("registry.company.ru/infra/cache-redis:3")
.withExposedPorts(6379);
Признак, что вы попали в эту ловушку: контейнер поднялся, а приложение ходит на localhost:6379 мимо него — то есть на адрес из application.yml, потому что настройку никто не переопределил.
Не только PostgreSQL: Kafka, свои образы, один контейнер на прогон
Testcontainers — это не библиотека про базу, а библиотека про «подними мне что угодно из образа и дай адрес». Три сценария, которые нужны почти всем.
Брокер. Тест, проверяющий, что событие ушло и слушатель его разобрал, поднимает настоящий Kafka:
@SpringBootTest
@Testcontainers
class OrderEventsTest {
@Container
@ServiceConnection
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.1"));
@Autowired
OrderService orders;
@Test
void publishesOrderCreated() {
orders.place(orderRequest());
await().atMost(Duration.ofSeconds(10))
.untilAsserted(() -> assertThat(received).hasSize(1));
}
}
Два замечания к этому примеру. Первое: @ServiceConnection сам подставит адреса брокера, руками их прописывать не надо. Второе: проверка обязательно с ожиданием, а не сразу после вызова — сообщение доходит до слушателя асинхронно, и assertThat следующей строкой будет мигать. Kafka в контейнере стартует медленнее базы (секунды), поэтому её держат на весь прогон, а не на класс.
Что угодно из образа. Нет специального класса — берут GenericContainer: заглушка внешнего партнёра, хранилище объектов, совместимое с S3, почтовый ловец, другая база. Адрес и порт спрашивают у контейнера (getHost(), getMappedPort(...)) и передают в контекст через @DynamicPropertySource. Порт всегда спрашивают у контейнера, а не пишут напрямую: изнутри контейнера он свой, а снаружи случайный.
Один контейнер на весь прогон. Самый практичный рецепт, и он проще, чем кажется: убрать @Container и стартовать контейнер в статическом блоке базового класса.
public abstract class IntegrationTest {
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine");
static {
POSTGRES.start();
}
@DynamicPropertySource
static void datasource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
}
}
Почему так, а не @Container static: аннотация привязывает жизнь контейнера к классу тестов, и при двадцати классах вы получаете двадцать стартов. Статический блок выполняется один раз на загрузку класса, то есть один раз на всю виртуальную машину прогона, и контейнер живёт до её конца. Останавливать его не нужно: за уборкой следит сторожевой контейнер Testcontainers, который гасит всё после завершения процесса. Этот же приём — единственный работающий, когда прогон идёт в несколько форков: у каждого форка своя виртуальная машина и свой контейнер, и тесты не делят базу.
И противоположный случай, о котором стоит знать: withReuse(true) оставляет контейнер жить между прогонами, чтобы не платить стартом на каждый запуск тестов локально. Работает он только при testcontainers.reuse.enable=true в файле ~/.testcontainers.properties на машине, а в сборке этот флаг обычно выключен — там контейнер поднимается заново. Из этого следует неочевидное требование: переиспользуемый контейнер сохраняет данные между прогонами, поэтому тесты обязаны быть чистыми сами по себе. Тест, который проходит на свежей базе и падает на второй запуск, — это не поломка переиспользования, а найденная зависимость от пустой базы, и лучше узнать о ней локально, чем на красной сборке.
Цена входа: тестам нужен Docker
Про Testcontainers обычно говорят «достаточно установленного Docker», и это правда, за которой стоит несколько решений, принимаемых один раз на команду. Лучше знать их заранее, чем обнаружить на красной сборке.
Агент сборки. Тестам нужен доступ к демону Docker, а агент сборки сам обычно работает в контейнере. Варианты: смонтировать в агент сокет демона хоста (просто, но даёт тестам права хоста), поднять отдельный демон рядом, или указать DOCKER_HOST на удалённый демон — тогда контейнеры поднимаются на выделенной машине, а тесты ходят к ней по сети. Для облачных агентов у всех крупных систем сборки Docker доступен из коробки, и проблема касается в основном своих установок.
Машина разработчика. Не у всех есть Docker Desktop, а в больших компаниях он платный — это не мелочь, а строка в бюджете на каждого разработчика. Заменители работают: Colima и Rancher Desktop на macOS, Podman с включённым сокетом, совместимым с Docker. Testcontainers ищет демон по стандартным путям и обычно находит их сам; если нет — путь задаётся переменной окружения. И отдельно стоит решить, что делать с теми, у кого Docker нет вовсе: пометить интеграционные тесты меткой (@Tag("docker")) и исключить их из локального прогона по умолчанию, но не из сборки. Вариант «у нас часть команды гоняет только модульные» рабочий; вариант «мы не включили эти тесты в сборку, потому что у кого-то нет Docker» — нет.
Закрытый контур. Если выхода в интернет нет, образы не скачаются. Решение — внутреннее зеркало реестра и настройка, которая дописывает его префикс ко всем образам (testcontainers.hub.image.name.prefix в файле свойств или переменная окружения), чтобы не править каждый тест. В зеркало кладут и служебный образ-сторож, которым Testcontainers убирает за собой; забыть его — типичная причина «всё скачалось, а тесты всё равно не стартуют».
Время и ресурсы. Каждый контейнер — это память и секунды старта: база несколько секунд, брокер до десяти, набор из четырёх служб превращает «быстрый локальный прогон» в минуту ожидания. Поэтому контейнеры поднимают один раз на прогон (см. выше), а не на класс, и не поднимают то, что в этом тесте не нужно.
Вывод не «Testcontainers дорог», а «это инфраструктурное решение, а не просто библиотека»: оно требует одного разговора с теми, кто держит сборку, и после этого работает годами.
Когда полный контекст, а когда срез
| Ситуация | Что использовать |
|---|---|
| Сквозной тест HTTP → база → ответ | @SpringBootTest + Testcontainers |
| Проверить SQL-запросы репозитория | @DataJpaTest + @ServiceConnection (на Boot до 3.4 — ещё и replace = Replace.NONE) |
| Проверить валидацию и ответы контроллера | @WebMvcTest + @MockitoBean |
| Проверить бизнес-логику сервиса | Модульный тест, без Spring-контекста |
Правило: поднимать ровно столько контекста, сколько нужно. Лишние бины замедляют старт и могут вносить неожиданные зависимости. Полный @SpringBootTest оправдан только для сквозных сценариев.
Если тестов с Testcontainers много, контейнер стоит выносить в общий базовый класс с @Container static — тогда Docker-образ поднимается один раз для всего набора тестов, а не отдельно под каждый класс.
И когда после этого прогон всё равно растёт, причина почти никогда не в контейнерах. Дело в кеше контекста Spring: контекст поднимается один раз на уникальный набор настроек и переиспользуется всеми тестами с тем же набором. Ключ кеша складывается из всего, что меняет конфигурацию: классы конфигурации, активные профили, свойства из @TestPropertySource, набор подменённых бинов. Добавили в один тест @MockitoBean — у него другой набор бинов, значит другой ключ, значит ещё один полный контекст, ещё раз миграции, ещё раз пулы соединений. Двадцать тестовых классов с чуть разными наборами дают двадцать контекстов и прогон в десятки минут при мгновенных тестах.
Поэтому смысл общего базового класса не в контейнере, а в единстве ключа: все тесты наследуют одну и ту же конфигурацию, один и тот же список свойств и один и тот же набор подмен — и поднимают один контекст на всех. Практически это значит: настройки и контейнеры только в базовом классе, подмены бинов не по одному тесту, а вынесены в общую тестовую конфигурацию, и число разных базовых классов держится в единицах. Проверить, сколько контекстов поднялось, можно включив отладочный вывод кеша контекстов — в журнале прогона видно каждое создание и каждое попадание.
Глубже: мигающие тесты: общее состояние, порядок, паузы, порты и параллельный прогонрасширенное
Тест, который то зелёный, то красный, разрушает доверие к сборке быстрее, чем отсутствие тестов: через месяц любую красноту перезапускают не глядя, и настоящую поломку перезапускают вместе с ней. Причин у мигания немного, и каждая находится.
Общее состояние. Статическое поле, кеш, синглтон, строка в базе, которую оставил предыдущий тест. Тест зависит от того, что было до него, и падает при другом порядке. Находят запуском в случайном порядке (junit.jupiter.testmethod.order.default со случайным упорядочивателем) и по одному: тест, который проходит один и падает в наборе, зависит от соседей. Лечат изоляцией: каждый тест готовит своё и убирает своё, а данные не удаляют между тестами, а различают по уникальному ключу, о чём раздел про изоляцию данных в статье про моки.
Паузы вместо ожиданий. Thread.sleep(500) перед проверкой асинхронного результата проходит на ноутбуке и падает на загруженном раннере. Ждут условия, а не времени: Awaitility с atMost и untilAsserted опрашивает проверку, пока она не пройдёт, и падает только по истечении предела; как это устроено для @Async и слушателей очереди, разбирает статья про тестирование в Spring.
Время и случайность. Тест, зависящий от «сейчас», падает в полночь, в конце месяца и при смене часового пояса раннера; случайные данные без зафиксированного зерна дают невоспроизводимое падение. Clock внедряют и подменяют, случайность фиксируют зерном и печатают его при падении.
Порты и ресурсы. Два теста поднимают сервер на одном порту, два прогона делят один контейнер базы. Порт всегда 0 (случайный свободный), контейнер на класс тестов или на прогон, а при параллельных форках Gradle у каждого свой контейнер или своя схема в базе, о чём статья про CI для Java.
Параллельный прогон обнажает всё перечисленное сразу, поэтому его включают не «для скорости», а как проверку изоляции, и включают постепенно: по классам, потом по методам.
Процесс. Мигающий тест не перезапускают до зелёного, а помечают (@Tag("flaky")) и выводят из обязательных проверок в карантин с задачей на починку и сроком; сборка считает долю перезапусков, и рост этой доли это тревога. Аннотации автоматического повтора существуют и годятся для тестов, зависящих от внешней сети, но не для своих: они прячут причину.
Глубже: тест миграций: накатить на непустую базу и почему ddl-auto прячет расхождениерасширенное
Статья дважды говорит, что схему накатывают миграции, и ни разу не показывает, как это проверить. Проверок три, и первая уже есть в любом тесте на Testcontainers, если не мешать.
Миграции проходят с нуля. Spring Boot при старте контекста запускает Liquibase или Flyway на чистой базе контейнера, и любой интеграционный тест это проверяет заодно. Ломают это настройкой spring.jpa.hibernate.ddl-auto=create в тестовом профиле: Hibernate создаёт схему по сущностям, миграции не запускаются, тесты зелёные, а в проде первая же миграция или расхождение колонки роняет старт. В тестах стоит ddl-auto: validate, тот же, что в проде: тогда тест падает, если сущность разошлась с тем, что создали миграции, и это единственная проверка соответствия кода и схемы до выката.
Миграция на непустой базе. Пустая таблица не покажет, что ALTER TABLE держит блокировку минуту, что NOT NULL на колонку с существующими NULL невозможен, что перелив данных не идемпотентен. Отдельный тест или шаг конвейера восстанавливает снимок схемы прода с обезличенным образцом данных (или заполняет таблицы генератором до реалистичного размера) и применяет только новые миграции с lock_timeout, замеряя время. Снимок обновляют регулярно, тест запускают на PR, где менялся каталог миграций.
Старый код на новой схеме. Во время выката и после отката предыдущая версия приложения работает с новой схемой, поэтому тесты предыдущей версии (образ с прошлым тегом уже есть) прогоняют против базы с новыми миграциями. Зелёный прогон означает, что миграция совместима, и откат безопасен; красный означает ломающее изменение, которое раскладывают на шаги, о чём статья про эволюцию схемы.
Что тесты не проверят: миграцию, которая зависит от данных, которых нет в снимке, и таймауты на таблицах в миллиард строк, которые больше любого стенда. Для них остаётся CONCURRENTLY, пакетный перелив и репетиция на копии, о чём разделы про PostgreSQL.
Глубже: тест доступа в срезе: кто получит 403, кто 200расширенное
Срез @WebMvcTest поднимает и Spring Security, поэтому запрос без пользователя получает 401, а тест, который об этом не знает, проваливается на первом же GET. Это не помеха, а возможность: доступ проверяется тем же срезом, что и контроллер.
Подложить пользователя можно тремя способами, и они проверяют разное. @WithMockUser(roles = "ADMIN") создаёт аутентификацию с указанными ролями напрямую и годится для проверки правил по ролям, но обходит разбор токена. jwt() из spring-security-test кладёт в контекст готовый JWT с нужными claim и правами и проверяет конвертер ролей и правила по claim. Подмена JwtDecoder заглушкой пропускает через настоящий фильтр заголовок Authorization с собранным в тесте токеном и проверяет aud, iss и истечение. Обязательный набор на закрытый эндпоинт: без пользователя 401, чужая роль 403, своя 200; для ресурсов с владельцем добавляют «чужой заказ по идентификатору отвечает 404».
CSRF в срезе включён, если он включён в приложении: POST без токена CSRF даёт 403 даже с правильным пользователем, и для API с JWT это означает, что в конфигурации CSRF должен быть выключен осознанно, а тест это подтверждает. Для приложений с сессией и формами добавляют .with(csrf()) к изменяющим запросам. Полный разбор с обходом всех маршрутов и denyAll по умолчанию в статье про Spring Security.
Коротко
- H2 и моки баз данных скрывают ошибки, которые видны только с реальным PostgreSQL. Testcontainers запускает Docker-контейнер прямо из теста — тот же образ, что в продакшне.
@SpringBootTestподнимает весь контекст; срезы@DataJpaTestи@WebMvcTest— только нужный слой. Полный контекст берут ради сквозного пути от запроса до базы и обратно, остальное дешевле проверить срезом.@ServiceConnection(Spring Boot 3.1+) убирает@DynamicPropertySourceи сам настраивает datasource.- Общий базовый класс с
staticконтейнером сокращает время сборки при большом числе тестов. - Мигание лечат по причинам: случайный порядок находит общее состояние, Awaitility вместо пауз,
Clockи зерно вместо «сейчас» и случайности, порт0и контейнер на прогон; мигающий тест в карантин с задачей, а не на перезапуск. - Миграции проверяют трижды: с нуля при старте контекста с
ddl-auto: validate, на снимке схемы прода с данными и временем, и тестами предыдущей версии против новой схемы. - Доступ проверяют в срезе:
@WithMockUserдля ролей,jwt()для claim, заглушкаJwtDecoderдляaudи истечения; на закрытый эндпоинт401,403,200и404для чужого; CSRF в срезе включён, если включён в приложении. - Таблицы в контейнере создают миграции при старте контекста, поэтому в тестах
ddl-auto: validate, а неcreate: иначе схему делает Hibernate, миграции не проверяются, а справочные данные и история миграций при очистке не трогаются. - Изоляция: откат транзакции для репозиториев,
TRUNCATEвсех таблиц одним запросом сRESTART IDENTITY CASCADEдля тестов через настоящий HTTP и поведения после фиксации, уникальные данные для параллельного прогона. - Контейнер поднимают один раз на прогон статическим блоком базового класса, а прогон растёт не от контейнеров, а от числа контекстов Spring: единый ключ кеша (одна конфигурация, одни свойства, одни подмены) важнее всего остального.
Что почитать дальше
- Пирамида тестирования — как соотносятся модульные, интеграционные и end-to-end тесты.
- Моки и внешние зависимости — когда мок уместен, а когда лучше реальный контейнер.
- Тесты в Spring —
@SpringBootTestи срезы подробнее в контексте Spring-экосистемы. - Стандарты тестирования — стайл-гайд по структуре и именованию тестов.