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

Тесты в Spring-проекте обычно живут на трёх уровнях, и важно не путать их назначение:

  • Unit-тесты — без Spring-контекста вообще. Просто new MyService(mock(...)). Самые быстрые, изолированные.
  • Slice-тесты — частичный контекст под конкретный слой: только MVC, только JPA, только JSON-сериализация.
  • Integration-тесты — полный контекст через @SpringBootTest + TestContainers.

Большинство проблем с тестами — попытка делать одним типом то, что лучше делается другим: писать всё через @SpringBootTest (медленно, флаки), либо вообще не использовать слайсы.

Unit-тесты — без Spring

Самый быстрый уровень. Spring не нужен, если класс — обычный POJO или Spring-@Service, которому можно подсунуть моки в конструктор:

class CreateOrderUseCaseHandlerTest {

    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() {
        var cmd = new CreateOrderCommand(UUID.randomUUID(), List.of(...));

        handler.handle(cmd);

        verify(orderRepo).save(any(Order.class));
        verify(events).publish(any(OrderCreatedEvent.class));
    }
}

Тест выполняется за миллисекунды. Любой класс, который не зависит от инфраструктуры, тестируется так.

Slice-тесты

Когда нужен частичный контекст — например, протестировать сериализацию контроллера, но без БД.

@WebMvcTest — контроллеры и MVC-слой

@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.fromString("...")));

        mvc.perform(post("/api/v1/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    { "customerName": "Иван", "lines": [...] }
                """))
            .andExpect(status().isCreated())
            .andExpect(header().exists("Location"));
    }
}

@WebMvcTest поднимает только MVC-слой (controllers, validators, exception handlers, Jackson). JPA, security, custom бины — не подгружаются. Быстро.

@DataJpaTest — repository-слой

@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);
    }
}

@DataJpaTest поднимает только JPA: EntityManager, репозитории, datasource (по умолчанию embedded H2). Каждый тест в транзакции, которая откатывается в конце — изоляция бесплатно.

Подмена H2 на реальный PostgreSQL через TestContainers — см. ниже.

Другие слайсы

  • @JsonTest — Jackson-сериализация.
  • @RestClientTestRestClient / WebClient + MockRestServiceServer.
  • @DataMongoTest — Spring Data Mongo.
  • @JdbcTest — голый JDBC + datasource.

Список — в spring-boot-test-autoconfigure.

@SpringBootTest — полный контекст

Когда нужен весь контекст приложения — security, custom бины, application events, реальная связка всех слоёв:

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class OrderE2ETest {

    @Autowired private MockMvc mvc;
    @Autowired private OrderRepository repo;

    @Test
    void create_then_fetch() throws Exception {
        var response = mvc.perform(post("/api/v1/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(...))
            .andReturn().getResponse();

        var location = response.getHeader("Location");
        mvc.perform(get(location))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.status").value("DRAFT"));
    }
}

webEnvironment:

  • MOCK (default)MockMvc, без поднятия Tomcat.
  • RANDOM_PORT — поднимает Tomcat на случайном порту. Используется с TestRestTemplate или WebTestClient.
  • DEFINED_PORT — на конкретном порту (для e2e с внешним клиентом).

MockMvc vs WebTestClient

Оба — для тестирования HTTP-слоя. Отличия:

MockMvcWebTestClient
СтекMVC (Servlet)MVC + WebFlux
Под капотомDispatcherServlet через mock-объектыРеальный Tomcat (с RANDOM_PORT) или WebTestClient.bindToController
СинтаксисJava DSL с большим количеством andExpectFluent с лучшей читаемостью
СкоростьЧуть быстрееЧуть медленнее, но не радикально

В MVC-проектах берут MockMvc, в WebFlux — WebTestClient. Если есть оба — WebTestClient покрывает оба.

TestContainers — реальная инфраструктура

H2 в @DataJpaTest экономит время, но поведение отличается от реального PostgreSQL: разные функции, разная семантика типов, разный поведение конкурентных транзакций. Когда дойдёт до integration test'а — баги вылетят на стейдже или проде.

Решение — TestContainers: реальный PG / Kafka / RabbitMQ / Mongo в Docker-контейнере на время теста.

@SpringBootTest
@Testcontainers
class OrderIntegrationTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");

    @Container
    @ServiceConnection
    static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.5.0"));

    @Test
    void writes_to_postgres_and_publishes_to_kafka() { ... }
}

@ServiceConnection (Spring Boot 3.1+) автоматически проставляет spring.datasource.url, spring.kafka.bootstrap-servers и другие свойства подключения из контейнера. Без него нужен @DynamicPropertySource.

Reuse контейнеров

Контейнеры запускаются медленно. Чтобы один контейнер переиспользовался между тестами:

# ~/.testcontainers.properties
testcontainers.reuse.enable=true
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
    .withReuse(true);

Контейнер не останавливается после теста — следующий запуск подключается к тому же. Спасает минуты при цикле разработки.

Тестирование транзакций

По умолчанию @SpringBootTest не оборачивает тест в транзакцию (раньше оборачивал, сейчас — нет). @Transactional на тесте — добавляет:

@SpringBootTest
@Transactional
class OrderServiceTest {
    @Autowired private OrderService service;

    @Test
    void creates_order() {
        service.create(...);
        // в конце теста — rollback, тестовая БД чистая
    }
}

С @DataJpaTest транзакция оборачивает по умолчанию.

Минус: если ваш Handler делает REQUIRES_NEW или публикует события через @TransactionalEventListener(AFTER_COMMIT) — тесты под @Transactional не дадут проверить, потому что главная транзакция никогда не коммитится. Для таких сценариев — отключайте @Transactional на тесте и чистите БД руками (или используйте TestContainers reuse + truncate).

Тестирование @Async

@Async создаёт другой поток. В тесте это редко удобно. Альтернатива:

@TestConfiguration
class AsyncTestConfig {
    @Bean
    @Primary
    public Executor taskExecutor() {
        return Runnable::run;  // выполняем синхронно
    }
}

@SpringBootTest
@Import(AsyncTestConfig.class)
class MyAsyncTest { ... }

Все @Async методы исполняются прямо в потоке вызывающего — тест видит результат сразу.

Параллельный запуск тестов

JUnit 5 поддерживает параллельный запуск. С TestContainers:

# junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent

Подводный камень: разные тест-классы могут стартовать разные контексты. Spring Test Context Cache хранит контексты в памяти — если у вас 5 разных @SpringBootTest с разными @MockitoBean-ами, в памяти будет 5 контекстов одновременно. Параллельный запуск ускоряет, но требует памяти.

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

  • @Transactional глубоко — особенности тестирования при разных propagation modes.
  • Spring MVC — что именно нужно тестировать в контроллерах.
  • Spring Data JPA — что покрывает @DataJpaTest.
  • TestContainers docs — официальная документация.