Тестирование на JVM: JUnit 5, Testcontainers, MockK и Mockito

Практичное тестирование JVM-сервиса: JUnit 5 как фундамент, интеграционные тесты на реальной инфраструктуре через Testcontainers, моки (Mockito/MockK) и где граница между unit и integration

«Тесты есть» и «тестам можно верить» — разные состояния. На 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 как два похожих, но не идентичных инструмента — отдельная короткая ремарка ниже.

Тестирование на JVM: JUnit 5 и Testcontainers

В статье

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

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
Один и тот же OrderService, две реализации репозитория, два порядка величины по времени

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 test

TESTCONTAINERS_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 между ними — контейнерный интеграционный тест из этой статьи становится частным случаем куда более скользкой темы: тестирование распределённых систем.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии