Java Тестирование: JUnit 5, Mockito, Testcontainers, интеграционные тесты
0%

Тестирование: JUnit 5, Mockito, Testcontainers, интеграционные тесты

Тестирование: JUnit 5, Mockito, Testcontainers, интеграционные тесты

Компилятор Java уже проверил многое: типы сошлись, метод существует, null не подставится вместо int. Тесты нужны для того, что типы выразить не могут — поведение во времени и в контексте: этот SQL действительно вернёт строки из этой схемы; при отмене платежа деньги вернутся ровно один раз; при 500 от внешнего API сервис не потеряет заказ.

У Java здесь есть редкое преимущество и редкая ловушка. Преимущество — экосистема, где запустить настоящий PostgreSQL, Kafka и S3-совместимое хранилище из тестового метода занимает пять строк, а результат детерминирован. Ловушка — культура, выросшая на медленных фреймворках: тестовый набор на 4000 тестов, идущий 25 минут, и половина этих тестов проверяет, что мок, который мы настроили тремя строками выше, вернул то, что мы ему велели вернуть.

Эта статья — о том, как получить первое и не получить второе. Как обычно в треке, идём от модели исполнения: что физически происходит, когда вы жмёте «Run test».

Модель исполнения: что такое JUnit 5

«JUnit 5» — не библиотека, а три части с разными задачами. Понимание этой развязки экономит часы отладки «почему IDE тест видит, а mvn test — нет».

  • JUnit Platform — фундамент. Определяет SPI TestEngine и предоставляет Launcher: API обнаружения и запуска тестов. Именно с Platform разговаривают IDE, Maven Surefire, Gradle и всё остальное.
  • JUnit Jupiter — новая модель программирования (аннотации @Test, @ParameterizedTest, модель расширений) плюс движок junit-jupiter-engine, реализующий TestEngine.
  • JUnit Vintage — движок, умеющий запускать старые тесты JUnit 3/4 на той же Platform.

Практический вывод: если тест не запускается в CI, но зелёный в IDE — почти всегда виноват не код, а то, что нужный TestEngine не попал в test-classpath, или Surefire не считает файл тестовым по имени. Java-разработчики теряют на этом первый рабочий день чаще, чем на любой ошибке в самих тестах.

Минимальная настройка Maven

<dependencyManagement>
  <dependencies>
    <!-- BOM выравнивает версии всех артефактов JUnit между собой -->
    <dependency>
      <groupId>org.junit</groupId>
      <artifactId>junit-bom</artifactId>
      <version>5.11.4</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>   <!-- api + params + engine -->
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
  </dependency>
</dependencies>

<build>
  <plugins>
    <!-- юнит-тесты: фаза test, шаблон *Test / Test* / *Tests -->
    <plugin>
      <artifactId>maven-surefire-plugin</artifactId>
      <version>3.5.2</version>
    </plugin>
    <!-- интеграционные: фаза integration-test, шаблон *IT / IT* -->
    <plugin>
      <artifactId>maven-failsafe-plugin</artifactId>
      <version>3.5.2</version>
      <executions>
        <execution><goals><goal>integration-test</goal><goal>verify</goal></goals></execution>
      </executions>
    </plugin>
  </plugins>
</build>

Разделение на два плагина — не бюрократия, а следствие устройства жизненного цикла Maven.

Фазы Maven, форки JVM и жизненный цикл контейнеров

В Gradle то же самое достигается отдельным sourceSet/тестовой сюитой (JVM Test Suite plugin): test для юнитов, integrationTest для медленных. Подробности сборки — в Установке и инструментарии.

Жизненный цикл теста и почему он именно такой

Ключевое решение JUnit: на каждый тестовый метод создаётся новый экземпляр тестового класса. Поля объекта чистые, порядок методов не влияет на результат, тесты можно переставлять и запускать по одному. Это Lifecycle.PER_METHOD — режим по умолчанию.

class LifecycleDemoTest {

    @BeforeAll  // статический: выполняется один раз до всех
    static void bootstrap() { System.out.println("BeforeAll"); }

    @BeforeEach // новый экземпляр класса → это поле каждый раз новое
    void setUp() { System.out.println("  BeforeEach на " + this.hashCode()); }

    @Test void first()  { System.out.println("    first"); }
    @Test void second() { System.out.println("    second"); }

    @AfterEach void tearDown() { System.out.println("  AfterEach"); }
    @AfterAll static void shutdown() { System.out.println("AfterAll"); }
}
BeforeAll
  BeforeEach на 1735600054
    first
  AfterEach
  BeforeEach на 21685669     ← другой объект: состояние не протекло
    second
  AfterEach
AfterAll

Именно поэтому @BeforeAll обязан быть static: на момент его вызова ни одного экземпляра ещё не существует. Снять это ограничение можно через @TestInstance(Lifecycle.PER_CLASS) — тогда один экземпляр обслуживает весь класс, @BeforeAll может быть нестатическим, а вы берёте на себя ответственность за чистку состояния между тестами. Разумное применение — дорогая фикстура (запущенный контейнер, разобранный большой файл); неразумное — «так короче».

Обратите внимание на шаг ParameterResolver. В Jupiter тестовый метод может принимать аргументы, и кто-то должен их поставить — это и есть точка расширения, на которой держатся Mockito, Spring, Testcontainers.

Расширения вместо наследования

В JUnit 4 переиспользование делали @RunWith (один на класс) и правилами @Rule. В Jupiter — единая модель Extension с набором колбэков, и расширений можно навесить сколько угодно. Своё расширение пишется за десять минут и часто заменяет базовый класс, от которого наследуются все тесты проекта (антипаттерн: AbstractBaseTest на 300 строк).

// Фиксируем время: тесты не должны зависеть от часов машины.
public class FixedClockExtension implements BeforeEachCallback, ParameterResolver {
    private static final Clock CLOCK =
            Clock.fixed(Instant.parse("2026-07-16T10:00:00Z"), ZoneOffset.UTC);

    @Override public void beforeEach(ExtensionContext ctx) {
        ctx.getStore(ExtensionContext.Namespace.GLOBAL).put("clock", CLOCK);
    }
    @Override public boolean supportsParameter(ParameterContext p, ExtensionContext c) {
        return p.getParameter().getType() == Clock.class;   // умеем поставлять Clock
    }
    @Override public Object resolveParameter(ParameterContext p, ExtensionContext c) {
        return CLOCK;
    }
}

@ExtendWith(FixedClockExtension.class)
class SubscriptionTest {
    @Test
    void истекаетРовноЧерез30Дней(Clock clock) {          // параметр внедрён расширением
        var sub = Subscription.startedAt(clock.instant());
        assertThat(sub.expiresAt()).isEqualTo(Instant.parse("2026-08-15T10:00:00Z"));
    }
}

Утверждения: точность важнее краткости

Встроенные Assertions покрывают базу, но в реальном коде почти все пишут на AssertJ — за счёт fluent-API он даёт несравнимо более информативные сообщения об ошибке.

import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertAll;

@Test
void разбираетКорректныйЗаказ() {
    Order order = parser.parse("""
            {"id":"A-17","items":[{"sku":"X1","qty":2}],"total":"19.98"}
            """);

    // assertAll: все проверки выполнятся, отчёт покажет ВСЕ провалившиеся,
    // а не только первую — экономит итерации «поправил → запустил → следующая».
    assertAll(
        () -> assertThat(order.id()).isEqualTo("A-17"),
        () -> assertThat(order.total()).isEqualByComparingTo("19.98"),  // BigDecimal!
        () -> assertThat(order.items()).singleElement()
                    .extracting(Item::sku, Item::qty)
                    .containsExactly("X1", 2)
    );
}

@Test
void отвергаетОтрицательноеКоличество() {
    String broken = "{\"id\":\"A-1\",\"items\":[{\"sku\":\"X1\",\"qty\":-1}]}";

    assertThatThrownBy(() -> parser.parse(broken))
            .isInstanceOf(ValidationException.class)
            .hasMessageContaining("qty")
            .extracting("field").isEqualTo("items[0].qty");   // проверяем данные, не текст
}

Три правила, которые снимают большинство «непонятных красных тестов»:

  1. Никогда assertTrue(a.equals(b)). При провале вы увидите expected: true but was: false — ноль информации. assertThat(a).isEqualTo(b) покажет обе стороны и diff.
  2. BigDecimal сравнивайте через isEqualByComparingTo. equals у BigDecimal учитывает масштаб: new BigDecimal("2.0").equals(new BigDecimal("2")) — это false. Классические полдня отладки в финансовом коде.
  3. Не проверяйте текст сообщения исключения как основной контракт — он локализуемый и меняется при рефакторинге. Проверяйте тип и структурированные поля.

Для double используйте isCloseTo(x, within(1e-9)): точное сравнение плавающей точки — источник флаки-тестов, которые падают только на другой архитектуре.

Параметризация: один тест, таблица случаев

Табличные тесты — самый выгодный по соотношению «покрытие/строки» инструмент Jupiter.

@ParameterizedTest(name = "{0} с тарифом {1} → скидка {2}%")
@CsvSource({
    "100.00, REGULAR, 0",
    "100.00, SILVER,  5",
    "100.00, GOLD,   10",
    "  0.00, GOLD,   10",     // граница: нулевая сумма
})
void скидкаПоТарифу(BigDecimal sum, Tier tier, int expectedPercent) {
    assertThat(discounts.percentFor(sum, tier)).isEqualTo(expectedPercent);
}

// @MethodSource — когда аргументы сложнее строк
static Stream<Arguments> невалидныеЗаказы() {
    return Stream.of(
        arguments(named("пустой список позиций", new Order(List.of())), "items"),
        arguments(named("дубликат SKU", orderWith("X1", "X1")),         "items"),
        arguments(named("сумма не сходится", orderWithWrongTotal()),    "total")
    );
}

@ParameterizedTest
@MethodSource("невалидныеЗаказы")
void валидаторОтклоняет(Order order, String expectedField) {
    assertThat(validator.validate(order))
            .extracting(Violation::field)
            .containsExactly(expectedField);
}

@ParameterizedTest
@EnumSource(OrderStatus.class)          // прогонит все значения enum
void любойСтатусСериализуется(OrderStatus status) {
    assertThat(json.roundTrip(status)).isEqualTo(status);
}

@EnumSource заслуживает отдельного упоминания: он автоматически подхватит новую константу enum, добавленную через полгода, — тест сам сообщит, что забыли обработать новый статус. Это редкий пример теста, который «растёт» вместе с кодом без правок.

Есть ещё @TestFactory (динамические тесты, генерируемые в рантайме — например, по файлам в каталоге) и @Nested для группировки по контексту:

class ShoppingCartTest {
    @Nested
    @DisplayName("когда корзина пуста")
    class WhenEmpty {
        private final Cart cart = new Cart();

        @Test void итогНоль()          { assertThat(cart.total()).isZero(); }
        @Test void удалениеБезошибочно(){ assertThatCode(() -> cart.remove("X1"))
                                              .doesNotThrowAnyException(); }
    }

    @Nested
    @DisplayName("когда в корзине один товар")
    class WithOneItem { /* ... */ }
}

Вложенные классы наследуют @BeforeEach внешнего — получается контекстная фикстура без копипасты, а отчёт читается как спецификация.

Что подменять, а что запускать по-настоящему

Здесь проходит главный водораздел. Прежде чем брать Mockito, ответьте: какую границу пересекает тест и почему её нельзя пересечь по-настоящему?

Границы тестов в Java-приложении

Классическая таксономия тестовых дублей (Джерард Месарош, xUnit Test Patterns):

Правило, к которому сходятся почти все зрелые команды:

  • Не мокайте то, чем не владеете. Мок JdbcTemplate, HttpClient, AmazonS3 проверяет ваши представления о чужом API, а не сам API. Если представления неверны — тест зелёный, прод красный. Вместо этого оборачивайте чужую библиотеку своим узким портом и мокайте порт, а сам порт проверяйте интеграционным тестом.
  • Не мокайте типы-значения. Мок Order, Money, LocalDate — почти всегда признак того, что настоящий объект неудобно создать; чините конструктор, а не тест.
  • Фейк побеждает мок там, где зависимость вызывается много раз. InMemoryOrderRepository на ConcurrentHashMap — 30 строк, которые заменяют сотни when(...).thenReturn(...) и попутно позволяют писать тесты на сценарии, а не на вызовы.

Mockito: как оно работает и где кусается

Mockito — не магия: он генерирует подкласс (или, с inline mock maker, переопределяет байткод класса через Java Instrumentation API) и перехватывает вызовы. С версии Mockito 5 inline mock maker включён по умолчанию, поэтому final-классы и статические методы мокаются без дополнительных артефактов — но цена этого выше, и в горячем тесте она заметна.

@ExtendWith(MockitoExtension.class)   // строгие заглушки + автоматическая инициализация
class OrderServiceTest {

    @Mock  PaymentGateway gateway;          // мок порта, которым мы владеем
    @Mock  OrderRepository repository;
    @InjectMocks OrderService service;      // конструктор получит моки

    @Test
    void приУспешнойОплатеЗаказПереходитВPAID() {
        var order = Order.newOrder("A-17", Money.of("19.98"));
        when(repository.findById("A-17")).thenReturn(Optional.of(order));
        when(gateway.charge(any(), any())).thenReturn(ChargeResult.ok("ch_1"));

        service.pay("A-17", "card_x");

        // Проверяем состояние (что сохранили), а не только факт вызова
        var saved = ArgumentCaptor.forClass(Order.class);
        verify(repository).save(saved.capture());
        assertThat(saved.getValue().status()).isEqualTo(OrderStatus.PAID);
        assertThat(saved.getValue().chargeId()).isEqualTo("ch_1");
    }

    @Test
    void приОтказеБанкаЗаказНеМеняетсяИСохраненияНет() {
        when(repository.findById("A-17"))
                .thenReturn(Optional.of(Order.newOrder("A-17", Money.of("19.98"))));
        when(gateway.charge(any(), any()))
                .thenThrow(new PaymentDeclinedException("insufficient_funds"));

        assertThatThrownBy(() -> service.pay("A-17", "card_x"))
                .isInstanceOf(PaymentDeclinedException.class);

        verify(repository, never()).save(any());   // критично: денег нет — статуса нет
    }
}

Несколько нюансов, на которых спотыкаются регулярно.

Строгие заглушки. MockitoExtension по умолчанию работает в режиме STRICT_STUBS: неиспользованная заглушка роняет тест с UnnecessaryStubbingException. Это раздражает первые два дня и спасает потом: «мёртвая» заглушка почти всегда означает, что тест давно проверяет не то, что вы думаете. Не отключайте Strictness.LENIENT глобально — точечно помечайте lenient().when(...).

Значения по умолчанию. Ненастроенный метод мока возвращает «пустое» значение: null для объектов, 0 для чисел, пустую коллекцию для List/Set/Map и… тоже null для Optional, если не включён RETURNS_DEEP_STUBS/Answers.RETURNS_SMART_NULLS. Отсюда классический NPE в тесте на методе, возвращающем Optional. Помните: ваш код ожидает Optional.empty(), мок молча даёт null.

verify не заменяет утверждение. Тест, состоящий только из verify(...), проверяет, что код вызвал то, что вы написали в коде. Он развалится при любом рефакторинге и не заметит, что результат неверный. Ориентир: на один verify должен приходиться хотя бы один assertThat на состоянии или на результате.

@InjectMocks — удобство с подвохом. Он молча оставит null в поле, для которого не нашёл мока, и вы получите NPE в непонятном месте. В новом коде честнее вызвать конструктор руками: new OrderService(gateway, repository, clock) — тест сразу перестанет компилироваться при изменении зависимостей, а это полезный сигнал.

Статические методы. mockStatic(Instant.class) работает, но это симптом. Время, случайные числа, UUID и путь к файлам — это зависимости; инжектируйте Clock, Supplier<UUID>, Random с фиксированным seed. Так тест становится и быстрее, и честнее.

Проектирование под тестируемость

Половина боли в Java-тестах — не про инструменты, а про то, что код спроектирован непроверяемо. Три приёма, дающие наибольший эффект.

Чистое ядро, тонкая оболочка. Правила («сколько скидки», «валиден ли заказ», «когда истекает подписка») формулируйте как чистые функции над record-ами — они тестируются без единого мока, мгновенно и в любом количестве. Ввод-вывод отодвигайте на края. Про records и sealed-иерархии — в ООП в Java, про чистые функции — в Stream API и функциональном стиле.

// Тестируется 40 кейсами за 3 мс, без Spring, без БД, без моков.
public record Cart(List<Item> items) {
    public Money total(Discounts d, Tier tier) { ... }
}

Зависимости через конструктор. Полевая инъекция (@Autowired на поле) делает объект непригодным для создания в тесте без контейнера. Конструктор — единственный способ, который даёт компилятор в союзники.

Время, случайность и окружение — параметры, а не глобальные вызовы. LocalDate.now() внутри бизнес-логики означает, что тест на «просрочку через 30 дней» либо будет менять системные часы, либо станет флаки 31-го числа. Clock из java.time создан ровно для этого.

Testcontainers: настоящая база вместо H2

Долгое время «интеграционный» тест в Java означал H2 в режиме совместимости с PostgreSQL. Это плохой договор: H2 не умеет jsonb, иначе трактует блокировки, у него другие типы, другой планировщик и другое поведение при конкурентных обновлениях. Тест зелёный, прод падает.

Testcontainers поднимает настоящий образ из Docker, дожидается готовности и отдаёт вам случайный порт хоста.

Ryuk — та деталь, ради которой стоит понимать картинку: это отдельный контейнер-«сторож», который держит соединение с тестовой JVM и убивает всё созданное, когда соединение рвётся. Поэтому упавший в CI прогон не оставляет висящих контейнеров. В окружениях без прав на запуск дополнительных контейнеров его отключают через TESTCONTAINERS_RYUK_DISABLED=true — и тогда уборка целиком на вас.

Правильный паттерн: контейнер один на весь прогон

Самая частая ошибка — нестатическое поле @Container: контейнер поднимается заново перед каждым тестовым методом, и набор из 40 тестов идёт восемь минут вместо двадцати секунд.

@Testcontainers
class OrderRepositoryIT {

    // static → один контейнер на весь класс (@BeforeAll/@AfterAll)
    @Container
    static final PostgreSQLContainer<?> POSTGRES =
            new PostgreSQLContainer<>("postgres:16-alpine")
                    .withDatabaseName("orders")
                    .withReuse(true);   // + testcontainers.reuse.enable=true в ~/.testcontainers.properties

    static DataSource dataSource;

    @BeforeAll
    static void migrate() {
        dataSource = pool(POSTGRES.getJdbcUrl(),
                          POSTGRES.getUsername(), POSTGRES.getPassword());
        Flyway.configure().dataSource(dataSource).load().migrate();  // те же миграции, что в проде
    }

    @AfterEach
    void cleanup() throws SQLException {
        // Дешевле и честнее, чем пересоздавать контейнер:
        try (var c = dataSource.getConnection(); var st = c.createStatement()) {
            st.execute("TRUNCATE orders, order_items RESTART IDENTITY CASCADE");
        }
    }

    @Test
    void сохраняетИЧитаетЗаказСJsonbМетаданными() {
        var repo = new JdbcOrderRepository(dataSource);
        repo.save(new Order("A-17", Money.of("19.98"), Map.of("source", "mobile")));

        assertThat(repo.findById("A-17")).get()
                .extracting(Order::metadata)
                .isEqualTo(Map.of("source", "mobile"));   // H2 бы это не проверил
    }
}

Ещё дешевле — singleton-контейнер: статическое поле в базовом классе (или в holder-классе), стартующее один раз на всю JVM и разделяемое всеми интеграционными тестами. Останавливать его не нужно: Ryuk уберёт.

public abstract class AbstractIT {
    static final PostgreSQLContainer<?> DB = new PostgreSQLContainer<>("postgres:16-alpine");
    static { DB.start(); }   // стартует однажды при первой загрузке класса
}

Отдельно стоит знать про JDBC-схему Testcontainers — самый короткий путь для теста, которому нужна просто база:

# контейнер поднимется сам при первом обращении к URL, скрипт применится автоматически
spring.datasource.url=jdbc:tc:postgresql:16-alpine:///orders?TC_INITSCRIPT=schema.sql
spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver

Testcontainers не ограничен базами: Kafka, Redis, RabbitMQ, LocalStack (эмуляция AWS), Elasticsearch, Keycloak, и GenericContainer для чего угодно. Родственные проекты есть для .NET, Go, Python и Node — но именно в Java модуль самый зрелый, и это одно из редких мест, где Java-экосистема объективно впереди остальных.

Интеграционные тесты Spring Boot: слайсы и кэш контекста

Полный разбор Spring — в следующей статье, но тестовая часть относится сюда, потому что именно она определяет время сборки.

@SpringBootTest поднимает весь контекст приложения. Это медленно (секунды), поэтому Spring предлагает срезы — конфигурации с ограниченным набором бинов:

Аннотация Что поднимает Для чего
@WebMvcTest(OrderController.class) web-слой, без БД JSON, статусы, валидация, обработка ошибок
@DataJpaTest JPA, репозитории, транзакции SQL, маппинг, миграции
@JsonTest Jackson-конфигурацию сериализация DTO
@SpringBootTest всё сквозные сценарии

Критически важная деталь: Spring TestContext Framework кэширует контексты, а ключ кэша — полный набор параметров конфигурации (классы, профили, property-источники, @MockitoBean). Пять тестовых классов с одинаковой конфигурацией разделят один контекст и стартуют один раз. Пять классов с чуть разными настройками поднимут пять контекстов — и сборка вырастет в разы.

Отсюда практические правила: держите один-два базовых тестовых конфига на приложение; @DirtiesContext используйте как крайнюю меру; @MockitoBean (в Spring Boot 3.4+; ранее @MockBean) — тоже фрагментирует кэш, поэтому выносите такие тесты в отдельную группу.

Начиная со Spring Boot 3.1 связка с Testcontainers делается декларативно:

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderApiIT {

    @Container
    @ServiceConnection                   // сам пропишет url/user/password в контекст
    static final PostgreSQLContainer<?> DB = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired TestRestTemplate http;

    @Test
    void создаётЗаказИВозвращает201СЗаголовкомLocation() {
        var response = http.postForEntity("/orders",
                new CreateOrderRequest("X1", 2), OrderResponse.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getHeaders().getLocation()).asString().contains("/orders/");
        assertThat(response.getBody().status()).isEqualTo("NEW");
    }
}

До 3.1 то же делалось через @DynamicPropertySource — метод, регистрирующий значения свойств после старта контейнера, но до создания контекста.

Ловушка @Transactional в тестах

@DataJpaTest по умолчанию оборачивает каждый тест в транзакцию и откатывает её. Удобно — и опасно:

  • изменения никогда не коммитятся, поэтому вы не проверите отложенные ограничения БД, триггеры на commit и то, что flush вообще произойдёт;
  • Persistence Context живёт весь тест, поэтому LazyInitializationException, который гарантированно случится в проде, в тесте не воспроизводится;
  • findById может вернуть объект из кэша первого уровня, а не из базы, — тест «проходит», даже если маппинг сломан.

Лечение: для проверок реального поведения ставьте @Transactional(propagation = NOT_SUPPORTED) или @Commit, вызывайте entityManager.flush() и clear() перед чтением, а данные между тестами чистите TRUNCATE. Подробнее о ловушках JPA — в Работе с данными.

Внешние HTTP-зависимости: WireMock

Чужой HTTP-API нельзя дёргать из тестов (недетерминизм, лимиты, деньги), но и мокать HttpClient бессмысленно — вы не проверите ни сериализацию, ни таймауты, ни ретраи. Правильный уровень подмены — сеть: поднимаем локальный сервер, отвечающий по контракту.

class PaymentGatewayClientTest {
    static WireMockServer wm;

    @BeforeAll static void up() { wm = new WireMockServer(options().dynamicPort()); wm.start(); }
    @AfterAll  static void down() { wm.stop(); }
    @AfterEach void reset() { wm.resetAll(); }

    @Test
    void приТаймаутеБросаетGatewayUnavailableИНеРетраитПлатёж() {
        wm.stubFor(post(urlEqualTo("/v1/charges"))
                .willReturn(aResponse().withFixedDelay(3_000).withStatus(200)));

        var client = new HttpPaymentGateway(URI.create(wm.baseUrl()), Duration.ofMillis(500));

        assertThatThrownBy(() -> client.charge("card_x", Money.of("19.98")))
                .isInstanceOf(GatewayUnavailableException.class);

        // Идемпотентность: повторного списания быть не должно
        wm.verify(1, postRequestedFor(urlEqualTo("/v1/charges")));
    }
}

WireMock умеет заданные задержки, обрыв соединения (Fault.CONNECTION_RESET_BY_PEER), сценарии со сменой состояния и запись реального трафика. Для проверки согласованности контракта между сервисами существуют контрактные тесты — Pact и Spring Cloud Contract; они особенно уместны в микросервисной архитектуре, где интеграционный тест «через все сервисы» неподъёмен.

Асинхронность, конкурентность и флаки

Главный источник нестабильных тестов в Java — Thread.sleep(200) в надежде, что фоновая задача успеет. На нагруженном CI-раннере не успевает.

// ПЛОХО: гонка, замаскированная задержкой
service.enqueue(order);
Thread.sleep(200);
assertThat(repository.findById("A-17")).isPresent();

// ХОРОШО: опрос с таймаутом (Awaitility)
service.enqueue(order);
await().atMost(Duration.ofSeconds(5))
       .pollInterval(Duration.ofMillis(50))
       .untilAsserted(() -> assertThat(repository.findById("A-17")).isPresent());

Ещё лучше — сделать асинхронность управляемой: передавать Executor как зависимость и в тестах подставлять синхронный (Runnable::run). Тогда проверка становится детерминированной без ожиданий вообще. Виртуальные потоки (Java 21+) не спасают от гонок в тестах: они делают блокировки дешёвыми, но не превращают недетерминированный код в детерминированный — см. Конкурентность.

Для собственно многопоточного кода полезны:

  • Thread.ofVirtual() + CountDownLatch для воспроизводимой одновременности;
  • jcstress — инструмент OpenJDK для проверки гонок и моделей памяти, единственный честный способ тестировать lock-free структуры;
  • прогон подозрительного теста 200 раз подряд (@RepeatedTest(200)) перед тем, как объявить его «стабильным».

Флаки-тест хуже отсутствующего: команда быстро учится перезапускать сборку не глядя, и вместе с флаки игнорируются настоящие падения. Правильная политика — карантин с дедлайном: тест помечается @Tag("flaky"), выносится из блокирующего набора и чинится за неделю или удаляется.

Покрытие, мутации, архитектура

JaCoCo — стандарт измерения покрытия: агент инструментирует байткод на лету и считает пройденные ветки. Настраивается порогом, ниже которого сборка падает:

<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <version>0.8.12</version>
  <executions>
    <execution><goals><goal>prepare-agent</goal></goals></execution>
    <execution><id>check</id><phase>verify</phase><goals><goal>check</goal></goals>
      <configuration><rules><rule>
        <element>BUNDLE</element>
        <limits><limit>
          <counter>BRANCH</counter><value>COVEREDRATIO</value><minimum>0.70</minimum>
        </limit></limits>
      </rule></rules></configuration>
    </execution>
  </executions>
</plugin>

Считайте ветки (BRANCH), а не строки: строчное покрытие легко накрутить, не проверив ни одного условия. И помните ограничение: покрытие показывает, какой код выполнился, а не какой проверен. Тест без единого утверждения даёт 100%.

Честный ответ на этот вопрос даёт мутационное тестирование: PIT вносит в байткод маленькие изменения (>>=, удаление вызова, инверсия условия) и проверяет, упадёт ли хоть один тест. «Выжившая мутация» — это точное место, где ваши тесты слепы. PIT медленный, поэтому его гоняют ночью или на изменённых классах (--changed-since), а не на каждый коммит.

Property-based тестирование (jqwik) проверяет свойства на сгенерированных данных и умеет минимизировать контрпример:

@Property
void сериализацияЗаказаОбратима(@ForAll("orders") Order original) {
    assertThat(json.parse(json.write(original))).isEqualTo(original);
}

@Property
void скидкаНикогдаНеБольшеСуммы(@ForAll @BigRange(min = "0", max = "1000000") BigDecimal sum,
                                @ForAll Tier tier) {
    assertThat(discounts.amountFor(sum, tier)).isLessThanOrEqualTo(sum);
}

ArchUnit проверяет то, что обычно живёт в head-е тимлида: правила зависимостей между слоями. Такой тест ловит архитектурную эрозию в момент коммита, а не через два года.

@AnalyzeClasses(packages = "com.example.shop")
class ArchitectureTest {

    @ArchTest
    static final ArchRule доменНеЗависитОтИнфраструктуры =
        noClasses().that().resideInAPackage("..domain..")
            .should().dependOnClassesThat()
            .resideInAnyPackage("..web..", "..persistence..", "org.springframework..");

    @ArchTest
    static final ArchRule циклыЗапрещены =
        slices().matching("com.example.shop.(*)..").should().beFreeOfCycles();
}

Про производительность и JMH (микробенчмарки — это тоже вид теста, но с совсем другими правилами) — в Производительности.

Скорость: как удержать сборку в пределах минут

Технические рычаги, в порядке отдачи:

  1. Параллельное исполнение Jupiter. Файл src/test/resources/junit-platform.properties:
    junit.jupiter.execution.parallel.enabled = true
    junit.jupiter.execution.parallel.mode.default = concurrent
    junit.jupiter.execution.parallel.mode.classes.default = concurrent
    junit.jupiter.execution.parallel.config.strategy = dynamic
    
    Обязательное условие — отсутствие разделяемого изменяемого состояния. Тесты, которым нужна эксклюзивность (меняют системное свойство, работают с общей таблицей), помечайте @ResourceLock или @Execution(SAME_THREAD).
  2. Форки Surefire. <forkCount>1C</forkCount> (по ядру) <reuseForks>true</reuseForks> — изоляция на уровне процессов; помогает, когда параллелизм внутри JVM невозможен.
  3. Один контекст Spring и один контейнер на весь набор — обычно это разница между 4 и 20 минутами.
  4. Теги и разделение наборов. @Tag("slow") + разные цели Maven; на pull request гоняем быстрые, ночью — всё.
  5. Кэш образов Docker в CI — иначе каждый прогон качает postgres:16 заново.

Про организацию всего этого в конвейере — Java в SDLC и трек DevOps; про тестирование как дисциплину, независимо от языка, — трек Тестирование.

Типичные грабли именно Java

  1. Интеграционный тест назван *Test — его подхватывает Surefire, Docker стартует на каждой сборке, -DskipITs не работает. Имя файла — это конфигурация.
  2. Нестатический @Container — контейнер на каждый метод. Проверяйте первым делом, если тесты «внезапно стали идти 10 минут».
  3. static-состояние переживает тесты. Surefire по умолчанию переиспользует форк: синглтон, кэш, System.setProperty из одного тест-класса видны в следующем. Симптом — тест зелёный поодиночке и красный в наборе (или наоборот).
  4. Зависимость от локали, таймзоны и кодировки. String.format("%,.2f", x), LocalDate.parse, toUpperCase() без Locale.ROOT ведут себя по-разному на машине разработчика и на UTC-раннере. Фиксируйте -Duser.language=en -Duser.country=US -Duser.timezone=UTC -Dfile.encoding=UTF-8 в конфигурации Surefire.
  5. assertEquals на BigDecimal — падает из-за масштаба (см. выше).
  6. Мок возвращает null вместо Optional.empty() — NPE в тесте на пустом результате.
  7. UnnecessaryStubbingException «лечат» через LENIENT — вместо того чтобы удалить заглушку, которая доказывает, что тест проверяет не тот путь.
  8. Тесты, зависящие от порядка. JUnit намеренно перемешивает порядок (детерминированный, но неалфавитный). Если вы тянетесь к @TestMethodOrder(OrderAnnotation.class) — почти всегда это способ спрятать общую мутируемую фикстуру.
  9. @Transactional на тесте скрывает LazyInitializationException и не проверяет commit.
  10. Тестирование приватных методов через reflection — цементирует реализацию. Если приватный метод хочется протестировать отдельно, это чаще всего отдельный класс.
  11. @MockBean/@MockitoBean в половине тестов — каждый уникальный набор моков создаёт новый контекст Spring; кэш перестаёт работать.
  12. Thread.sleep вместо ожидания условия — флаки при первой же нагрузке на CI.
  13. Хардкод порта (8080, 5432) — конфликт при параллельном прогоне. webEnvironment = RANDOM_PORT и getMappedPort() существуют ровно для этого.
  14. Ассерты внутри лямбды без assertAll — при провале первый же выбросит исключение, остальные не выполнятся, и вы будете чинить по одному дефекту за прогон.
  15. System.exit() в тестируемом коде — убивает JVM Surefire и даёт загадочное The forked VM terminated without properly saying goodbye.

Честно о месте Java в тестировании

Где выигрывает. Инструментальная зрелость беспрецедентна: JUnit — прародитель всей xUnit-семьи, а байткод-инструментирование (агенты, Instrumentation API) даёт возможности, недоступные компилируемым в машинный код языкам: покрытие без перекомпиляции, мутационное тестирование, мокирование final-классов, профилирование прямо в тесте. Testcontainers, рождённый в Java, задал стандарт интеграционного тестирования для всей индустрии. Богатая рефлексия делает возможным DI-контейнер в тестах со срезами, чего в языках без рефлексии не построить.

Где проигрывает. Цена входа во время: старт JVM (десятки-сотни миллисекунд), прогрев JIT, поднятие Spring-контекста (секунды). Набор из 5000 тестов на Go идёт быстрее набора из 500 тестов на Spring Boot. Культурная проблема — мок-тяжёлый стиль, унаследованный от эпохи тяжёлых фреймворков: тесты, дублирующие структуру кода вместо проверки поведения. Property-based тестирование, естественное для Elixir/Haskell/Rust, в Java существует (jqwik), но остаётся нишевым. Сравните с тестированием в Elixir, где async: true и изоляция процессов делают параллельность бесплатной по умолчанию.

Сравнение с .NET (трек C#/.NET): ниши почти идентичны, различий немного. JUnit 5 и xUnit концептуально близки (новый экземпляр на тест, атрибуты/аннотации, параметризация), Mockito и Moq решают одну задачу разными синтаксисами, Testcontainers существует в обеих экосистемах как один проект с общим протоколом. Отличия: у .NET нет прямого аналога срезов Spring (WebApplicationFactory ближе к @SpringBootTest), зато старт .NET-приложения обычно быстрее; у Java сильнее инструменты байткод-уровня (PIT, JaCoCo, jcstress) и заметно богаче выбор модулей Testcontainers. В остальном навыки переносятся почти один в один.

Куда не стоит тащить. Java-тесты плохо подходят для мгновенной обратной связи в стиле «сохранил файл — увидел результат»: даже с непрерывным прогоном (mvn -o test в watch-режиме) цикл длиннее, чем в скриптовых языках. Для исследовательского программирования и быстрых экспериментов честнее взять JShell или другой инструмент, а Java-тесты держать как регрессионную сеть.

Мини-итог

  • JUnit 5 = Platform (запуск) + Jupiter (модель) + Vintage (наследие). Проблемы «не запускается в CI» почти всегда про classpath движка и шаблон имени файла, а не про код.
  • Новый экземпляр тест-класса на каждый метод — это гарантия независимости. PER_CLASS берите осознанно и чистите состояние руками.
  • Утверждайте на состоянии и результате, а не на assertTrue. AssertJ, assertAll, isEqualByComparingTo для BigDecimal.
  • Мокайте только те границы, которыми владеете. Для часто вызываемых зависимостей фейк дешевле мока; для чужого HTTP — WireMock, а не мок клиента.
  • Testcontainers сделал H2-совместимость устаревшей практикой. Контейнер — статический, один на прогон, данные между тестами чистятся TRUNCATE.
  • Кэш контекста Spring — главный рычаг скорости интеграционных тестов. Считайте количество уникальных конфигураций.
  • Покрытие измеряет исполнение, а не проверку. Хотите узнать правду — PIT.
  • Флаки-тест лечится карантином с дедлайном, а не перезапуском сборки.

Источники

Что дальше

Мы научились проверять код на всех уровнях — от чистой функции до сквозного сценария с настоящей базой в контейнере. Дальше разберём фреймворк, вокруг которого построена подавляющая часть промышленного Java-кода: как работает внедрение зависимостей, откуда берутся бины, что именно происходит при старте Spring Boot и почему автоконфигурация — это не магия, а вполне читаемый механизм.

Spring и Spring Boot: DI, конфигурация, web-слой, что происходит под капотом

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов