The test pyramid says: many unit tests at the base, fewer integration tests in the middle, very few E2E tests at the top. The reasoning is cost — unit tests are fast, cheap, and pinpoint failures precisely. E2E tests are slow, brittle, and when they fail you often don't know where to look.
In practice on Spring Boot services, I aim for roughly: 70% unit, 25% integration, 5% E2E. But the exact ratio matters less than making sure each layer tests the right thing.
The inverted pyramid — teams that test almost everything through @SpringBootTest because "it's closer to production." These test suites take 20+ minutes to run, developers stop running them locally, and the feedback loop dies. I've seen this on multiple teams. The fix is always pushing logic down into pure service/domain classes that can be tested without a context.
These three annotations load different slices of the Spring context. Choosing the right one is about loading only what you need — smaller context = faster startup = faster feedback.
Loads the entire application context, exactly like production. Use it for true integration tests where you need multiple layers working together — controller + service + repository + database. It's the slowest option. If you use it for everything, your test suite will crawl.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderFlowIntegrationTest {
@Autowired TestRestTemplate restTemplate;
@Test
void placeOrder_fullFlow_returnsOrderId() {
// hits real controller → service → repository → DB
}
}
Loads only the web layer: controllers, filters, Jackson config, Spring Security. No service beans, no repositories. Everything else you mock. Use it to test request mapping, input validation, serialization, and error handling — without needing a database.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mockMvc;
@MockBean OrderService orderService; // service is mocked — not loaded
@Test
void createOrder_invalidBody_returns400() throws Exception {
mockMvc.perform(post("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors").isArray());
}
}
Loads only JPA repositories, entity classes, and an in-memory H2 database by default. No web layer, no services. Use it to test custom JPQL queries, derived query methods, and entity mappings. I usually override the database with Testcontainers to avoid H2/PostgreSQL dialect differences:
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // use real DB
@Import(TestcontainersConfig.class)
class OrderRepositoryTest {
@Autowired OrderRepository repo;
@Test
void findByCustomerIdAndStatus_returnsMatchingOrders() {
// test your custom query against real PostgreSQL
}
}
| What you're testing | Annotation |
|---|---|
| Full flow, multiple layers | @SpringBootTest |
| Controller logic, validation, serialization | @WebMvcTest |
| Repository queries, entity mapping | @DataJpaTest |
| Business logic only | Plain JUnit — no Spring at all |
@JsonTest for Jackson serialization, @DataMongoTest for MongoDB, @RestClientTest for RestTemplate/WebClient. All follow the same pattern — load only the relevant slice.@Mock is pure Mockito — it creates a mock object with no Spring involvement at all. Use it in plain unit tests where you're not loading a Spring context. @MockBean is Spring Boot's wrapper — it creates a Mockito mock and registers it as a Spring bean, replacing any real bean of that type in the application context. Use it in slice tests like @WebMvcTest where Spring needs to inject the dependency.
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock OrderRepository repo; // pure Mockito, no Spring
@Mock PaymentClient paymentClient;
@InjectMocks OrderService service; // Mockito injects mocks into constructor
@Test
void placeOrder_insufficientStock_throwsException() {
given(repo.findAvailableStock(any())).willReturn(0);
assertThatThrownBy(() -> service.placeOrder(order))
.isInstanceOf(InsufficientStockException.class);
}
}
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mockMvc;
@MockBean OrderService orderService; // replaces real bean in Spring context
@Test
void getOrder_notFound_returns404() throws Exception {
given(orderService.getOrder(99L)).willThrow(new ResourceNotFoundException());
mockMvc.perform(get("/orders/99"))
.andExpect(status().isNotFound());
}
}
@MockBean forces Spring to create a new application context for that test class. If you use different @MockBean combinations across test classes, Spring can't reuse its context cache — startup time multiplies. Group tests that share the same mock setup into the same class, or use a shared base test configuration.Mocking gives you false confidence when you mock the wrong thing. The classic trap: you mock a repository, your service test passes, but in production the query is wrong or the transaction boundary is in the wrong place. The mock didn't test any of that.
My rule: don't mock what you own at the boundary. Mock external HTTP clients, third-party SDKs, email services. Don't mock your own repositories — use Testcontainers for those. The gap between your mock and reality is where production bugs live.
mock() is a mock, @Spy is a spy. Use spies sparingly — they couple tests to implementation details.When you annotate a test method with @Transactional, Spring wraps the entire test in a transaction and automatically rolls it back after the test completes. This keeps the database clean between tests without you having to delete data manually. It's convenient — but it has a well-known trap that I always warn junior engineers about.
@DataJpaTest // @Transactional is applied by default in DataJpaTest
class OrderRepositoryTest {
@Autowired OrderRepository repo;
@Test
void save_thenFind_returnsOrder() {
repo.save(new Order("ORD-001"));
assertThat(repo.findByReference("ORD-001")).isPresent();
// after test: entire transaction is rolled back — DB is clean
}
}
Your production code runs in separate transactions: the controller commits, then the next request reads. In a transactional test, everything is in one transaction that never commits. This hides three categories of bugs:
For integration tests that test transactional behaviour — like testing that a REQUIRES_NEW inner transaction commits independently, or that a constraint is enforced — don't use @Transactional on the test. Clean up manually or use Testcontainers with a fresh schema per test run instead.
@SpringBootTest
class OrderIntegrationTest {
// No @Transactional — transactions behave exactly like production
@Autowired OrderRepository repo;
@AfterEach
void cleanup() {
repo.deleteAll(); // manual cleanup — more honest
}
}
@Commit?" → Overrides the default rollback — the test transaction actually commits. Useful for debugging what's actually in the DB, but don't leave it in CI — it leaves dirty data that breaks subsequent tests.Testcontainers is a library that spins up real Docker containers — PostgreSQL, Redis, Kafka, whatever — as part of your test lifecycle. The container starts before your tests, your tests run against it, then it shuts down. You test against the exact same database engine you run in production.
H2 is an in-memory database that's fast and zero-config, but it's not the database you run in production. The SQL dialects differ, the constraint behaviour differs, the index behaviour differs. I've seen bugs that only appeared in production because H2 silently accepted SQL that PostgreSQL rejects.
@SpringBootTest
@Testcontainers
class OrderRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb");
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", postgres::getJdbcUrl);
r.add("spring.datasource.username", postgres::getUsername);
r.add("spring.datasource.password", postgres::getPassword);
}
@Test
void customQuery_worksOnRealPostgres() { ... }
}
Starting a container per test class is slow. The fix is declaring the container static (shared within a class) and using Spring's context caching so the same context — and same container — is reused across test classes that share the same configuration.
For even better performance, use the .withReuse(true) flag — Testcontainers keeps the container alive between test runs (across JVM restarts), so the first run is slow, subsequent runs are fast.
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15")
.withReuse(true); // container survives JVM restart
Testcontainers works for any Docker image — Kafka, Redis, LocalStack (AWS), Wiremock. I use it for Kafka consumer tests especially: spin up a real broker, publish a message, assert the consumer processed it correctly. There's no reliable way to test that with mocks.
Async and event-driven code is where most testing strategies break down, because the code you're trying to assert runs on a different thread, at an unknown time after the test body. If you assert immediately after triggering the async work, the work often hasn't run yet — your test passes or fails non-deterministically.
The simplest approach: override the task executor in your test configuration to use a synchronous executor. @Async still works but runs on the calling thread, so there's no timing issue.
@TestConfiguration
class SyncAsyncConfig {
@Bean
@Primary
TaskExecutor taskExecutor() {
return new SyncTaskExecutor(); // runs on calling thread — no async
}
}
Good for unit/service-level tests. Not ideal if the async boundary itself is what you're testing.
When you want to test that the async work actually happens asynchronously, use Awaitility to poll until the condition is met or a timeout is reached:
import static org.awaitility.Awaitility.await;
@Test
void publishEvent_asyncListenerProcesses_withinTimeout() {
eventPublisher.publishEvent(new OrderPlacedEvent(orderId));
await().atMost(2, SECONDS)
.untilAsserted(() ->
verify(notificationService, times(1)).sendConfirmation(orderId)
);
}
Awaitility retries the assertion block until it passes or the timeout expires. It's far better than Thread.sleep() — no arbitrary waits, no flaky tests from timing differences.
For @EventListener and @TransactionalEventListener, test that the event was published using a captured list or a @RecordApplicationEvents annotation (Spring Boot 2.4+):
@SpringBootTest
@RecordApplicationEvents
class OrderServiceEventTest {
@Autowired ApplicationEvents events;
@Autowired OrderService orderService;
@Test
@Transactional
void placeOrder_publishesOrderPlacedEvent() {
orderService.placeOrder(newOrder());
assertThat(events.stream(OrderPlacedEvent.class))
.hasSize(1)
.first()
.extracting(OrderPlacedEvent::getOrderId)
.isEqualTo("ORD-001");
}
}
KafkaTemplate to publish a test message, then use Awaitility to wait until your consumer has processed it and assert on the side effect (database record written, downstream service called, etc.).TDD in its pure form — write a failing test, write minimum code to pass, refactor — works very well for business logic with clear rules: validation, calculations, state machines. It forces you to think about the interface before the implementation, and the test suite you end up with is genuinely useful because it was written to drive design, not just for coverage.
But I'm pragmatic about it. I don't TDD everything, and I think teams that insist on strict TDD for all code usually end up with slow delivery and test suites that test implementation details rather than behaviour.
I use TDD strictly for the domain/service layer where business rules live. For everything else — controllers, repositories, config — I write tests after the implementation is stable but before the PR is merged. The rule I follow: no PR without tests, but tests don't always have to come first.
The most important TDD habit that doesn't require strict red-green-refactor: write the test before you call it "done." Don't write code, ship it, and add tests later. That path leads to code that's hard to test, and tests that are retrofitted to pass rather than to verify behaviour.