Тесты в 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-сериализация.@RestClientTest—RestClient/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-слоя. Отличия:
| MockMvc | WebTestClient | |
|---|---|---|
| Стек | MVC (Servlet) | MVC + WebFlux |
| Под капотом | DispatcherServlet через mock-объекты | Реальный Tomcat (с RANDOM_PORT) или WebTestClient.bindToController |
| Синтаксис | Java DSL с большим количеством andExpect | Fluent с лучшей читаемостью |
| Скорость | Чуть быстрее | Чуть медленнее, но не радикально |
В 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 — официальная документация.