Skip to main content
Technology & EngineeringJava Spring361 lines

Spring Testing

Testing patterns for Spring Boot applications including unit tests, integration tests, sliced tests, and test containers

Quick Summary18 lines
You are an expert in testing Spring Boot applications using JUnit 5, Mockito, Spring Test, and Testcontainers. You write tests that verify behavior and catch regressions without becoming brittle mirrors of the implementation, and you structure test suites to remain fast as the codebase grows.

## Key Points

- **Unit tests** — test individual classes in isolation with mocked dependencies. Fast, numerous.
- **Slice tests** — `@WebMvcTest`, `@DataJpaTest`, etc. Load only the relevant Spring context slice.
- **Integration tests** — `@SpringBootTest` loads the full application context. Slower, fewer.
- **Follow the test pyramid** — write many fast unit tests, fewer slice tests, and the fewest integration tests. Each layer catches different categories of bugs.
- **Use Testcontainers over H2** — H2 behaves differently from PostgreSQL/MySQL in subtle ways. Testcontainers gives you a real database in seconds.
- **Test behavior, not implementation** — assert on outcomes (HTTP status, response body, database state), not on which internal methods were called.
- **Use `@Transactional` in `@DataJpaTest`** — it rolls back after each test, keeping the database clean. Be aware this can hide flush-time validation errors.
- **Name tests clearly** — use the pattern `methodName_givenCondition_expectedBehavior` so test failures are self-documenting.
- **Keep test data local** — create test data in each test method or use `@BeforeEach`. Shared mutable state between tests causes intermittent failures.
- **Flaky tests from shared database state** — tests that depend on data created by other tests fail when run in isolation or in a different order. Each test must set up its own data.
- **Mocking too much** — if a unit test mocks every dependency and just verifies mock interactions, it tests nothing meaningful. Ensure tests verify actual behavior.
- **Not testing error paths** — happy-path tests pass but the application crashes in production on invalid input, timeouts, or downstream failures. Test edge cases and failure modes.
skilldb get java-spring-skills/Spring TestingFull skill: 361 lines
Paste into your CLAUDE.md or agent config

Spring Testing — Java/Spring Boot

You are an expert in testing Spring Boot applications using JUnit 5, Mockito, Spring Test, and Testcontainers. You write tests that verify behavior and catch regressions without becoming brittle mirrors of the implementation, and you structure test suites to remain fast as the codebase grows.

Core Philosophy

Tests exist to give confidence that the application works correctly and will continue to work correctly as it evolves. This means tests should verify observable behavior -- HTTP responses, database state, published events -- not internal method calls or implementation details. A test that breaks every time the implementation is refactored without changing behavior is a maintenance burden, not a safety net. The best tests describe what the system does, not how it does it, and they serve as living documentation for the application's contract.

The test pyramid is a resource allocation strategy, not a dogma. Unit tests are fast and cheap to write, so they should cover the majority of logic: validation rules, business calculations, state machines. Slice tests like @WebMvcTest and @DataJpaTest verify the integration between your code and the framework without loading the full context. Full @SpringBootTest integration tests verify the complete request lifecycle but are expensive to run. The pyramid shape emerges naturally when each layer tests what it is best positioned to test, rather than when a team enforces arbitrary ratios.

Realistic test infrastructure pays for itself. Testing against H2 when production runs PostgreSQL creates a class of bugs that only appears in production: different SQL dialects, different locking behavior, different type handling. Testcontainers eliminates this gap by running real databases in Docker containers with minimal setup. The few seconds of container startup time are vastly cheaper than a production bug caused by a database compatibility gap that an H2 test could never catch.

Overview

Spring Boot provides comprehensive testing support through spring-boot-starter-test, which bundles JUnit 5, Mockito, AssertJ, Hamcrest, and Spring Test. The testing strategy spans unit tests (fast, isolated), slice tests (load only relevant Spring context), and full integration tests (complete application context). Testcontainers enables realistic integration tests against real databases and message brokers in Docker containers.

Core Concepts

Test Pyramid

  • Unit tests — test individual classes in isolation with mocked dependencies. Fast, numerous.
  • Slice tests@WebMvcTest, @DataJpaTest, etc. Load only the relevant Spring context slice.
  • Integration tests@SpringBootTest loads the full application context. Slower, fewer.

Unit Tests with Mockito

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;

    @Mock
    private PaymentGateway paymentGateway;

    @Mock
    private InventoryService inventoryService;

    @InjectMocks
    private OrderService orderService;

    @Test
    void placeOrder_withValidRequest_createsOrderAndChargesPayment() {
        // Arrange
        OrderRequest request = new OrderRequest("product-1", 2, new BigDecimal("49.99"));
        Order expectedOrder = Order.from(request);
        expectedOrder.setId(1L);

        when(inventoryService.checkAvailability("product-1", 2)).thenReturn(true);
        when(orderRepository.save(any(Order.class))).thenReturn(expectedOrder);
        when(paymentGateway.charge(any(BigDecimal.class))).thenReturn(new PaymentResult(true, "txn-123"));

        // Act
        Order result = orderService.placeOrder(request);

        // Assert
        assertThat(result.getId()).isEqualTo(1L);
        verify(inventoryService).checkAvailability("product-1", 2);
        verify(paymentGateway).charge(new BigDecimal("99.98"));
        verify(orderRepository).save(any(Order.class));
    }

    @Test
    void placeOrder_withInsufficientStock_throwsException() {
        OrderRequest request = new OrderRequest("product-1", 100, new BigDecimal("49.99"));
        when(inventoryService.checkAvailability("product-1", 100)).thenReturn(false);

        assertThatThrownBy(() -> orderService.placeOrder(request))
                .isInstanceOf(InsufficientStockException.class)
                .hasMessageContaining("product-1");

        verify(paymentGateway, never()).charge(any());
        verify(orderRepository, never()).save(any());
    }
}

Web Layer Tests with @WebMvcTest

@WebMvcTest(ProductController.class)
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private ProductService productService;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    void getProduct_whenExists_returns200() throws Exception {
        ProductDTO product = new ProductDTO(1L, "Widget", new BigDecimal("29.99"));
        when(productService.findById(1L)).thenReturn(Optional.of(product));

        mockMvc.perform(get("/api/v1/products/1"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.name").value("Widget"))
                .andExpect(jsonPath("$.price").value(29.99));
    }

    @Test
    void getProduct_whenNotFound_returns404() throws Exception {
        when(productService.findById(999L)).thenReturn(Optional.empty());

        mockMvc.perform(get("/api/v1/products/999"))
                .andExpect(status().isNotFound());
    }

    @Test
    void createProduct_withValidRequest_returns201() throws Exception {
        CreateProductRequest request = new CreateProductRequest("Widget", new BigDecimal("29.99"));
        ProductDTO created = new ProductDTO(1L, "Widget", new BigDecimal("29.99"));
        when(productService.create(any())).thenReturn(created);

        mockMvc.perform(post("/api/v1/products")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(header().string("Location", "/api/v1/products/1"));
    }

    @Test
    void createProduct_withBlankName_returns400() throws Exception {
        CreateProductRequest request = new CreateProductRequest("", new BigDecimal("29.99"));

        mockMvc.perform(post("/api/v1/products")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
    }
}

Repository Tests with @DataJpaTest

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("testdb");

    @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
    private OrderRepository orderRepository;

    @Autowired
    private TestEntityManager entityManager;

    @Test
    void findByCustomerEmailAndStatus_returnsMatchingOrders() {
        Order order1 = new Order("alice@example.com", OrderStatus.COMPLETED, new BigDecimal("100.00"));
        Order order2 = new Order("alice@example.com", OrderStatus.PENDING, new BigDecimal("50.00"));
        Order order3 = new Order("bob@example.com", OrderStatus.COMPLETED, new BigDecimal("75.00"));

        entityManager.persistAndFlush(order1);
        entityManager.persistAndFlush(order2);
        entityManager.persistAndFlush(order3);

        List<Order> results = orderRepository.findByCustomerEmailAndStatus(
                "alice@example.com", OrderStatus.COMPLETED);

        assertThat(results).hasSize(1);
        assertThat(results.get(0).getTotal()).isEqualByComparingTo(new BigDecimal("100.00"));
    }
}

Implementation Patterns

Full Integration Tests with @SpringBootTest

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @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
    private TestRestTemplate restTemplate;

    @Test
    void createAndRetrieveOrder_fullFlow() {
        // Create
        CreateOrderRequest request = new CreateOrderRequest("alice@example.com", "product-1", 2);
        ResponseEntity<OrderDTO> createResponse = restTemplate.postForEntity(
                "/api/v1/orders", request, OrderDTO.class);

        assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(createResponse.getBody()).isNotNull();
        Long orderId = createResponse.getBody().getId();

        // Retrieve
        ResponseEntity<OrderDTO> getResponse = restTemplate.getForEntity(
                "/api/v1/orders/" + orderId, OrderDTO.class);

        assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(getResponse.getBody().getCustomerEmail()).isEqualTo("alice@example.com");
    }
}

Testing with Security

@WebMvcTest(AdminController.class)
@Import(SecurityConfig.class)
class AdminControllerSecurityTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private AdminService adminService;

    @Test
    void adminEndpoint_withoutAuth_returns401() throws Exception {
        mockMvc.perform(get("/api/admin/users"))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(roles = "ADMIN")
    void adminEndpoint_withAdminRole_returns200() throws Exception {
        when(adminService.listUsers()).thenReturn(List.of());

        mockMvc.perform(get("/api/admin/users"))
                .andExpect(status().isOk());
    }

    @Test
    @WithMockUser(roles = "USER")
    void adminEndpoint_withUserRole_returns403() throws Exception {
        mockMvc.perform(get("/api/admin/users"))
                .andExpect(status().isForbidden());
    }
}

WireMock for External Service Stubs

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@WireMockTest(httpPort = 8089)
class PaymentIntegrationTest {

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("payment.service.url", () -> "http://localhost:8089");
    }

    @Test
    void processPayment_whenGatewayApproves_completesOrder() {
        stubFor(post(urlPathEqualTo("/api/payments/charge"))
                .willReturn(aResponse()
                        .withStatus(200)
                        .withHeader("Content-Type", "application/json")
                        .withBody("""
                                {"transactionId": "txn-123", "status": "APPROVED"}
                                """)));

        // ... test logic that triggers the payment call
    }

    @Test
    void processPayment_whenGatewayTimesOut_returnsError() {
        stubFor(post(urlPathEqualTo("/api/payments/charge"))
                .willReturn(aResponse()
                        .withFixedDelay(6000) // Exceeds timeout
                        .withStatus(200)));

        // ... assert the timeout is handled gracefully
    }
}

Shared Test Configuration

// Base class for integration tests that need a database
@Testcontainers
public abstract class AbstractIntegrationTest {

    @Container
    protected static final PostgreSQLContainer<?> POSTGRES =
            new PostgreSQLContainer<>("postgres:16-alpine")
                    .withDatabaseName("testdb")
                    .withReuse(true); // Reuse container across test classes

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

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderIntegrationTest extends AbstractIntegrationTest {
    // Inherits the container setup
}

Best Practices

  • Follow the test pyramid — write many fast unit tests, fewer slice tests, and the fewest integration tests. Each layer catches different categories of bugs.
  • Use Testcontainers over H2 — H2 behaves differently from PostgreSQL/MySQL in subtle ways. Testcontainers gives you a real database in seconds.
  • Test behavior, not implementation — assert on outcomes (HTTP status, response body, database state), not on which internal methods were called.
  • Use @Transactional in @DataJpaTest — it rolls back after each test, keeping the database clean. Be aware this can hide flush-time validation errors.
  • Name tests clearly — use the pattern methodName_givenCondition_expectedBehavior so test failures are self-documenting.
  • Keep test data local — create test data in each test method or use @BeforeEach. Shared mutable state between tests causes intermittent failures.

Common Pitfalls

  • Slow test suites from @SpringBootTest overuse — loading the full context for every test class is expensive. Use @WebMvcTest, @DataJpaTest, or plain unit tests when the full context is not needed.
  • Flaky tests from shared database state — tests that depend on data created by other tests fail when run in isolation or in a different order. Each test must set up its own data.
  • Mocking too much — if a unit test mocks every dependency and just verifies mock interactions, it tests nothing meaningful. Ensure tests verify actual behavior.
  • Ignoring @Transactional side effects@DataJpaTest wraps tests in a transaction that rolls back. This means JPA flush and constraint validation may not trigger unless you call entityManager.flush() explicitly.
  • Not testing error paths — happy-path tests pass but the application crashes in production on invalid input, timeouts, or downstream failures. Test edge cases and failure modes.

Anti-Patterns

  • The Spring context per test class — every test class loads @SpringBootTest because it is the most convenient annotation, even for tests that only need a service and a mock. This makes the test suite slow and discourages running tests frequently. Use unit tests with @ExtendWith(MockitoExtension.class) for isolated logic and slice tests for framework integration.

  • Mock verification as the only assertion — tests that call verify(mock).someMethod() without asserting on the actual result or state change. These tests pass even when the business logic is wrong, as long as the method was called. Verify interactions only when the interaction itself is the behavior being tested (e.g., "did we send the email?").

  • Shared mutable test data — a @BeforeAll method that creates database records used by every test in the class. When one test modifies the shared data, other tests fail intermittently depending on execution order. Each test should create exactly the data it needs and clean up via @Transactional rollback or RefreshDatabase.

  • Testing the framework — writing tests that verify Spring Data JPA generates correct SQL for findById, or that @Valid triggers validation. The framework is already tested. Focus testing effort on the application's custom logic, mappings, and business rules.

  • Ignoring test performance — letting the test suite grow to 20+ minutes without investigation. Slow tests stop being run locally, which means bugs are caught later. Profile the suite, identify the slowest tests, and refactor them to use lighter-weight test infrastructure where possible.

Install this skill directly: skilldb add java-spring-skills

Get CLI access →