Тестирование: 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.
discover() → execute()"] subgraph Движки["TestEngine SPI"] JUP["junit-jupiter-engine
@Test, @Nested, расширения"] VIN["junit-vintage-engine
наследие JUnit 4"] OTH["jqwik / Spock / Cucumber
сторонние движки"] end TP["TestPlan: дерево
контейнеров и тестов"] LST["TestExecutionListener
отчёты, IDE, JaCoCo"] IDE --> L SF --> L GR --> L L --> JUP L --> VIN L --> OTH JUP --> TP VIN --> TP OTH --> TP TP --> LST
Практический вывод: если тест не запускается в 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.
В 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"); // проверяем данные, не текст
}
Три правила, которые снимают большинство «непонятных красных тестов»:
- Никогда
assertTrue(a.equals(b)). При провале вы увидитеexpected: true but was: false— ноль информации.assertThat(a).isEqualTo(b)покажет обе стороны и diff. BigDecimalсравнивайте черезisEqualByComparingTo.equalsуBigDecimalучитывает масштаб:new BigDecimal("2.0").equals(new BigDecimal("2"))— этоfalse. Классические полдня отладки в финансовом коде.- Не проверяйте текст сообщения исключения как основной контракт — он локализуемый и меняется при рефакторинге. Проверяйте тип и структурированные поля.
Для 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, ответьте: какую границу пересекает тест и почему её нельзя пересечь по-настоящему?
Классическая таксономия тестовых дублей (Джерард Месарош, 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).
Пять тестовых классов с одинаковой конфигурацией разделят один контекст и стартуют один раз.
Пять классов с чуть разными настройками поднимут пять контекстов — и сборка вырастет в разы.
@SpringBootTest, профиль test"] --> K1["ключ кэша 1"] B["PaymentServiceIT
@SpringBootTest, профиль test"] --> K1 C["ShippingIT
тот же тест плюс @MockitoBean"] --> K2["ключ кэша 2"] D["PricingIT
тот же тест плюс @TestPropertySource"] --> K3["ключ кэша 3"] E["любой тест с @DirtiesContext"] --> X["контекст выброшен из кэша:
следующий ждёт полного старта"] K1 --> S1["один старт контекста
на два класса"] K2 --> S2["ещё один старт"] K3 --> S3["ещё один старт"]
Отсюда практические правила: держите один-два базовых тестовых конфига на приложение;
@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 (микробенчмарки — это тоже вид теста, но с совсем другими правилами) — в Производительности.
Скорость: как удержать сборку в пределах минут
Технические рычаги, в порядке отдачи:
- Параллельное исполнение 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). - Форки Surefire.
<forkCount>1C</forkCount>(по ядру)<reuseForks>true</reuseForks>— изоляция на уровне процессов; помогает, когда параллелизм внутри JVM невозможен. - Один контекст Spring и один контейнер на весь набор — обычно это разница между 4 и 20 минутами.
- Теги и разделение наборов.
@Tag("slow")+ разные цели Maven; на pull request гоняем быстрые, ночью — всё. - Кэш образов Docker в CI — иначе каждый прогон качает
postgres:16заново.
Про организацию всего этого в конвейере — Java в SDLC и трек DevOps; про тестирование как дисциплину, независимо от языка, — трек Тестирование.
Типичные грабли именно Java
- Интеграционный тест назван
*Test— его подхватывает Surefire, Docker стартует на каждой сборке,-DskipITsне работает. Имя файла — это конфигурация. - Нестатический
@Container— контейнер на каждый метод. Проверяйте первым делом, если тесты «внезапно стали идти 10 минут». static-состояние переживает тесты. Surefire по умолчанию переиспользует форк: синглтон, кэш,System.setPropertyиз одного тест-класса видны в следующем. Симптом — тест зелёный поодиночке и красный в наборе (или наоборот).- Зависимость от локали, таймзоны и кодировки.
String.format("%,.2f", x),LocalDate.parse,toUpperCase()безLocale.ROOTведут себя по-разному на машине разработчика и на UTC-раннере. Фиксируйте-Duser.language=en -Duser.country=US -Duser.timezone=UTC -Dfile.encoding=UTF-8в конфигурации Surefire. assertEqualsнаBigDecimal— падает из-за масштаба (см. выше).- Мок возвращает
nullвместоOptional.empty()— NPE в тесте на пустом результате. UnnecessaryStubbingException«лечат» черезLENIENT— вместо того чтобы удалить заглушку, которая доказывает, что тест проверяет не тот путь.- Тесты, зависящие от порядка. JUnit намеренно перемешивает порядок (детерминированный,
но неалфавитный). Если вы тянетесь к
@TestMethodOrder(OrderAnnotation.class)— почти всегда это способ спрятать общую мутируемую фикстуру. @Transactionalна тесте скрываетLazyInitializationExceptionи не проверяет commit.- Тестирование приватных методов через reflection — цементирует реализацию. Если приватный метод хочется протестировать отдельно, это чаще всего отдельный класс.
@MockBean/@MockitoBeanв половине тестов — каждый уникальный набор моков создаёт новый контекст Spring; кэш перестаёт работать.Thread.sleepвместо ожидания условия — флаки при первой же нагрузке на CI.- Хардкод порта (
8080,5432) — конфликт при параллельном прогоне.webEnvironment = RANDOM_PORTиgetMappedPort()существуют ровно для этого. - Ассерты внутри лямбды без
assertAll— при провале первый же выбросит исключение, остальные не выполнятся, и вы будете чинить по одному дефекту за прогон. 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.
- Флаки-тест лечится карантином с дедлайном, а не перезапуском сборки.
Источники
- JUnit 5 User Guide — исчерпывающая документация: расширения, параметризация, параллельность.
- AssertJ Core documentation — справочник утверждений.
- Mockito javadoc и введение — канонический источник, читайте раздел про strictness.
- Testcontainers for Java — модули, wait-стратегии, reuse, Ryuk.
- Spring Boot: Testing —
срезы,
@ServiceConnection, кэш контекста. - WireMock documentation — стабы, сценарии, отказы сети.
- PIT Mutation Testing и jqwik — проверка качества самих тестов.
- ArchUnit User Guide — архитектурные правила как тесты.
- Gerard Meszaros, xUnit Test Patterns — источник терминологии дублей и каталог тестовых запахов.
- Vladimir Khorikov, Unit Testing: Principles, Practices, and Patterns — лучшая современная книга о том, что считать хорошим тестом.
- Freeman, Pryce, Growing Object-Oriented Software, Guided by Tests — первоисточник mockist-подхода, полезно прочитать вместе с критикой Хорикова.
- Martin Fowler, Mocks Aren’t Stubs — статья, задавшая рамку спора «классики против мокистов».
Что дальше
Мы научились проверять код на всех уровнях — от чистой функции до сквозного сценария с настоящей базой в контейнере. Дальше разберём фреймворк, вокруг которого построена подавляющая часть промышленного Java-кода: как работает внедрение зависимостей, откуда берутся бины, что именно происходит при старте Spring Boot и почему автоконфигурация — это не магия, а вполне читаемый механизм.
Spring и Spring Boot: DI, конфигурация, web-слой, что происходит под капотом