Spring Testing
Testing patterns for Spring Boot applications including unit tests, integration tests, sliced tests, and test containers
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 linesSpring 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 —
@SpringBootTestloads 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
@Transactionalin@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_expectedBehaviorso 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
@SpringBootTestoveruse — 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
@Transactionalside effects —@DataJpaTestwraps tests in a transaction that rolls back. This means JPA flush and constraint validation may not trigger unless you callentityManager.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
@SpringBootTestbecause 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
@BeforeAllmethod 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@Transactionalrollback orRefreshDatabase. -
Testing the framework — writing tests that verify Spring Data JPA generates correct SQL for
findById, or that@Validtriggers 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
Related Skills
Spring Actuator
Application monitoring, health checks, metrics, and observability with Spring Boot Actuator and Micrometer
Spring Batch
Batch processing with Spring Batch including jobs, steps, chunk processing, readers, writers, and job scheduling
Spring Boot Basics
Core Spring Boot concepts including auto-configuration, starters, dependency injection, and application lifecycle
Spring Cloud
Microservices architecture with Spring Cloud including service discovery, API gateway, circuit breakers, and distributed configuration
Spring Data Jpa
Data persistence with Spring Data JPA including repositories, entity mapping, queries, and transaction management
Spring Security
Authentication, authorization, and security configuration with Spring Security including JWT, OAuth2, and method-level security