1. Testcontainers vs Docker Compose
Если вы уже поднимали PostgreSQL через Docker Compose, то вопрос «зачем ещё один Docker» абсолютно нормальный. Кажется, что можно просто запускать тесты против локальной базы — и всё будет честно. Проблема в том, что локальная база у каждого студента (и в CI) — это маленькая вселенная со своими настройками, остаточными данными и “ой, я забыл применить миграции”.
Testcontainers решает это очень прагматично: тест сам поднимает свой изолированный PostgreSQL, сам даёт Spring’у параметры подключения и сам же его выключает. То есть тест перестаёт зависеть от того, что у вас сейчас запущено в Docker Desktop, какие данные остались после вчерашних экспериментов и не поменял ли кто-то порт в .env. Для сервисных тестов это особенно важно, потому что они проверяют не один запрос, а целый сценарий, где «чуть-чуть несоответствие БД» превращается в «почему у меня заказ то создаётся, то нет».
Полезно зафиксировать это в виде простой таблицы, как “матрица решений”, а не списка правил:
| Подход | Что запускаем | Плюсы | Минусы | Когда уместно |
|---|---|---|---|---|
| H2 (in-memory) | Ничего внешнего | Очень быстро | Не PostgreSQL, может скрывать проблемы | Быстрый сигнал на простые проверки, когда PostgreSQL-специфика не важна |
| Локальная PostgreSQL через Compose | Вручную/скриптом | Реальная PostgreSQL | Не изолировано, зависит от окружения | Локальная разработка, ручные проверки, запуск приложения |
| PostgreSQL через Testcontainers | Поднимается тестом | Изолировано, ближе к production, отлично для CI | Медленнее, нужен Docker | Сервисные интеграционные тесты, миграции, constraints, optimistic locking, native SQL |
2. Что такое Testcontainers: «JUnit → Docker → PostgreSQL → Spring»
Testcontainers легко воспринимать как «библиотеку, которая делает контейнеры», но это слишком расплывчато. Гораздо полезнее мыслить так: у JUnit-теста появляется зависимость “внешний ресурс”, и Testcontainers берёт на себя жизненный цикл этого ресурса. То есть он стартует контейнер перед тестами, предоставляет параметры подключения и гарантирует, что контейнер будет корректно остановлен.
Ниже — схема, которая помогает новичкам не путаться, кто кого запускает. Здесь нет “магии Spring”, только последовательность шагов:
flowchart TD T[Test method запускается в JUnit 6] --> TC["@Testcontainers управляет контейнером"] TC --> D[Docker запускает postgres image] D --> P[(PostgreSQL в контейнере)] T --> S[Spring Boot Test Context стартует приложение] S -->|читает свойства datasource| P S -->|Flyway применяет миграции| P T -->|вызывает сервисы| S
Обратите внимание на важный момент: Spring Boot не «поднимает Docker». Docker поднимает Testcontainers, а Spring Boot просто получает обычные параметры DataSource (url, username, password) и работает так, как будто это «обычная PostgreSQL где-то рядом». Именно поэтому @DynamicPropertySource — ключевой мост: он связывает мир контейнера и мир Spring-конфигурации.
И да, за этим стоит Docker. Так что если Docker не запущен, Testcontainers будет честно ругаться. Никаких чудес: контейнеры без Docker не заводятся примерно так же, как чайник без электричества.
3. Подключаем Testcontainers в Gradle и готовим проект
Перед тем как писать код теста, нужно сделать две скучные, но важные вещи: добавить зависимости и убедиться, что тестовое окружение действительно может запускать контейнеры. С зависимостями всё просто: нам нужен модуль Testcontainers для JUnit 6 и модуль для PostgreSQL. Важно, что эти зависимости должны быть testImplementation, потому что в runtime самого приложения Testcontainers не нужен. Этого setup достаточно: дальше сервисные тесты просто используют его, а не изобретают новую конфигурацию под каждый класс.
Для shop-data-jpa (Gradle) это обычно выглядит так:
dependencies {
// Базовый набор для тестов Spring Boot (JUnit 6 внутри starter'а)
testImplementation("org.springframework.boot:spring-boot-starter-test")
// Интеграция Testcontainers с JUnit 6 (Jupiter API)
testImplementation("org.testcontainers:junit-jupiter")
// Контейнер PostgreSQL для тестов
testImplementation("org.testcontainers:postgresql")
}
Если у вас уже подключён spring-boot-starter-test, то добавляются буквально две строки. Дальше стоит помнить про инфраструктурную реальность: Testcontainers запускает Docker-контейнер, значит на вашей машине должен быть установлен и запущен Docker (на Windows/macOS — чаще всего Docker Desktop). Это не “настройка Spring”, это просто условие игры.
Ещё один важный нюанс именно для нашего курса: если у вас в проекте уже есть Flyway и миграции лежат в db/migration, то при старте @SpringBootTest на чистой контейнерной БД Flyway применит миграции автоматически. Это огромный плюс: вы тестируете сервисы на схеме, которая соответствует проекту, а не на “как получится”.
4. Поднимаем PostgreSQL контейнер в JUnit 6: @Testcontainers и @Container
Теперь переходим к самому вкусному: как выглядит контейнерная PostgreSQL в тестовом классе. Здесь важно не перепутать две роли. Аннотация @Testcontainers говорит JUnit 6: «у этого тестового класса есть контейнеры, их жизненным циклом управляет Testcontainers». Аннотация @Container помечает конкретное поле-контейнер: «вот этот контейнер нужно стартовать/останавливать».
Минимальный каркас контейнера такой:
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
class PostgresContainerBaseTest {
// Контейнер поднимается один раз на класс (быстрее, но важна дисциплина по данным)
@Container
static PostgreSQLContainer
postgres =
new PostgreSQLContainer<>("postgres:17") // Версия образа фиксируется, чтобы не ловить сюрпризы
.withDatabaseName("shop")
.withUsername("shop")
.withPassword("shop");
}
Здесь важно слово static. Если контейнер static, то он поднимается один раз на весь класс (обычно быстрее). Если контейнер не static, он поднимается на каждый тестовый метод (это чище по изоляции, но ощутимо медленнее). Для сервисных интеграционных тестов в учебном проекте разумный default — static, а чистоту данных обеспечивать либо аккуратной подготовкой, либо явной очисткой.
И да, образ postgres:17 — это обычный Docker image. Никакой “специальной PostgreSQL для тестов” не существует: это нормальная PostgreSQL, просто временная.
5. Пробрасываем параметры в Spring Boot: @DynamicPropertySource
Сам по себе контейнер нам ничего не даст, если Spring Boot продолжит читать настройки DataSource из application-test.yml и пытаться подключиться к старому адресу. Наша задача — сказать Spring’у: «бери URL, логин и пароль у контейнера». Для этого и используется @DynamicPropertySource: это способ программно добавить свойства в Spring Environment до старта контекста.
Выглядит это так:
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
@DynamicPropertySource
static void postgresProperties(DynamicPropertyRegistry registry) {
// URL на самом деле включает рандомный порт, который выдал Docker
registry.add("spring.datasource.url", postgres::getJdbcUrl);
// Логин/пароль тоже берём у контейнера, чтобы не было рассинхронизации с конфигом
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
Обратите внимание: метод обязательно static. Это одна из тех мелочей, которые ломают всё «вроде бы без причины», если пропустить. Логика простая: Spring должен получить эти свойства ещё до создания бинов, а значит он не может ждать, пока у него появится экземпляр тестового класса.
Ещё один прагматичный момент. Иногда в application-test.yml остаются старые настройки вроде spring.datasource.url=jdbc:h2:mem:testdb. С @DynamicPropertySource они будут переопределены, но лучше держать тестовый конфиг аккуратным, чтобы не гадать, что сейчас реально применилось. В тестах от прозрачности выигрывает всё: и вы, и будущая команда.
6. Сервисный интеграционный тест на PostgreSQL
Когда контейнер поднят и Spring подключился к нему, сервисный тест выглядит почти так же, как обычный @SpringBootTest. Это важный психологический момент: вам не нужно “переписывать тесты под контейнеры”. Вы просто меняете источник базы данных на более реалистичный.
Ниже пример скелета теста, который живёт в контексте shop-data-jpa и проверяет, что контекст поднимается, а сервис доступен:
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class OrderServicePostgresIT extends PostgresTestBase {
// Достаём bean из контекста: важный минимум, чтобы понять, что приложение реально стартовало
@Autowired
OrderService orderService;
@Test
void contextStarts() {
// Учебная проверка: сервис поднялся и подставился через DI
org.junit.jupiter.api.Assertions.assertNotNull(orderService);
}
}
Чтобы это работало, нам нужна базовая абстракция PostgresTestBase, где лежат контейнер и @DynamicPropertySource. Её удобно держать в тестовом пакете com.example.shopdatajpa.testinfra или рядом, чтобы не копировать одно и то же в каждый тест.
Пример такой базы:
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
abstract class PostgresTestBase {
// База для всех интеграционных тестов:
// - тут объявляем postgres container
// - тут же пробрасываем свойства через @DynamicPropertySource
}
Теперь добавим маленькую, но очень полезную диагностику: убедимся, что мы действительно подключились к PostgreSQL, а не случайно опять оказались на H2 из-за конфигурации.
import javax.sql.DataSource;
import java.sql.Connection;
@Autowired
DataSource dataSource;
@Test
void usesPostgreSql() throws Exception {
// Подключаемся к DataSource, который собрал Spring (важно: не к контейнеру напрямую)
try (Connection c = dataSource.getConnection()) {
String db = c.getMetaData().getDatabaseProductName();
System.out.println("DB = " + db); // Ожидаем увидеть: DB = PostgreSQL
}
}
Это не “настоящий assert”, но как учебный шаг — очень полезно. Если вы увидели H2, значит контейнер не подключился, и тест вообще не о том.
А теперь чуть ближе к смыслу сервисных тестов: проверка use case. Упрощённый пример: создадим тестовые данные репозиториями, вызовем placeOrder(), потом перечитаем данные и проверим результат. Тут важна форма проверки: сервис вызывает транзакционный сценарий, а репозитории используются как «датчики», чтобы измерить итоговое состояние в базе.
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
@Test
void placeOrderCreatesOrder() {
// Подготовка входных данных: продукт + остатки на складе
Long productId = testData.createProduct("SKU-900", new BigDecimal("10.00"));
testData.setStock(productId, 5);
// Действие: вызываем бизнес-сценарий на уровне сервиса
Long orderId = orderService.placeOrder(productId, 2);
// Проверка результата: перечитываем сущность из БД (репозиторий выступает как «датчик»)
CustomerOrder order = orderRepository.findById(orderId).orElseThrow();
org.junit.jupiter.api.Assertions.assertEquals(2, order.getItems().size());
}
В этом фрагменте я специально использую testData как учебную абстракцию, потому что реальная подготовка данных для заказа обычно занимает больше 10 строк. Смысл в другом: вы не обязаны «вручную писать SQL» для подготовки. Вы можете подготовить данные через репозитории, а сам use case запускать через сервис.
И ещё один важный нюанс, который прямо связан с темой сегодняшнего дня. Если вы тестируете rollback, вам особенно важно перечитывать данные из репозиториев после исключения, а не смотреть на уже загруженные сущности. Контейнерная PostgreSQL тут не “лечит” проблему, но делает тест честнее: rollback и ограничения ведут себя максимально близко к проектной базе.
7. Когда Testcontainers действительно нужен
Когда человек впервые видит Testcontainers, у него появляется желание сказать: «О, супер! Теперь все тесты будут на PostgreSQL, и мы навсегда забудем про сюрпризы». Это желание понятное. И опасное — прежде всего по скорости: контейнеры поднимаются дольше, контекст стартует дольше, и в итоге тесты начинают “наказывать” вас за каждое изменение.
Здоровая стратегия для нашего курса такая. Узкие repository-тесты остаются в @DataJpaTest, потому что они дают быстрый сигнал и проверяют очень конкретные вещи. Сервисные интеграционные тесты, где важны транзакции, rollback, constraints, optimistic locking и миграции, — хорошие кандидаты на Testcontainers. Получается не “всё на контейнерах”, а “контейнеры там, где они дают новую информацию”.
Ещё полезно держать в голове простую инженерную формулу: Testcontainers не добавляет вам новых assertions. Он всего лишь делает среду ближе к PostgreSQL-truth. Если тест у вас уже слабый (проверяет только assertNotNull(service)), контейнер не превратит его в умный. А вот если тест реально проверяет бизнес-инвариант и поведение транзакций, контейнер добавит уверенности, что вы проверили это в правильной СУБД.
8. Типичные ошибки при работе с Testcontainers
Ошибка №1: Docker не запущен, а мы ждём, что “Spring как-нибудь справится”.
Самая частая причина падений контейнерных тестов — банальная: Docker выключен или недоступен (особенно после обновления системы). Важно принять: Testcontainers — это не «зависимость Java», это мост к Docker. Если Docker недоступен, тесты не смогут поднять базу, и это нормально.
Ошибка №2: контейнер подняли, но Spring всё равно подключился не туда.
Это происходит, когда вы забыли @DynamicPropertySource, неправильно прописали ключи spring.datasource.* или оставили в тестовом профиле что-то, что переопределяет свойства позже. Симптом выглядит как “подключение проходит, но база не Postgres”. В учебном проекте полезно иметь один диагностический тест, который печатает DatabaseProductName.
Ошибка №3: забыли, что @DynamicPropertySource должен быть static.
Эта ошибка особенно раздражает новичков: код выглядит правильным, контейнер вроде объявлен, но свойства не применяются. Причина почти всегда в том, что метод не static (или контейнер объявлен не в той области видимости). Дисциплина тут простая: контейнер и метод свойств — статические, чтобы Spring мог воспользоваться ими до старта контекста.
Ошибка №4: «контейнер есть — значит база чистая» (а она не чистая).
Если контейнер static, то база живёт на весь тестовый класс. Это означает, что данные, созданные одним тестом, могут повлиять на следующий. Иногда это проявляется не сразу, а “на третьем запуске”. Лечится не магией, а дисциплиной: либо аккуратная очистка данных в @BeforeEach (в правильном порядке из-за FK), либо продуманная подготовка данных так, чтобы тесты не конфликтовали.
Ошибка №5: превращаем контейнерный тест в энциклопедию проверок.
Появляется соблазн: раз уж тест медленный и честный, давайте в него запихнём всё — и rollback, и миграции, и optimistic locking, и ещё отчётный запрос. В итоге при падении вы не понимаете, что именно сломалось. Контейнерный тест, как и любой хороший тест, должен проверять одно главное обещание use case, просто на более реалистичной базе.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ