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)
  • @ControllerAdvice exception 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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

When Automation Isn’t Enough: Why Humans Still Lead Code Quality

Automated tools can catch syntax errors and enforce style, but they can’t think. Humans remain essential to maintaining true code quality and long-term project health.

Read more

ActiveRecord Query Patterns That Actually Scale

ActiveRecord makes simple queries trivial and complex queries dangerous. These are the patterns that remain correct under load — and the common ones that quietly fall apart at scale.

Read more

How I Handle Authentication in Rails API Mode Without Overcomplicating It

JWT, sessions, Devise, OAuth — Rails API authentication has more options than decisions that need making. Here is a clear-eyed breakdown of what to use when and how to implement it without pulling in more than you need.

Read more

Blocks, Procs, and Lambdas — A Practical Guide Without the Confusion

Ruby gives you three ways to package callable code, and most developers cargo-cult the choice. Here's a precise breakdown of the differences that actually affect behavior in production code.

Read more