← назад к разделу

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

Оба симптома растут из одного места: тест написан не на том уровне, где живёт проверяемая ошибка. Spring умеет подниматься целиком, кусочком или не подниматься вовсе, и разница между режимами меряется не процентами, а сотнями раз по времени.

одна и та же проверка, три способа — и разная цена старта 0 5 с 10 с 15 с JUnit + моки Spring не стартует 0,05 спроверяет логику самого класса — и только её @WebMvcTest срез: контроллер и Jackson 1,8 спроверяет маршрут, разбор JSON, код ответа @SpringBootTest контекст целиком + Postgres 12 спроверяет все слои вместе: SQL, транзакции, настройки

Цифры примерные, важен порядок величин: без контекста тест стартует мгновенно, срез — за секунду-другую, полный контекст с базой — за десяток секунд. Но и проверяют они разное: @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): аннотации, каждая из которых поднимает контекст, урезанный до одного слоя. Что в срез попало, а что нет, решено заранее — и именно это чаще всего удивляет.

что поднимается внутри теста, а чего в нём нет @WebMvcTest @DataJpaTest @SpringBootTest контроллеры, Jackson, валидация фильтры Spring Security репозитории, EntityManager ваши @Service и @Component планировщик, слушатели очередей база данных есть нет есть есть нет есть нет есть есть нет нет есть нет нет есть нет встроенная как в проекте маршрут, JSON и security проверить можно, базу — нет SQL и маппинг проверить можно, веб и свои сервисы — нет проверить можно всё — и заплатить придётся за всё срез поднимает один слой; полный контекст — все сразу

Строка «фильтры 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

Оба отправляют запрос в ваше приложение и разбирают ответ. Выбирают по стеку, а не по вкусу.

MockMvcWebTestClient
Стек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. Два класса с одинаковым ключом делят один контекст, и второй стартует мгновенно. Любое отличие, вплоть до одной лишней заглушки, — новый ключ и ещё один старт.

ключ кеша = конфигурация + профили + свойства + набор заглушек OrderApiTest заглушка: PaymentClient OrderFlowTest заглушка: PaymentClient ReportTest заглушки: PaymentClient, Clock первый стартконтекст №112 с на старт, дальше 0 ключ тот же — из кеша одна лишняя заглушкаконтекст №2ещё 12 с тридцать три разных ключа — и кеш начинает вытеснять сам себя

Классы не делят контекст «по-соседски»: они делят его только при полном совпадении ключа. Поэтому один `@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 заказа.

Что почитать дальше