Tests in a Spring project usually live on three levels, and it is important not to confuse their purpose:
- Unit tests — no Spring context at all. Just
new MyService(mock(...)). The fastest, isolated. - Slice tests — a partial context for a specific layer: only MVC, only JPA, only JSON serialization.
- Integration tests — the full context via
@SpringBootTest+ TestContainers.
Most test problems come from trying to do with one type what is better done with another: writing everything through @SpringBootTest (slow, flaky), or not using slices at all.
Unit tests — no Spring
The fastest level. Spring is not needed if the class is a plain POJO or a Spring @Service whose mocks can be passed into the constructor:
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));
}
}
The test runs in milliseconds. Any class that does not depend on infrastructure is tested this way.
Slice tests
When you need a partial context — for example, to test a controller's serialization but without a database.
@WebMvcTest — controllers and the MVC layer
@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": "Ivan", "lines": [...] }
"""))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"));
}
}
@WebMvcTest brings up only the MVC layer (controllers, validators, exception handlers, Jackson). JPA, security, and custom beans are not loaded. Fast.
@DataJpaTest — the repository layer
@DataJpaTest
class OrderRepositoryTest {
@Autowired private OrderRepository repo;
@Autowired private TestEntityManager em;
@Test
void finds_by_customer_id() {
var customer = em.persist(new Customer("Ivan"));
em.persist(new Order(customer, BigDecimal.valueOf(100)));
em.flush();
var found = repo.findByCustomerId(customer.getId());
assertThat(found).hasSize(1);
}
}
@DataJpaTest brings up only JPA: EntityManager, repositories, the datasource (by default an embedded H2). Each test runs in a transaction that is rolled back at the end — isolation for free.
Swapping H2 for a real PostgreSQL via TestContainers — see below.
Other slices
@JsonTest— Jackson serialization.@RestClientTest—RestClient/WebClient+MockRestServiceServer.@DataMongoTest— Spring Data Mongo.@JdbcTest— bare JDBC + datasource.
The full list is in spring-boot-test-autoconfigure.
@SpringBootTest — the full context
When you need the entire application context — security, custom beans, application events, the real wiring of all layers together:
@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, without starting Tomcat.RANDOM_PORT— starts Tomcat on a random port. Used withTestRestTemplateorWebTestClient.DEFINED_PORT— on a specific port (for e2e with an external client).
MockMvc vs WebTestClient
Both are for testing the HTTP layer. The differences:
| MockMvc | WebTestClient | |
|---|---|---|
| Stack | MVC (Servlet) | MVC + WebFlux |
| Under the hood | DispatcherServlet through mock objects | Real Tomcat (with RANDOM_PORT) or WebTestClient.bindToController |
| Syntax | Java DSL with a lot of andExpect | Fluent, with better readability |
| Speed | Slightly faster | Slightly slower, but not dramatically |
In MVC projects you take MockMvc, in WebFlux — WebTestClient. If you have both — WebTestClient covers both.
TestContainers — real infrastructure
H2 in @DataJpaTest saves time, but its behavior differs from a real PostgreSQL: different functions, different type semantics, different behavior of concurrent transactions. When it comes to an integration test, the bugs will surface on staging or in production.
The solution is TestContainers: a real PG / Kafka / RabbitMQ / Mongo in a Docker container for the duration of the test.
@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+) automatically sets spring.datasource.url, spring.kafka.bootstrap-servers and other connection properties from the container. Without it you need @DynamicPropertySource.
Reusing containers
Containers start slowly. To reuse a single container across tests:
# ~/.testcontainers.properties
testcontainers.reuse.enable=true
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withReuse(true);
The container is not stopped after the test — the next run connects to the same one. It saves minutes during the development cycle.
Testing transactions
By default @SpringBootTest does not wrap the test in a transaction (it used to, now it does not). @Transactional on the test adds that:
@SpringBootTest
@Transactional
class OrderServiceTest {
@Autowired private OrderService service;
@Test
void creates_order() {
service.create(...);
// at the end of the test — rollback, the test DB is clean
}
}
With @DataJpaTest the transaction wraps by default.
The downside: if your Handler does REQUIRES_NEW or publishes events via @TransactionalEventListener(AFTER_COMMIT), tests under @Transactional will not let you verify it, because the main transaction is never committed. For such scenarios, disable @Transactional on the test and clean the DB by hand (or use TestContainers reuse + truncate).
Testing @Async
@Async creates a different thread. In a test this is rarely convenient. An alternative:
@TestConfiguration
class AsyncTestConfig {
@Bean
@Primary
public Executor taskExecutor() {
return Runnable::run; // execute synchronously
}
}
@SpringBootTest
@Import(AsyncTestConfig.class)
class MyAsyncTest { ... }
All @Async methods run right in the caller's thread — the test sees the result immediately.
Running tests in parallel
JUnit 5 supports parallel execution. With TestContainers:
# junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
The pitfall: different test classes may start different contexts. The Spring Test Context Cache keeps contexts in memory — if you have 5 different @SpringBootTest classes with different @MockitoBeans, there will be 5 contexts in memory at once. Parallel execution speeds things up but requires memory.
What to read next
@Transactionalin depth — the specifics of testing with different propagation modes.- Spring MVC — what exactly to test in controllers.
- Spring Data JPA — what
@DataJpaTestcovers. - TestContainers docs — the official documentation.