«Тесты есть» и «тестам можно верить» — разные состояния. На JVM легко нагородить моков так, что тест проверяет заглушки, а не поведение, и легко перепутать причину падения интеграционного теста: то ли сломалась бизнес-логика, то ли просто не поднялся Docker-контейнер. Разберём это на конкретном стенде: сервис заказов с одним и тем же кусочком бизнес-правила, проверенным на двух уровнях — юнит-тестом за микросекунды и интеграционным тестом против настоящего Postgres в Testcontainers.
Стенд маленький намеренно: OrderService.createOrder() — простое правило (сумма от 100 рублей уходит в ручной REVIEW, иначе сразу PENDING), OrderRepository — узкий интерфейс с двумя реализациями. Юнит-тесты подсовывают ручной stub или Mockito-мок, интеграционный — реальный JdbcOrderRepository поверх Postgres 18.4, поднятого Testcontainers. Разница во времени выполнения между ними — не абстракция из учебника, а измеренные секунды против измеренных микросекунд на одной и той же машине.
Отдельно — про топологию, которая почти всегда всплывает при первом знакомстве с Testcontainers на Docker Desktop (Windows/WSL2): «test runner сам внутри контейнера» ломает и версию Docker API, и сетевую доступность контейнера с базой. Оба фикса — не косметика, без них интеграционный тест просто не стартует.
Статья JVM-серии: инструменты общие для Java и Kotlin. Про Mockito и MockK как два похожих, но не идентичных инструмента — отдельная короткая ремарка ниже.
В статье
- JUnit 5: фундамент
- Моки без самообмана: stub против Mockito
- Testcontainers: интеграционный тест на настоящем Postgres
- Подводные камни DinD: Docker Desktop, Windows, WSL2
- Контраст: микросекунды против секунд
flowchart TD
A["OrderService.createOrder()"] --> B{"Какая реализация\nOrderRepository?"}
subgraph unit ["Unit-тесты — 4, JUnit 5"]
B -->|"ручной stub\n(3 теста)"| C["InMemoryOrderRepository"]
B -->|"Mockito mock\n(1 тест)"| D["mock(OrderRepository.class)"]
C --> E["assert: 19–28 мкс"]
D --> E
end
subgraph integration ["Integration-тесты — 2, @Testcontainers"]
B -->|"реальная реализация"| F["JdbcOrderRepository"]
F --> G["PostgreSQLContainer\npostgres:18.4\nстарт ~2.9 с"]
G --> H["INSERT + SELECT\n45–69 мс"]
end
style C fill:#c9e4c5,stroke:#5b8a5e
style D fill:#c9e4c5,stroke:#5b8a5e
style F fill:#f5d6d0,stroke:#b05050
style G fill:#f5d6d0,stroke:#b05050
style H fill:#f5d6d0,stroke:#b05050
JUnit 5: фундамент
JUnit 5 (junit-jupiter) в стенде — версия 5.14.4, актуальный latest stable на Maven Central. JUnit 6.x уже существует, но Testcontainers 1.21.x таргетирует именно JUnit 5 API через свой отдельный модуль org.testcontainers:junit-jupiter, версией которого управляет testcontainers-bom — апгрейд на JUnit 6 в проекте с Testcontainers не такой тривиальный, как кажется.
Механика жизненного цикла в стенде используется по минимуму — ровно то, что нужно для контраста: @Test на обычных методах и @BeforeAll на статическом методе applySchema(), который накатывает DDL один раз на весь класс, а не перед каждым тестом (схема не меняется между тестами, гонять CREATE TABLE шесть раз бессмысленно). В более сложных сьютах к этому добавляются параметризованные тесты (@ParameterizedTest + @ValueSource/@MethodSource), @Nested для группировки сценариев и расширения (@ExtendWith) — но именно @Testcontainers (см. ниже) уже сама по себе такое расширение, реализованное поверх BeforeAllCallback/AfterAllCallback.
Юнит-тест — самый простой случай: чистая Java, без Docker, без сети, без Spring-контекста.
@Test
void pendingForSmallAmount_withStub() {
long start = System.nanoTime();
InMemoryOrderRepository repository = new InMemoryOrderRepository();
OrderService service = new OrderService(repository);
Order order = service.createOrder("Alice", 500);
assertEquals(OrderStatus.PENDING, order.status());
assertEquals(1, repository.size());
log.info("pendingForSmallAmount_withStub took {} µs", (System.nanoTime() - start) / 1000);
}InMemoryOrderRepository — не Mockito и не какой-то тестовый фреймворк, а обычный Java-класс, реализующий OrderRepository поверх HashMap. Это самый дешёвый способ изолировать бизнес-правило от хранилища: никакой рефлексии, никакого поднятия mock-инфраструктуры — просто ещё одна реализация интерфейса.
Моки без самообмана: stub против Mockito
В стенде три из четырёх юнит-тестов держатся на ручном stub, и только один — на Mockito (5.23.0 + mockito-junit-jupiter). Разница не количественная, а по сути того, что тест проверяет.
Ручной stub (InMemoryOrderRepository) реально хранит данные — тест pendingForSmallAmount_withStub проверяет не только статус заказа, но и то, что repository.size() стал равен 1: репозиторий действительно что-то сохранил. Mockito-мок ничего не хранит — он записывает вызовы и умеет их проверить:
@Test
void createOrder_savesThroughRepository_withMockitoMock() {
// Контраст: тот же сценарий, но через Mockito-mock вместо ручного stub —
// здесь важна не сама запись, а факт и аргументы вызова save().
OrderRepository mockRepository = mock(OrderRepository.class);
when(mockRepository.save(any(Order.class)))
.thenAnswer(invocation -> invocation.<Order>getArgument(0).withId(42L));
OrderService service = new OrderService(mockRepository);
Order order = service.createOrder("Carol", 250);
assertEquals(42L, order.id());
assertTrue(order.customerName().equals("Carol"));
verify(mockRepository).save(any(Order.class));
}Здесь id = 42 не результат работы никакой реальной последовательности — это то, что мок договорился вернуть через thenAnswer. verify(mockRepository).save(...) проверяет факт вызова, а не то, что данные где-то реально легли. Это ровно тот случай, где легко впасть в антипаттерн «тест проверяет мок, а не поведение»: если бы OrderService вообще не вызывал save(), а просто возвращал заранее сконструированный Order с id = 42, verify() бы это поймал — а вот наивный ручной stub без счётчика вызовов пропустил бы такую регрессию молча. У обоих подходов есть слепые пятна, и они разные: mock проверяет контракт вызова, stub — побочный эффект. Комбинация обоих в одном сьюте (как в стенде) — не дублирование, а два независимых способа поймать разные классы багов.
На Kotlin-стороне JVM-серии за ту же роль обычно отвечает MockK — API устроен похоже (every { ... } returns ... вместо when(...).thenReturn(...)), но заточен под Kotlin: умеет мокать object, extension-функции и suspend-функции без дополнительных плагинов, чего Mockito из коробки не делает. Для тестирования корутин (runTest из kotlinx-coroutines-test) отдельная механика — она не про моки, а про виртуальное время внутри coroutine-скоупа; подробнее об идиомах Kotlin для backend — в обзоре Kotlin для JVM-серии.
Testcontainers: интеграционный тест на настоящем Postgres
Юнит-тесты выше ничего не говорят о том, действительно ли SQL в JdbcOrderRepository работает против настоящей БД: правильно ли составлен INSERT, вернёт ли Postgres реальный id из BIGSERIAL-последовательности, отработает ли SELECT с нужными типами колонок. Testcontainers (1.21.3, PostgreSQL-модуль) поднимает для этого настоящий postgres:18.4 в Docker и убирает его после теста — без ручного docker-compose и без in-memory-имитаций вроде H2 «под Postgres», у которых поведение по краевым случаям (типы, диалект, constraint-ошибки) отличается от настоящего движка.
@Testcontainers
class OrderRepositoryIntegrationTest {
@Container
static final PostgreSQLContainer<?> POSTGRES = createContainer();
private static PostgreSQLContainer<?> createContainer() {
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:18.4"))
.withDatabaseName("orders_test")
.withUsername("orders")
.withPassword("orders");
}
@Test
void savesAndReadsOrder_againstRealPostgres() throws Exception {
long start = System.nanoTime();
OrderRepository repository = new JdbcOrderRepository(() -> {
try {
return openConnection();
} catch (SQLException e) {
throw new RuntimeException(e);
}
});
OrderService service = new OrderService(repository);
Order created = service.createOrder("Dave", 12_000);
assertTrue(created.id() != null && created.id() > 0, "generated id from real Postgres sequence");
assertEquals(OrderStatus.REVIEW, created.status());
Optional<Order> reloaded = repository.findById(created.id());
assertTrue(reloaded.isPresent(), "row must be readable back from Postgres");
log.info("savesAndReadsOrder_againstRealPostgres took {} ms", (System.nanoTime() - start) / 1_000_000);
}
}@Testcontainers + @Container — это уже готовое расширение JUnit 5 (тот самый @ExtendWith под капотом): оно поднимает контейнер до тестов класса и гарантированно останавливает после, без ручного try/finally. created.id() > 0 — не формальная проверка «не null»: это буквально сгенерированный Postgres-BIGSERIAL-идентификатор, полученный через Statement.RETURN_GENERATED_KEYS, то есть тест реально проверяет round-trip через настоящую последовательность в БД, а не выдуманное значение.
Testcontainers удобен ещё и как единая точка зависимости для инфраструктуры за пределами Postgres — доступ к данным в этой серии уже разбирался на том же образе postgres:18.4, и тот же паттерн @Testcontainers без изменений переносится на Kafka- или Redis-модули, если понадобится проверить messaging против настоящего брокера, а не мока продюсера.
Подводные камни DinD: Docker Desktop, Windows, WSL2
На Linux CI-раннере с хостовым Docker всё написанное выше просто работает: mvn test поднимает контейнер, JDBC подключается по getJdbcUrl(), никакой возни. На Docker Desktop (Windows/WSL2) — если сам mvn test тоже гоняется внутри контейнера с проброшенным docker.sock (типичная схема «test runner в контейнере», не изобретение этого стенда, а задокументированный Testcontainers-паттерн для DinD) — обнажились две независимые проблемы, без фикса которых интеграционный тест просто не стартовал. Третья проблема к Docker отношения не имеет вовсе и ждёт всех, кто соберёт этот же стенд на JDK 25, — она разобрана в конце раздела.
Фикс А — версия Docker Engine API. Docker Desktop проксирует примонтированный docker.sock через внутренний прокси (в docker inspect видно Labels: dockerSocketProxied), который отвечает 400 Bad Request на запросы с Docker Engine API ниже 1.40. Testcontainers 1.21.x без явно заданной версии API жёстко фолбэчится на VERSION_1_32 — отсюда IllegalStateException: Could not find a valid Docker environment при полностью рабочем сокете. Переменная окружения DOCKER_API_VERSION эту версию не переопределяет — код DefaultDockerClientConfig$Builder.fromEnv() читает только DOCKER_HOST/DOCKER_TLS_VERIFY/DOCKER_CONTEXT/DOCKER_CONFIG/DOCKER_CERT_PATH. Работает только системное свойство api.version, проброшенное в форкнутую JVM surefire через argLine:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.6</version>
<configuration>
<argLine>-Dapi.version=1.43</argLine>
</configuration>
</plugin>Фикс Б — сетевая доступность. После фикса А контейнер поднимался (Container postgres:18.4 started in ...), но JDBC-подключение на опубликованный host-порт стабильно получало Connection refused — и это не гонка: тот же порт оставался недостижим ещё 30+ секунд отдельным параллельным опросом. Похоже на особенность socket-прокси Docker Desktop именно для контейнеров, порождённых из вложенного контейнера: публикация порта на хосте не долетает до соседних контейнеров, хотя сам Postgres уже принимает соединения. Обход — официально задокументированный Testcontainers-паттерн для этой топологии: test-runner и Postgres-контейнер сидят на одной предсозданной user-defined docker-сети и общаются по внутреннему container-IP, а не через опубликованный порт:
docker network create khorost-testing-net # один раз
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$PWD":/app -v "$HOME/.m2":/root/.m2 -w /app \
--network khorost-testing-net \
-e TESTCONTAINERS_RYUK_DISABLED=true \
-e TC_TESTCONTAINERS_NETWORK=khorost-testing-net \
maven:3.9-eclipse-temurin-25 mvn -pl testing -am testTESTCONTAINERS_RYUK_DISABLED=true в этой команде — компромисс именно для DinD-топологии на этой машине, а не рекомендация для прод-CI. Ryuk — служебный контейнер-«уборщик», который подчищает осиротевшие контейнеры, если тестовая JVM падает аварийно (OOM, kill -9, обрыв CI-раннера); без него в DinD-топологии он сам не может получить доступ к докеру, поэтому проще отключить и полагаться на shutdown hook JVM (что в стенде проверено — три прогона подряд, осиротевших контейнеров не остаётся). В обычном CI без вложенных контейнеров Ryuk отключать незачем — там он и должен работать как задумано.
Фикс В — Mockito на JDK 25. Эта ловушка не про Docker: она срабатывает и на голом хосте. Mockito 5 создаёт моки через inline mock maker, который грузит Byte Buddy как Java-агент — динамически, прицепляя агент к своей же JVM. Начиная с JDK 21 такой self-attach даёт предупреждение (JEP 451), а в JDK 25 запрещён по умолчанию: единственный тест с mock(OrderRepository.class) падает с «Byte Buddy could not self-attach», и на выходе — 6 тестов, 1 ошибка, при полностью зелёном Testcontainers-тесте рядом. Штатное решение из документации Mockito — подключить mockito-core статически, через -javaagent (у jar’а есть Premain-Class). Путь к jar’у в локальном репозитории подставляет dependency:properties — без этой привязки surefire передаёт строку ${org.mockito:mockito-core:jar} в java буквально, и форк умирает ещё до тестов с «agent library failed Agent_OnLoad: instrument»:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.11.0</version>
<executions>
<execution>
<id>resolve-agent-paths</id>
<phase>initialize</phase>
<goals><goal>properties</goal></goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.6</version>
<configuration>
<argLine>-javaagent:${org.mockito:mockito-core:jar} -Dapi.version=1.43</argLine>
</configuration>
</plugin>Побочный эффект статического агента — предупреждение Sharing is only supported for boot loader classes because bootstrap classpath has been appended в начале класса: CDS-архив классов не применяется, потому что к bootstrap-classpath добавлен агент. На корректность это не влияет, на старт JVM — влияет незначительно.
Отдельная и не менее реальная ловушка — с версией самого Testcontainers. Parent POM импортирует spring-boot-dependencies (3.5.3) раньше testcontainers-bom (1.21.3) в dependencyManagement, а Spring Boot 3.5.3 сам управляет org.testcontainers:testcontainers на версии 1.21.2. При импорте нескольких BOM в один dependencyManagement-список побеждает первое объявление — без явной версии в модуле резолвился бы 1.21.2, а не заявленный 1.21.3, тихо, без предупреждения от Maven:
<!-- Testcontainers — версия из parent testcontainers.version (1.21.3), ЯВНО
(не полагаясь на dependencyManagement из testcontainers-bom): в parent
pom.xml spring-boot-dependencies импортирован ПЕРЕД testcontainers-bom,
а Spring Boot 3.5.3 сам управляет org.testcontainers:testcontainers на
1.21.2 — при импорте нескольких BOM в один dependencyManagement первое
объявление побеждает. -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>Урок общий для любого мультимодульного Maven-проекта с несколькими BOM в родителе: порядок <dependencyManagement><dependencies> в parent решает, какая версия транзитивной зависимости победит, и это не всегда та версия, что написана в <properties>. mvn dependency:tree — единственный надёжный способ проверить, что резолвится на самом деле, а не что задумано.
Контраст: микросекунды против секунд
Реальный прогон (три раза подряд, все зелёные):
[INFO] Running tech.khorost.testing.OrderRepositoryIntegrationTest
...
[main] INFO tc.postgres:18.4 - Container postgres:18.4 started in PT2.917072504S
[main] INFO tech.khorost.testing.OrderRepositoryIntegrationTest - Postgres container started: jdbc:postgresql://172.25.0.3:5432/orders_test (network=jdd-testing-net, container IP=172.25.0.3)
[main] INFO tech.khorost.testing.OrderRepositoryIntegrationTest - savesAndReadsOrder_againstRealPostgres took 69 ms
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 8.255 s -- in tech.khorost.testing.OrderRepositoryIntegrationTest
[INFO] Running tech.khorost.testing.OrderServiceUnitTest
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
[main] INFO tech.khorost.testing.OrderServiceUnitTest - pendingForSmallAmount_withStub took 19 µs
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.744 s -- in tech.khorost.testing.OrderServiceUnitTest
[INFO] Results:
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESSШесть тестов итого: 4 юнит (3 на ручном stub + 1 на Mockito-mock) + 2 интеграционных (@Testcontainers + PostgreSQLContainer). Числа в логе выше легко прочитать неправильно, если смотреть только на Time elapsed — а именно оно и вводит в заблуждение:
- Юнит-уровень: реальное время самой проверки, залогированное внутри теста — 19–28 микросекунд по трём прогонам.
Time elapsed: 2.744sдля всего класса — это оверхед старта JVM и загрузки Mockito-агента, не сама бизнес-логика; логика отрабатывает за время, которое неотличимо от нуля на фоне старта JVM. На холодном прогоне (пустой кеш Maven, первая JVM) то же значение доходило до5.416s— ещё одно напоминание, чтоTime elapsedизмеряет инфраструктуру, а не тест. - Интеграционный уровень: старт контейнера
postgres:18.4— ~2.9–3.0 секунды (в трёх прогонахPT3.014S,PT2.890S,PT2.917S), а сам SQL round-trip (INSERT+SELECTпротив настоящего Postgres) — 45–69 миллисекунд.Time elapsed: 8.255sна класс включает старт контейнера плюс ретраи соединения (небольшой backoff-цикл на случай, что порт ещё не готов принимать TCP сразу после старта логов контейнера).
Разница по порядку величины — единицы-десятки микросекунд против единиц секунд, то есть примерно в 100 000 раз. Но это не повод сокращать интеграционные тесты до минимума ради скорости: секунды здесь — это цена за то, что тест реально проверяет поведение против настоящей БД (сгенерированный Postgres-sequence-идентификатор, реальный диалект SQL, реальные типы колонок), а не против мока, который отвечает ровно так, как его настроили. Юнит-тесты быстро ловят регрессии бизнес-правила («сумма ≥ 10000 копеек → REVIEW»); интеграционный тест ловит то, что юнит-тест в принципе не может увидеть — ошибку в самом SQL, несовпадение типов между Java и Postgres, поведение RETURN_GENERATED_KEYS на конкретной версии драйвера. Пирамида тестов работает не потому, что unit «лучше», а потому что у каждого уровня своя, непересекающаяся зона ответственности.
Отдельно стоит сказать про Spring Boot: этот стенд сознательно обходится без него — репозиторий и сервис написаны на голой Java, без @SpringBootTest. Причина не в нелюбви к Spring, а в контрасте: @DataJpaTest/@WebMvcTest (слайс-тесты, поднимающие часть контекста) и полный @SpringBootTest добавляют свой собственный оверхед поверх и JUnit 5, и Testcontainers — то есть третий, отдельный источник задержки, который здесь только размыл бы измерение «unit vs integration» до «unit vs integration vs Spring-контекст». Механика @Testcontainers, показанная выше, работает внутри Spring-тестов без изменений — разница лишь в том, что Time elapsed для integration-слайса вырастет ещё на время поднятия контекста.
Итог
Тест, который ничего не мокает — правда о поведении по цене секунд. Тест, который мокает всё — быстрая, но потенциально лживая иллюстрация того, что вы думаете о своём коде. Смысл контраста unit/integration не в том, чтобы выбрать одно, а в том, чтобы честно понимать, какой вопрос отвечает каждый уровень: юнит-тест над InMemoryOrderRepository и Mockito-mock проверяют бизнес-правило изолированно и быстро; @Testcontainers-тест над настоящим postgres:18.4 проверяет, что это правило действительно доживает до реальной БД. DinD-сложности этой статьи (версия Docker API, сетевая топология, порядок BOM) — цена входа именно на Docker Desktop/Windows; на Linux CI с хостовым докером тот же стенд стартует без единого дополнительного флага.
Пирамида unit/integration и сама идея «мок проверяет контракт, а не побочный эффект» — не специфика JVM: те же уровни и та же ловушка самообмана моками разобраны шире, без привязки к одному языку, в тестировании в разных языках. А если система, которую вы тестируете, не укладывается в один процесс и один Postgres — очередь, второй сервис, eventual consistency между ними — контейнерный интеграционный тест из этой статьи становится частным случаем куда более скользкой темы: тестирование распределённых систем.
Комментарии