Сборка идёт восемнадцать минут, и почти всё это время приложение поднимается заново — для каждого тестового класса. А когда тест падает, из сообщения непонятно, что сломалось: контроллер, запрос к базе или настройка, которую поменяли неделю назад.
Оба симптома растут из одного места: тест написан не на том уровне, где живёт проверяемая ошибка. Spring умеет подниматься целиком, кусочком или не подниматься вовсе, и разница между режимами меряется не процентами, а сотнями раз по времени.
Цифры примерные, важен порядок величин: без контекста тест стартует мгновенно, срез — за секунду-другую, полный контекст с базой — за десяток секунд. Но и проверяют они разное: @SpringBootTest не заменяет unit-тест, а unit-тест не поймает опечатку в маршруте. Сначала решают, что нужно проверить, и только потом считают секунды.
Когда Spring не нужен вовсе
Самая частая лишняя трата — поднимать приложение, чтобы проверить формулу. Скидка постоянному покупателю, правило «дешевле тысячи — доставка платная» — всё это живёт в обычном классе, которому ни база, ни веб не нужны: его создают руками, подсовывают в конструктор заглушки и вызывают метод напрямую.
class CreateOrderHandlerTest {
private final OrderRepository orderRepo = mock(OrderRepository.class);
private final EventPublisher events = mock(EventPublisher.class);
private final CreateOrderHandler handler = new CreateOrderHandler(orderRepo, events);
@Test
void creates_order_and_publishes_event() {
handler.handle(new CreateOrderCommand(UUID.randomUUID(), List.of(new Line("SKU-1", 2))));
verify(orderRepo).save(any(Order.class));
verify(events).publish(any(OrderCreatedEvent.class));
}
}
Ни одной аннотации Spring — и это главное: контейнер не стартует, тест идёт миллисекунды, а таких тестов могут быть тысячи, и прогон всё равно уложится в минуту. Если класс не ходит в базу, в сеть и в очередь, поднимать ради него приложение незачем. Обратная сторона: такой тест не знает, зарегистрирован ли контроллер по нужному адресу и переживёт ли запрос настоящий PostgreSQL.
Проверить один слой, не поднимая всё приложение
Формулу проверили, осталась обвязка, которую делает сам Spring: маршрут POST /api/v1/orders, разбор тела в объект, код 201 и заголовок Location. Значит, Spring нужен — но не весь: база, очереди и планировщик к этой проверке отношения не имеют, а стартуют дольше всего. Для таких случаев есть срезы (в документации — slice tests): аннотации, каждая из которых поднимает контекст, урезанный до одного слоя. Что в срез попало, а что нет, решено заранее — и именно это чаще всего удивляет.
Строка «фильтры Spring Security» — источник самой частой неожиданности: в срезе контроллера security есть, и запрос до контроллера может не дойти. Строка «база данных» — источник второй: в срезе репозитория база не та, что в проде.
Контроллер, JSON и коды ответов: @WebMvcTest
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired private MockMvc mvc;
@MockitoBean private CreateOrderUseCase createOrder;
@Test
void create_order_returns_201() throws Exception {
when(createOrder.handle(any())).thenReturn(new OrderId(UUID.randomUUID()));
mvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{ "customerName": "Иван", "lines": [] }
"""))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"));
}
}
В срез попадают контроллеры, валидаторы, обработчики ошибок и Jackson. Всё остальное — ваши сервисы, репозитории, клиенты чужих систем — не поднимается вовсе: то, что контроллеру нужно по конструктору, вы подставляете сами через @MockitoBean.
У этой аннотации два поколения, и старый код сбивает с толку: @MockBean и @SpyBean из Spring Boot объявлены устаревшими в версии 3.4, на их место пришли @MockitoBean и @MockitoSpyBean из Spring Framework 6.2. Ведут себя они одинаково, в новом коде берут новые.
А вот security в срез попадает, и это первая неожиданность. Вместе с MockMvc подтягивается автоконфигурация Spring Security с цепочкой фильтров, поэтому в проекте с spring-boot-starter-security тот же post(...) без CSRF-токена вернёт 403, а GET без пользователя не дойдёт до контроллера — будет 401 или переадресация на форму входа. Лечится не отключением среза, а @WithMockUser на тесте и .with(csrf()) на запросе.
Запросы к базе: @DataJpaTest
@DataJpaTest
class OrderRepositoryTest {
@Autowired private OrderRepository repo;
@Autowired private TestEntityManager em;
@Test
void finds_by_customer_id() {
var customer = em.persist(new Customer("Иван"));
em.persist(new Order(customer, BigDecimal.valueOf(100)));
em.flush();
var found = repo.findByCustomerId(customer.getId());
assertThat(found).hasSize(1);
}
}
Здесь всё зеркально: поднимаются EntityManager, репозитории и datasource, а веба нет. Каждый тестовый метод идёт внутри транзакции, которая в конце откатывается, — тесты не мешают друг другу, и чистить за собой не нужно.
Удивляет другое: база в этом срезе не ваша. Настоящий datasource подменяется на встроенный, обычно H2, и сама H2 ниоткуда не появляется: нет com.h2database:h2 в тестовых зависимостях — тест до проверок не дойдёт и скажет Failed to replace DataSource with an embedded database for tests.
Поставить вместо неё настоящий PostgreSQL из контейнера можно, но тут ступенька. Подстановкой заведует @AutoConfigureTestDatabase, и до Spring Boot 3.4 он срабатывал всегда: контейнер поднимался, а тест всё равно уезжал на H2 — спасал только явный replace = Replace.NONE. С версии 3.4 по умолчанию стоит NON_TEST, и datasource от @ServiceConnection, @DynamicPropertySource или URL вида jdbc:tc: больше не подменяется.
Остальные срезы и когда их берут
Срезов больше десятка, но регулярно нужны два.
@JsonTest — когда спор про формат ответа: дата ушла числом вместо строки, поле с null пропало из JSON, имя поля не то, что ждёт мобильное приложение. Поднимается один Jackson с вашими настройками, и это быстрее среза контроллера.
@RestClientTest — когда ваш код сам ходит в чужой сервис через RestTemplate или RestClient. Срез даёт MockRestServiceServer: заглушку, которой говорят «на этот адрес ответь вот таким телом и кодом 500». Для WebClient он не работает — там MockWebServer или WireMock.
Остальные (@JdbcTest, @DataMongoTest, @DataRedisTest) устроены так же, только для своего хранилища; полный список — в документации Spring Boot.
Когда нужен весь контекст: @SpringBootTest
Срез не поймает ошибку на стыке слоёв: контроллер собрал ответ правильно, репозиторий сохранил правильно, а вместе они дают заказ без позиций — транзакция закрылась раньше. Такое видит только приложение целиком.
@SpringBootTest
@AutoConfigureMockMvc
class OrderFlowTest {
@Autowired private MockMvc mvc;
@Test
void create_then_fetch() throws Exception {
var created = mvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON).content(ORDER_JSON))
.andReturn().getResponse();
mvc.perform(get(created.getHeader("Location")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("DRAFT"));
}
}
Параметр webEnvironment решает, будет ли у теста настоящий веб-сервер. По умолчанию стоит MOCK: Tomcat не стартует, запросы идут через MockMvc мимо сети. RANDOM_PORT поднимает Tomcat на свободном порту, и в тест приходят TestRestTemplate или WebTestClient; берут его, когда важна именно работа по сети — свой HTTP-клиент, таймауты, сжатие. DEFINED_PORT — то же, но на фиксированном порту.
Ловушка ровно одна: RANDOM_PORT и MockMvc в одном тесте не дружат. Tomcat стартует, а mvc.perform(...) идёт мимо него — запрос не выходит в сеть, и тест платит за сервер, которым не пользуется. Либо MOCK и MockMvc, либо RANDOM_PORT и TestRestTemplate с WebTestClient.
Чем стучаться в свой HTTP: MockMvc или WebTestClient
Оба отправляют запрос в ваше приложение и разбирают ответ. Выбирают по стеку, а не по вкусу.
| MockMvc | WebTestClient | |
|---|---|---|
| Стек | MVC (Servlet) | MVC и WebFlux |
| Под капотом | DispatcherServlet без сервера | настоящий Tomcat с RANDOM_PORT либо MockMvcWebTestClient.bindTo(mvc) — тоже без сервера |
| Скорость | без сети и без сервера | с RANDOM_PORT это настоящий HTTP через петлю, на сотнях тестов разница заметна; без сервера — наравне с MockMvc |
В MVC-проекте берут MockMvc, в WebFlux — WebTestClient; если есть и то и другое, WebTestClient закрывает оба случая.
Почему прогон растёт от каждого нового теста
Вернёмся к восемнадцати минутам. В отчёте о прогоне строчка о старте приложения появляется почти в каждом тестовом классе: сорок классов — сорок стартов по двенадцать секунд, восемь минут одного только подъёма. Так быть не должно — Spring умеет поднять приложение один раз и отдавать его всем тестам подряд. Механизм называется кешем контекста, и ломается он тише всего.
Как свести всё к одному контексту
Лечение — унификация: один базовый класс (или своя мета-аннотация), от которого наследуются все интеграционные тесты — общий профиль, общие свойства, общие контейнеры.
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
public abstract class IntegrationTestBase {
@ServiceConnection
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16");
static { POSTGRES.start(); }
}
Дальше — правило, которое удерживает ключ одинаковым: заглушки внешних систем не объявляют в тестовых классах. Вместо @MockitoBean в каждом втором классе их подменяют один раз, тестовым бином в общей конфигурации: WireMock вместо чужого HTTP-сервиса, заглушка вместо отправки почты. Тогда набор заглушек у всех одинаковый — и ключ один.
@DirtiesContext здесь крайняя мера: она не «чистит контекст», а выбрасывает его из кеша, и следующий класс поднимает приложение заново. Её оставляют там, где тест правда ломает контекст (подменил бин, закрыл пул), а потребность «начать с чистых данных» решают очисткой таблиц.
Проверить, сработало ли, можно не считая строчки о старте: включите уровень DEBUG для org.springframework.test.context.cache — он напишет статистику кеша, размер и число промахов. Промахов должно быть примерно столько, сколько у вас правда разных конфигураций.
Настоящая база вместо H2: TestContainers
H2 в срезе репозитория экономит время, но это другая база: иначе понимает типы, не знает jsonb и половины функций PostgreSQL, по-своему ведёт себя на ON CONFLICT и блокировках. Итог знакомый — двести зелёных прогонов на H2 и падение на проде при первом же дубле. Чтобы тест шёл на той же базе, что и прод, её поднимают в контейнере на время прогона: этим занимается библиотека TestContainers.
@SpringBootTest
@Testcontainers
class OrderIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Container
@ServiceConnection
static ConfluentKafkaContainer kafka = new ConfluentKafkaContainer("confluentinc/cp-kafka:7.8.0");
@Test
void writes_to_postgres_and_publishes_to_kafka() { }
}
Контейнер поднимается на случайном порту, и приложению надо этот порт сообщить. Раньше это делали руками через @DynamicPropertySource, с версии 3.1 хватает @ServiceConnection: она сама проставит spring.datasource.url, spring.kafka.bootstrap-servers и остальные свойства подключения.
Про Kafka отдельно: старый KafkaContainer из пакета org.testcontainers.containers устарел. Под образы Confluent теперь ConfluentKafkaContainer, под apache/kafka — одноимённый KafkaContainer, но из пакета org.testcontainers.kafka. Оба класса @ServiceConnection понимает с версии 3.4.
Транзакция вокруг теста: удобство и ловушка
Чистить данные приходится не всегда: у теста может быть своя транзакция, которая в конце откатывается. Важно понимать, откуда она берётся: сам по себе @SpringBootTest тест в транзакцию не оборачивает и никогда не оборачивал. Её даёт только явный @Transactional на тестовом классе или аннотация, которая несёт его в себе — так устроен @DataJpaTest.
Удобство очевидное: после теста Spring делает откат, и следующий тест видит чистую базу. Ловушка в том, что такой тест проверяет не то, что происходит в проде: там транзакция коммитится, а здесь нет, и всё привязанное к фиксации не случается. Обработчик с @TransactionalEventListener(AFTER_COMMIT) не сработает ни разу, метод с REQUIRES_NEW откроет свою транзакцию и увидит базу без данных, которые «записал» тест, триггеры и отложенные ограничения промолчат.
Поэтому сценарии, где важна фиксация, пишут без @Transactional, и за чистотой базы следят сами. Первое, что приходит в голову, работает плохо:
// так не надо: порядок удаления надо помнить, и он ломается на первой же новой связи
@AfterEach
void cleanup() {
customerRepo.deleteAll();
orderRepo.deleteAll();
}
Первым же вызовом такая очистка и упадёт: на покупателей ссылаются их заказы, а внешний ключ не даёт удалить строку, на которую ссылаются. Значит, порядок вызовов придётся держать в голове и переписывать при каждой новой таблице; вдобавок deleteAll() в JPA вычитывает сущности и удаляет их по одной. Надёжнее отдать разбор связей базе:
// так надо: один скрипт, порядок и связи — забота базы
@Test
@Sql(scripts = "/sql/truncate-all.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)
void publishes_event_after_commit() {
service.create(command);
assertThat(mailbox.messages()).hasSize(1);
}
Внутри скрипта — одна команда вида TRUNCATE TABLE order_line, orders, customers RESTART IDENTITY CASCADE: она чистит перечисленные таблицы вместе со связанными, сбрасывает счётчики идентификаторов и не зависит от объёма данных.
Как уложить прогон в бюджет сборки
Даже аккуратные интеграционные тесты идут минуты, и это нормально. Ненормально — платить за них при каждом изменении строки кода.
Быстрые отдельно от медленных
Тесты разводят на два прогона: быстрый, на каждое изменение, за десятки секунд, и полный, перед слиянием. Проще всего развести их по имени файла — интеграционным дают суффикс IT:
val integrationTest by tasks.registering(Test::class) {
useJUnitPlatform()
include("**/*IT.class")
}
tasks.test { exclude("**/*IT.class") }
Второй способ — метки JUnit: @Tag("slow") на классе и includeTags / excludeTags в настройке задачи. Он гибче, когда деление идёт не по слою, а по стоимости.
Глубже: из чего складывается ключ контекстарасширенное
Кешируется контекст не «на проект», а по ключу. В ключ входит всё, что могло бы сделать этот контекст другим: набор классов конфигурации, активные профили, добавленные свойства (@TestPropertySource) и — про это забывают чаще всего — набор заглушек @MockitoBean и @MockitoSpyBean. Два класса с одинаковым ключом делят один контекст, и второй стартует мгновенно. Любое отличие, вплоть до одной лишней заглушки, — новый ключ и ещё один старт.
Классы не делят контекст «по-соседски»: они делят его только при полном совпадении ключа. Поэтому один `@MockitoBean`, добавленный в один класс, стоит ровно столько же, сколько лишний старт приложения.
Глубже: кеш контекстов не резиновыйрасширенное
Пока ключей мало, это терпимо: подняли десять контекстов, зато каждый переиспользовали по пять раз. Но у кеша есть предел — 32 контекста (свойство spring.test.context.cache.maxSize), и на пределе самый давно не использованный контекст закрывается и выбрасывается.
Отсюда самое неприятное свойство разросшегося прогона: время растёт не пропорционально числу тестов, а рывком. Пока ключей тридцать, каждый контекст поднимается один раз. Стало тридцать пять — контексты начинают выбивать друг друга, и закрытый ради нового следующему классу придётся поднимать заново. Добавили один класс, а прогон прибавил не двенадцать секунд, а несколько минут.
Глубже: один контейнер на несколько прогоноврасширенное
Старт PostgreSQL занимает секунды, и при частом запуске тестов из среды разработки это раздражает. Контейнер можно не гасить, а подключаться к уже работающему:
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withReuse(true);
У флага два условия. Первое: сам по себе withReuse(true) ничего не включает — переиспользование разрешают на машине, строкой testcontainers.reuse.enable=true в файле ~/.testcontainers.properties. В сборочной среде такой строки обычно нет, и там контейнер каждый раз поднимается заново; так и задумано.
Второе важнее: переживший прогон контейнер сохраняет данные. Тест, оставивший после себя три заказа, во второй раз увидит их снова и упадёт на проверке «заказов должно быть три», хотя вчера был зелёным. Отсюда знакомое «локально красный, в сборке зелёный»: с переиспользованием тесты обязаны чистить за собой сами.
Глубже: асинхронный код в тестерасширенное
@Async, слушатель очереди, задача по расписанию выполняются в другом потоке, и тест об этом не знает: он дошёл до проверки раньше, чем работа началась. Самый частый способ «починить» — Thread.sleep(2000) — плох вдвойне: на быстрой машине крадёт две секунды у каждого прогона, на медленной иногда всё равно не хватает, и тест падает через раз.
Если асинхронность здесь не проверяется, а просто мешает, её убирают — подменяют исполнителя на такого, который выполняет задачу прямо в вызывающем потоке:
@TestConfiguration
class SyncExecutorConfig {
@Bean
@Primary
Executor taskExecutor() {
return Runnable::run;
}
}
С таким бином @Async-метод отработает до возврата из вызова, и тест увидит результат сразу.
Когда проверяется именно асинхронная работа — слушатель очереди, обработчик после фиксации, — исполнителя подменять нельзя. Тогда результат ждут не паузой, а повторной проверкой до срабатывания или до истечения срока; это делает библиотека Awaitility:
await().atMost(Duration.ofSeconds(5))
.untilAsserted(() -> assertThat(repo.findById(id)).isPresent());
Проверка повторяется каждые сто миллисекунд и завершается, как только условие выполнено: на быстрой машине тест закончится за десятые доли секунды, а пятисекундный предел сработает, только если что-то сломалось.
Глубже: параллельный запускрасширенное
Когда медленные отделены, их прогон ускоряют параллельным запуском — JUnit 5 умеет это без сторонних инструментов:
# junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
Цена — память: параллельные классы держат свои контексты одновременно, пять разных ключей — пять живых приложений вместе с пулами соединений. Поэтому выигрыш получается ровно тогда, когда ключ у тестов общий: один контекст, один контейнер базы, много потоков. Там, где у каждого класса свой набор заглушек, параллельность добавит расход памяти, а не скорость.
И про данные: тесты, работающие одновременно с одной базой, мешают друг другу. Либо каждый пишет в свои строки (свой покупатель, свой заказ), либо такие классы помечают как несовместимые с параллельным запуском.
Коротко
- Уровень выбирают по тому, где живёт ошибка: формулу — без Spring, маршрут и разбор JSON — срезом, стык слоёв и настройки — полным контекстом. В
@WebMvcTestпри этом есть security и нет базы, в@DataJpaTest— наоборот, и база там встроенная. - Прогон растёт не от числа тестов, а от числа разных ключей кеша контекста: конфигурация, профили, свойства, набор
@MockitoBean. Кеш хранит 32 контекста и дальше вытесняет старые — время прыгает рывком. - Отсюда главное правило: один базовый тестовый класс на проект, заглушки внешних систем — в общей конфигурации.
@DirtiesContextвыбрасывает контекст из кеша, это крайняя мера. @MockBeanи@SpyBeanустарели в Spring Boot 3.4; в новом коде —@MockitoBeanи@MockitoSpyBean.- TestContainers дают ту же базу, что в проде,
@ServiceConnectionсама пробрасывает адрес. Переиспользование контейнера включают файлом~/.testcontainers.properties, и данные в нём остаются от прошлого прогона. @Transactionalна тесте отменяет всё, привязанное к фиксации:AFTER_COMMIT,REQUIRES_NEW, триггеры. Такие сценарии пишут без транзакции, с очисткой через@SqlиTRUNCATE ... CASCADE.- Асинхронное не ждут паузой: либо синхронный исполнитель, либо ожидание условия через Awaitility.
- Медленные тесты отделяют от быстрых (суффикс
ITили метка JUnit), а параллельный запуск помогает только там, где контекст общий.
Что пощупать
Все три этажа тестов из статьи стоят в практикуме remodov/marketplace-system рядом и с разным временем прогона: правила товара без Spring за миллисекунды, HTTP через MockMvc с одним контекстом на все классы, и у взрослого сервиса заказов — настоящая база, WireMock вместо соседей и подменённые время и идентификаторы.
Код: тесты стартового каталога, test-utils заказа.
Что почитать дальше
- Пирамида тестов — сколько каких тестов держать, чтобы прогон не разъехался.
- Интеграционные тесты — TestContainers и работа с настоящей инфраструктурой подробнее.
- Моки и внешние системы — чем и как подменять чужие сервисы, чтобы заглушка не разошлась с реальностью.
@Transactionalглубоко — режимы распространения и фазы транзакции, из-за которых тест видит не то, что прод.