Spring Boot Testing: What Should Actually Be Tested and How

Spring Boot’s testing support is extensive and well-designed. It’s also easy to misuse in ways that produce slow, fragile test suites with poor diagnostic value.

The core question is not “how do I test with Spring Boot?” but “what needs to be tested, and at what level?”

The Testing Pyramid Applied to Spring Boot

The testing pyramid principle: favor many fast, isolated unit tests over fewer slow integration tests. Each level of the pyramid is slower and more expensive to run but tests more of the system.

In a Spring Boot application:

Unit tests (base): test a single class in isolation, no Spring context. Run in milliseconds. Cover business logic, domain models, utility classes.

Slice tests (middle): test a specific layer using a partial Spring context. @WebMvcTest for controllers, @DataJpaTest for repositories. Faster than full integration tests, test more than unit tests.

Integration tests (top): full Spring context, real or containerized infrastructure. Test the entire application or significant slices of it. Slow, but catch integration problems.

The mistake: writing @SpringBootTest tests for everything, including logic that could be tested with a plain unit test. This makes the suite slow and the slow tests provide no additional value over the fast ones.

Unit Tests: Test Domain Logic Without Spring

Business logic should be testable without Spring. If your domain objects and services require a Spring context to run, your architecture is telling you something.

// Domain logic — no Spring annotations, no Spring context needed
public class Order {
    private final List<OrderLine> lines;
    private OrderStatus status;

    public void cancel(CancellationReason reason) {
        if (status == OrderStatus.DELIVERED) {
            throw new OrderCannotBeCancelledException("Cannot cancel delivered order");
        }
        this.status = OrderStatus.CANCELLED;
    }
}

// Plain unit test — no Spring, runs in < 1ms
class OrderTest {

    @Test
    void cancellingDeliveredOrder_throwsException() {
        Order order = OrderBuilder.aDeliveredOrder().build();

        assertThatThrownBy(() -> order.cancel(CancellationReason.CUSTOMER_REQUEST))
            .isInstanceOf(OrderCannotBeCancelledException.class)
            .hasMessage("Cannot cancel delivered order");
    }
}

This test is fast, reliable, and precisely targets the business rule. It doesn’t need a database, a message broker, or a Spring context.

Slice Tests: Test Layers in Isolation

@WebMvcTest starts only the web layer — controllers, filters, exception handlers. No service beans, no repositories. Use @MockBean to mock the service layer.

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockBean
    OrderService orderService;

    @Test
    void getOrder_returnsOrder_whenFound() throws Exception {
        var orderId = OrderId.of("ORD-001");
        when(orderService.findOrder(orderId))
            .thenReturn(Optional.of(new OrderSummary(orderId, Money.of(100), ACTIVE)));

        mockMvc.perform(get("/orders/{id}", "ORD-001")
            .accept(APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value("ORD-001"))
            .andExpect(jsonPath("$.status").value("ACTIVE"));
    }

    @Test
    void getOrder_returns404_whenNotFound() throws Exception {
        when(orderService.findOrder(any())).thenReturn(Optional.empty());

        mockMvc.perform(get("/orders/{id}", "NONEXISTENT"))
            .andExpect(status().isNotFound());
    }
}

This tests: request mapping, input deserialization, response serialization, HTTP status codes, error handling — without touching the service layer.

@DataJpaTest loads only JPA-related components. Uses an in-memory database (H2) by default.

@DataJpaTest
class OrderRepositoryTest {

    @Autowired
    OrderJpaRepository repository;

    @Autowired
    TestEntityManager entityManager;

    @Test
    void findByCustomer_returnsOrdersForCustomer() {
        var customerId = "CUST-001";
        entityManager.persist(OrderEntity.builder()
            .customerId(customerId)
            .status(ACTIVE)
            .build());
        entityManager.flush();

        List<OrderEntity> orders = repository.findByCustomerId(customerId);

        assertThat(orders).hasSize(1);
        assertThat(orders.get(0).customerId()).isEqualTo(customerId);
    }
}

Caution with H2: H2 is not your production database. Some PostgreSQL-specific behavior (JSON operators, window functions, specific index types) doesn’t work with H2. For those, use Testcontainers.

Integration Tests With Testcontainers

Testcontainers starts real Docker containers for your dependencies — PostgreSQL, Kafka, Redis, whatever your application uses.

@SpringBootTest
@Testcontainers
class OrderServiceIntegrationTest {

    @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);
    }

    @Autowired
    OrderService orderService;

    @Test
    @Transactional
    void placingOrder_persists_andPublishesEvent() {
        var command = new PlaceOrderCommand(
            CustomerId.of("CUST-1"),
            List.of(new OrderLine(ProductId.of("PROD-1"), 2))
        );

        OrderId orderId = orderService.placeOrder(command);

        assertThat(orderId).isNotNull();
        Optional<Order> found = orderService.findOrder(orderId);
        assertThat(found).isPresent();
        assertThat(found.get().status()).isEqualTo(PENDING);
    }
}

Use @Testcontainers + @Container pattern with static containers to reuse the container across all tests in the class — avoiding the overhead of starting a new container per test.

Spring Boot 3.1+ has built-in Testcontainers support with @ServiceConnection:

@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
// @ServiceConnection automatically configures the datasource — no @DynamicPropertySource needed

@SpringBootTest: Use Sparingly

@SpringBootTest starts the full application context. It’s the most realistic test type and the most expensive.

Use it for:

  • Testing the full startup (does the application boot without errors?)
  • Testing complete request-response cycles that span multiple layers
  • Testing configuration and wiring

Don’t use it for:

  • Testing individual domain logic
  • Testing controller behavior (use @WebMvcTest)
  • Testing repository queries (use @DataJpaTest or Testcontainers)

A test suite where every test is @SpringBootTest is slow, brittle, and provides poor diagnostic value — when something breaks, you have to dig through the entire application to find what failed.

What Not to Test

Framework code: don’t test that Spring’s dependency injection works. Don’t test that JPA generates the SQL you expect (unless you’re verifying a specific query optimization or behavior).

Generated code: if you’re using Lombok, MapStruct, or another code generator, don’t write tests that essentially test the generator’s output.

Simple getters and setters: if a method has no logic, it has no test case.

Positive tests only: test the error paths. What happens when the database is unavailable? When the input is invalid? When a required external service returns an error? These paths are often where the real bugs are.

Contract Testing

When services call each other, the contract between them — the request and response format — is an implicit dependency. When one service changes its API, dependent services break.

Spring Cloud Contract generates both producer-side tests and consumer-side stubs from contract definitions. The producer verifies it fulfills the contract. The consumer tests against stubs that match the contract. Both can be done without the other service running.

// Contract definition
Contract.make {
    request {
        method 'GET'
        url '/orders/ORD-001'
    }
    response {
        status 200
        headers {
            contentType applicationJson()
        }
        body([
            id: 'ORD-001',
            status: 'ACTIVE'
        ])
    }
}

Contract testing is most valuable in microservices architectures where many teams are independently deploying services.

Test Execution Speed

A test suite that takes 20 minutes to run is one where engineers stop running tests locally. Fix the root causes:

  • Replace @SpringBootTest tests with slice tests where possible
  • Share the Spring context across tests in the same test class (Spring caches contexts by default)
  • Use @Testcontainers with static containers (one container per JVM, not per test)
  • Identify the 10 slowest tests and understand why they’re slow

Most slow test suites are slow because they start too many Spring contexts. Profile which context configurations are created and how many tests share each.

The Bottom Line

The right test strategy for Spring Boot: lots of fast unit tests for domain logic, targeted slice tests for web and persistence layers, selective integration tests for cross-layer behavior, and full integration tests for smoke testing the complete application.

The signal that your strategy is wrong: tests that take minutes and fail for reasons unrelated to the code being tested.