Spring Boot Testing Strategy — Unit Tests, Slice Tests, and When to Use @SpringBootTest
by Eric Hanson, Backend Developer at Clean Systems Consulting
The testing pyramid for Spring Boot
The test pyramid applies directly to Spring Boot: many fast unit tests at the base, fewer slice tests in the middle, few integration tests at the top. Each level trades speed for realism.
- Unit tests — no Spring context, no database, no HTTP. Test a single class with its dependencies mocked. Run in milliseconds.
- Slice tests (
@WebMvcTest,@DataJpaTest,@DataRedisTest) — load only a specific layer of the application. Faster than full context loading, slower than unit tests. - Integration tests (
@SpringBootTest) — load the full application context. Slowest, most realistic.
The failure that's most common: using @SpringBootTest where a slice test or unit test would suffice. A test suite with 500 @SpringBootTest tests loading a full application context on each run takes minutes. The same coverage with appropriate test types takes seconds.
Unit tests — the default choice
A service, value object, utility class, or any class whose behavior doesn't depend on Spring context wiring belongs in a plain JUnit 5 test:
class OrderServiceTest {
private OrderRepository orderRepository;
private PaymentGateway paymentGateway;
private OrderService orderService;
@BeforeEach
void setUp() {
orderRepository = mock(OrderRepository.class);
paymentGateway = mock(PaymentGateway.class);
orderService = new OrderService(orderRepository, paymentGateway);
}
@Test
void processOrder_chargesPaymentAndSavesOrder() {
Order order = new Order("ord-1", userId, items, Money.of(100, "USD"));
when(orderRepository.findById("ord-1")).thenReturn(Optional.of(order));
when(paymentGateway.charge(any(), any())).thenReturn(PaymentResult.success("ch-123"));
orderService.processOrder("ord-1", "pm-visa");
verify(paymentGateway).charge(Money.of(100, "USD"), "pm-visa");
verify(orderRepository).save(argThat(o -> o.getStatus() == OrderStatus.PAID));
}
@Test
void processOrder_throwsException_whenOrderNotFound() {
when(orderRepository.findById("missing")).thenReturn(Optional.empty());
assertThrows(OrderNotFoundException.class,
() -> orderService.processOrder("missing", "pm-visa"));
}
}
No @ExtendWith(MockitoExtension.class) required for the above — mock() creates Mockito mocks directly. Add @ExtendWith(MockitoExtension.class) and @Mock/@InjectMocks annotations if you prefer the annotation style, but the constructor-injection approach above is explicit and requires no annotation magic.
What makes a good unit test target: a class that has its dependencies injected via constructor and doesn't reach into the Spring context for anything. Service objects, domain objects, value objects, utility classes. If a class uses @Autowired field injection or calls ApplicationContext.getBean(), it's harder to unit test without Spring — another reason to prefer constructor injection.
@WebMvcTest — testing the HTTP layer
@WebMvcTest loads only the web layer: controllers, @ControllerAdvice, security filters (configurable), and MockMvc. No service beans, no repositories, no database.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mockMvc;
@Autowired ObjectMapper objectMapper;
@MockBean OrderService orderService; // @MockBean creates a Mockito mock in the Spring context
@Test
@WithMockUser(username = "alice@example.com", roles = "USER")
void createOrder_returns201_withValidRequest() throws Exception {
Order createdOrder = new Order("ord-1", ...);
when(orderService.createOrder(any(), any(), any())).thenReturn(createdOrder);
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"items": [{"productId": "p-1", "quantity": 2}],
"paymentMethodId": "pm-visa"}
"""))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(jsonPath("$.id").value("ord-1"));
verify(orderService).createOrder(eq("alice@example.com"), any(), eq("pm-visa"));
}
@Test
void createOrder_returns401_withoutAuthentication() throws Exception {
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void createOrder_returns400_withInvalidRequest() throws Exception {
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"items": [], "paymentMethodId": ""}"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("validation_failed"));
}
}
@MockBean is the @WebMvcTest equivalent of mock() — it creates a mock and registers it in the Spring context. The controller gets the mock injected.
@WebMvcTest loads Spring Security configuration by default. Use @WithMockUser for authenticated requests, call endpoints without authentication to test 401 responses. This is the right place to test:
- HTTP status codes for success and failure paths
- Request body validation (
@Valid,@NotBlank) - Response body structure
- Security rules (which endpoints require authentication, which require specific roles)
@ControllerAdviceexception handling
What @WebMvcTest doesn't test: the service layer, the database, or any business logic. The service is mocked — the test verifies the controller's behavior given a specific service response.
@DataJpaTest — testing the persistence layer
@DataJpaTest loads only JPA-related components: entities, repositories, and the in-memory database (H2 by default). No controllers, no services, no security.
@DataJpaTest
class OrderRepositoryTest {
@Autowired TestEntityManager entityManager;
@Autowired OrderRepository orderRepository;
@Test
void findByStatus_returnsMatchingOrders() {
// Use TestEntityManager to set up test data directly
User user = entityManager.persistAndFlush(new User("alice@example.com"));
Order pending = entityManager.persistAndFlush(new Order(user, OrderStatus.PENDING));
Order shipped = entityManager.persistAndFlush(new Order(user, OrderStatus.SHIPPED));
List<Order> result = orderRepository.findByStatus(OrderStatus.PENDING);
assertThat(result).hasSize(1);
assertThat(result.get(0).getId()).isEqualTo(pending.getId());
}
@Test
void findByUserIdWithDetails_fetchesAssociations() {
User user = entityManager.persistAndFlush(new User("alice@example.com"));
Order order = new Order(user, OrderStatus.PENDING);
LineItem item = new LineItem(order, "product-1", 2);
order.getLineItems().add(item);
entityManager.persistAndFlush(order);
entityManager.clear(); // clear persistence context to force reload
List<Order> result = orderRepository.findByUserIdWithDetails(user.getId());
assertThat(result).hasSize(1);
assertThat(result.get(0).getLineItems()).hasSize(1); // verify JOIN FETCH worked
}
}
entityManager.clear() between setup and assertion is important — without it, Hibernate returns the cached first-level cache object, and the test doesn't verify that the query actually fetches from the database.
Testing against a real database. The default H2 in-memory database may not support all PostgreSQL features. For queries using PostgreSQL-specific syntax (window functions, tsvector, JSONB), test against a real PostgreSQL instance using Testcontainers:
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryPostgresTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
// Tests run against real PostgreSQL
}
@AutoConfigureTestDatabase(replace = NONE) prevents Spring Boot from replacing the configured datasource with H2. @DynamicPropertySource registers the Testcontainers PostgreSQL URL before the Spring context starts.
@SpringBootTest — when to reach for it
@SpringBootTest loads the full application context. It's slow (5–30 seconds for a typical Spring Boot app) and should be reserved for tests that genuinely need the full stack:
- Tests that verify the wiring of the application context (beans are properly connected)
- End-to-end tests that exercise multiple layers together
- Tests of infrastructure concerns that can't be isolated (transaction propagation, cache behavior across service calls)
- Smoke tests that verify the application starts correctly
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderApiIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired TestRestTemplate restTemplate;
@Autowired OrderRepository orderRepository;
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
// ...
}
@Test
void createAndRetrieveOrder_fullStackFlow() {
// No mocking — real service, real database, real HTTP
ResponseEntity<OrderResponse> createResponse = restTemplate
.withBasicAuth("alice@example.com", "password")
.postForEntity("/api/v1/orders", createOrderRequest(), OrderResponse.class);
assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
String orderId = createResponse.getBody().getId();
// Verify persistence
assertThat(orderRepository.findById(orderId)).isPresent();
// Verify retrieval
ResponseEntity<OrderResponse> getResponse = restTemplate
.withBasicAuth("alice@example.com", "password")
.getForEntity("/api/v1/orders/" + orderId, OrderResponse.class);
assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
WebEnvironment.RANDOM_PORT starts a real server on a random port — TestRestTemplate makes actual HTTP calls. This is the closest to production behavior, appropriate for integration testing the full request lifecycle.
Context caching. Spring Boot caches the application context between tests in the same JVM run. Tests that use identical context configurations share the cached context. This means 10 @SpringBootTest tests with the same configuration load the context once, not ten times. Tests that modify context configuration (different @MockBean, different properties) break caching and require a new context load.
Minimize @MockBean in @SpringBootTest tests — each unique @MockBean combination creates a separate cached context. Reserve @MockBean for external services that genuinely can't be reached in CI (payment gateways, SMS providers) and use Testcontainers for infrastructure.
The decision for each test
Ask these questions in order:
Does the test need Spring at all? If the class under test has no Spring dependencies — no @Autowired, no ApplicationContext — write a plain JUnit test. Most service and domain logic qualifies.
Does the test need only the web layer? Test the HTTP contract, request parsing, response serialization, and security rules with @WebMvcTest. The service is mocked.
Does the test need only the persistence layer? Test custom repository queries and entity mappings with @DataJpaTest. Use Testcontainers if the queries use database-specific features.
Does the test need the full stack? Use @SpringBootTest sparingly. These tests are valuable for verifying that the application's layers work together but expensive to run at scale.
A test suite with this distribution — 70% unit tests, 20% slice tests, 10% integration tests — runs in seconds, provides meaningful coverage, and fails fast when something breaks. A suite dominated by @SpringBootTest tests provides similar coverage but runs in minutes and gives no guidance on which layer contains the failure.