SP
Testing
Spring testing v1.0.0
Spring Testing
Overview
This skill covers comprehensive testing strategies for Spring Boot applications including unit testing with mocks, slice tests, integration tests with Testcontainers, and contract testing. Effective testing ensures application reliability and enables confident refactoring.
Key Concepts
Testing Pyramid
┌─────────────────────────────────────────────────────────────┐
│ Testing Pyramid │
├─────────────────────────────────────────────────────────────┤
│ │
│ ╱╲ │
│ ╱ ╲ E2E Tests │
│ ╱ ╲ (Fewest, Slowest) │
│ ╱──────╲ │
│ ╱ ╲ │
│ ╱ Integration╲ Integration Tests │
│ ╱ Tests ╲ (Moderate) │
│ ╱────────────────╲ │
│ ╱ ╲ │
│ ╱ Unit Tests ╲ Unit Tests │
│ ╱ ╲ (Most, Fastest) │
│ ╱────────────────────────╲ │
│ │
│ Characteristics: │
│ • Unit: Fast, isolated, many │
│ • Integration: Moderate speed, test boundaries │
│ • E2E: Slow, few, test complete flows │
│ │
└─────────────────────────────────────────────────────────────┘
Spring Test Annotations
| Annotation | Purpose | Speed |
|---|---|---|
@SpringBootTest | Full context | Slow |
@WebMvcTest | Web layer only | Fast |
@DataJpaTest | JPA layer only | Medium |
@WebFluxTest | WebFlux layer | Fast |
@JsonTest | JSON serialization | Fast |
Best Practices
1. Test Behavior, Not Implementation
Focus on what the code does, not how it does it.
2. Use Slice Tests
Load only necessary context for faster tests.
3. Prefer Real Dependencies in Integration Tests
Use Testcontainers for realistic database and messaging tests.
4. Mock External Services
Isolate tests from external dependencies.
5. Keep Tests Independent
Each test should run independently.
Code Examples
Example 1: Unit Testing with Mockito
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private PaymentService paymentService;
@Mock
private EventPublisher eventPublisher;
@InjectMocks
private OrderService orderService;
@Captor
private ArgumentCaptor<OrderCreatedEvent> eventCaptor;
@Test
void shouldCreateOrder() {
// Given
CreateOrderCommand command = CreateOrderCommand.builder()
.customerId("cust-123")
.items(List.of(new OrderItem("prod-1", 2)))
.build();
when(orderRepository.save(any(Order.class)))
.thenAnswer(inv -> {
Order order = inv.getArgument(0);
return order.withId(OrderId.generate());
});
// When
Order result = orderService.createOrder(command);
// Then
assertThat(result).isNotNull();
assertThat(result.getCustomerId()).isEqualTo("cust-123");
assertThat(result.getItems()).hasSize(1);
verify(orderRepository).save(any(Order.class));
verify(eventPublisher).publish(eventCaptor.capture());
OrderCreatedEvent event = eventCaptor.getValue();
assertThat(event.getOrderId()).isEqualTo(result.getId());
}
@Test
void shouldThrowWhenPaymentFails() {
// Given
CreateOrderCommand command = validOrderCommand();
when(orderRepository.save(any())).thenReturn(validOrder());
when(paymentService.authorize(any()))
.thenThrow(new PaymentFailedException("Card declined"));
// When/Then
assertThatThrownBy(() -> orderService.createOrder(command))
.isInstanceOf(PaymentFailedException.class)
.hasMessage("Card declined");
// Verify no event published on failure
verify(eventPublisher, never()).publish(any());
}
@Nested
class OrderCancellation {
@Test
void shouldCancelPendingOrder() {
Order order = Order.builder()
.id(OrderId.generate())
.status(OrderStatus.PENDING)
.build();
when(orderRepository.findById(order.getId()))
.thenReturn(Optional.of(order));
when(orderRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Order result = orderService.cancelOrder(order.getId());
assertThat(result.getStatus()).isEqualTo(OrderStatus.CANCELLED);
}
@Test
void shouldNotCancelShippedOrder() {
Order order = Order.builder()
.id(OrderId.generate())
.status(OrderStatus.SHIPPED)
.build();
when(orderRepository.findById(order.getId()))
.thenReturn(Optional.of(order));
assertThatThrownBy(() -> orderService.cancelOrder(order.getId()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Cannot cancel shipped order");
}
}
@ParameterizedTest
@CsvSource({
"100.00, 10.00, 110.00",
"200.00, 0.00, 200.00",
"50.00, 5.00, 55.00"
})
void shouldCalculateTotalWithTax(BigDecimal subtotal, BigDecimal tax, BigDecimal expected) {
Order order = Order.builder()
.subtotal(subtotal)
.tax(tax)
.build();
assertThat(order.getTotal()).isEqualByComparingTo(expected);
}
}
Example 2: Web Layer Testing
@WebMvcTest(OrderController.class)
@Import({SecurityConfig.class, TestConfig.class})
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private OrderService orderService;
@Test
@WithMockUser(roles = "CUSTOMER")
void shouldCreateOrder() throws Exception {
// Given
CreateOrderRequest request = new CreateOrderRequest(
List.of(new OrderItemRequest("prod-1", 2)),
new AddressRequest("123 Main St", "NYC", "NY", "10001")
);
Order order = Order.builder()
.id(OrderId.of("order-123"))
.status(OrderStatus.CREATED)
.build();
when(orderService.createOrder(any())).thenReturn(order);
// When/Then
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(jsonPath("$.id").value("order-123"))
.andExpect(jsonPath("$.status").value("CREATED"));
}
@Test
@WithMockUser(roles = "CUSTOMER")
void shouldReturnNotFoundForMissingOrder() throws Exception {
when(orderService.getOrder(any()))
.thenThrow(new OrderNotFoundException(OrderId.of("missing")));
mockMvc.perform(get("/api/orders/missing"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error").value("ORDER_NOT_FOUND"));
}
@Test
@WithMockUser(roles = "CUSTOMER")
void shouldValidateRequest() throws Exception {
CreateOrderRequest invalidRequest = new CreateOrderRequest(
List.of(), // Empty items - should fail
null // Missing address
);
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(invalidRequest)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors").isArray())
.andExpect(jsonPath("$.errors[*].field").value(
hasItems("items", "shippingAddress")));
}
@Test
void shouldRejectUnauthenticated() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "CUSTOMER")
void shouldPaginateResults() throws Exception {
Page<Order> orderPage = new PageImpl<>(
List.of(createOrder("1"), createOrder("2")),
PageRequest.of(0, 10),
100
);
when(orderService.findOrders(any())).thenReturn(orderPage);
mockMvc.perform(get("/api/orders")
.param("page", "0")
.param("size", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").isArray())
.andExpect(jsonPath("$.content.length()").value(2))
.andExpect(jsonPath("$.totalElements").value(100))
.andExpect(jsonPath("$.totalPages").value(10));
}
}
Example 3: Data Layer Testing with Testcontainers
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class OrderRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Autowired
private OrderRepository orderRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void shouldFindOrdersByCustomerId() {
// Given
String customerId = "cust-123";
Order order1 = createOrder(customerId, OrderStatus.CREATED);
Order order2 = createOrder(customerId, OrderStatus.SHIPPED);
Order order3 = createOrder("other-customer", OrderStatus.CREATED);
entityManager.persist(order1);
entityManager.persist(order2);
entityManager.persist(order3);
entityManager.flush();
// When
List<Order> orders = orderRepository.findByCustomerId(customerId);
// Then
assertThat(orders)
.hasSize(2)
.extracting(Order::getCustomerId)
.containsOnly(customerId);
}
@Test
void shouldFindOrdersByStatusAndDateRange() {
LocalDateTime now = LocalDateTime.now();
Order recentOrder = createOrderWithDate(now.minusDays(1));
Order oldOrder = createOrderWithDate(now.minusDays(30));
entityManager.persistAndFlush(recentOrder);
entityManager.persistAndFlush(oldOrder);
List<Order> orders = orderRepository.findByStatusAndCreatedAtBetween(
OrderStatus.CREATED,
now.minusDays(7),
now
);
assertThat(orders).hasSize(1);
assertThat(orders.get(0).getId()).isEqualTo(recentOrder.getId());
}
@Test
void shouldUpdateOrderStatus() {
Order order = createOrder("cust-123", OrderStatus.CREATED);
order = entityManager.persistAndFlush(order);
entityManager.clear(); // Clear cache
int updated = orderRepository.updateStatus(order.getId(), OrderStatus.CONFIRMED);
assertThat(updated).isEqualTo(1);
Order found = entityManager.find(Order.class, order.getId());
assertThat(found.getStatus()).isEqualTo(OrderStatus.CONFIRMED);
}
@Test
void shouldUseProjection() {
Order order = createOrderWithTotal(new BigDecimal("199.99"));
entityManager.persistAndFlush(order);
Optional<OrderSummary> summary = orderRepository.findSummaryById(order.getId());
assertThat(summary).isPresent();
assertThat(summary.get().id()).isEqualTo(order.getId());
assertThat(summary.get().total()).isEqualByComparingTo("199.99");
}
@Test
void shouldHandleOptimisticLocking() {
Order order = createOrder("cust-123", OrderStatus.CREATED);
order = entityManager.persistAndFlush(order);
// Simulate concurrent modification
entityManager.getEntityManager()
.createQuery("UPDATE Order o SET o.version = o.version + 1 WHERE o.id = :id")
.setParameter("id", order.getId())
.executeUpdate();
entityManager.clear();
Order stale = entityManager.find(Order.class, order.getId());
stale.setStatus(OrderStatus.CONFIRMED);
assertThatThrownBy(() -> {
orderRepository.save(stale);
entityManager.flush();
}).isInstanceOf(OptimisticLockingFailureException.class);
}
}
Example 4: Integration Testing
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
@ActiveProfiles("test")
class OrderIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");
@Container
@ServiceConnection
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.4.0"));
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private OrderRepository orderRepository;
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
@BeforeEach
void setup() {
orderRepository.deleteAll();
}
@Test
void shouldCreateOrderEndToEnd() {
// Given
CreateOrderRequest request = validOrderRequest();
// When
ResponseEntity<OrderResponse> response = restTemplate.postForEntity(
"/api/orders",
request,
OrderResponse.class
);
// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().id()).isNotNull();
// Verify persisted
Order saved = orderRepository.findById(
OrderId.of(response.getBody().id())).orElseThrow();
assertThat(saved.getStatus()).isEqualTo(OrderStatus.CREATED);
}
@Test
void shouldPublishEventOnOrderCreation() throws Exception {
// Setup Kafka consumer
List<String> receivedMessages = new CopyOnWriteArrayList<>();
kafkaConsumer.subscribe("order-events", receivedMessages::add);
// Create order
CreateOrderRequest request = validOrderRequest();
restTemplate.postForEntity("/api/orders", request, OrderResponse.class);
// Wait for event
await()
.atMost(Duration.ofSeconds(10))
.untilAsserted(() -> {
assertThat(receivedMessages).hasSize(1);
assertThat(receivedMessages.get(0)).contains("OrderCreated");
});
}
@Test
void shouldHandleConcurrentUpdates() throws Exception {
// Create order
Order order = orderRepository.save(createOrder("cust-1"));
// Concurrent updates
ExecutorService executor = Executors.newFixedThreadPool(10);
CountDownLatch latch = new CountDownLatch(10);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger conflictCount = new AtomicInteger(0);
for (int i = 0; i < 10; i++) {
final int itemCount = i + 1;
executor.submit(() -> {
try {
ResponseEntity<Void> response = restTemplate.exchange(
"/api/orders/{id}/items",
HttpMethod.POST,
new HttpEntity<>(new AddItemRequest("prod-" + itemCount, 1)),
Void.class,
order.getId()
);
if (response.getStatusCode().is2xxSuccessful()) {
successCount.incrementAndGet();
} else if (response.getStatusCode() == HttpStatus.CONFLICT) {
conflictCount.incrementAndGet();
}
} finally {
latch.countDown();
}
});
}
latch.await(30, TimeUnit.SECONDS);
// At least some should succeed, some may conflict
assertThat(successCount.get()).isGreaterThan(0);
assertThat(successCount.get() + conflictCount.get()).isEqualTo(10);
}
}
Example 5: Contract Testing with Pact
@Provider("order-service")
@PactBroker
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class OrderContractTest {
@LocalServerPort
private int port;
@MockBean
private OrderService orderService;
@BeforeEach
void setup(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port));
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void pactVerificationTestTemplate(PactVerificationContext context) {
context.verifyInteraction();
}
@State("order with ID order-123 exists")
void orderExists() {
Order order = Order.builder()
.id(OrderId.of("order-123"))
.customerId("cust-456")
.status(OrderStatus.CREATED)
.total(new BigDecimal("199.99"))
.build();
when(orderService.getOrder(OrderId.of("order-123"))).thenReturn(order);
}
@State("no orders exist")
void noOrdersExist() {
when(orderService.getOrder(any()))
.thenThrow(new OrderNotFoundException(OrderId.of("any")));
}
}
// Consumer side contract test
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "order-service")
class OrderClientContractTest {
@Pact(consumer = "checkout-service")
public V4Pact createPact(PactDslWithProvider builder) {
return builder
.given("order with ID order-123 exists")
.uponReceiving("a request for order order-123")
.path("/api/orders/order-123")
.method("GET")
.willRespondWith()
.status(200)
.body(newJsonBody(body -> {
body.stringValue("id", "order-123");
body.stringValue("status", "CREATED");
body.decimalType("total", 199.99);
}).build())
.toPact(V4Pact.class);
}
@Test
@PactTestFor(pactMethod = "createPact")
void shouldFetchOrder(MockServer mockServer) {
OrderClient client = new OrderClient(mockServer.getUrl());
Order order = client.getOrder("order-123");
assertThat(order.getId()).isEqualTo("order-123");
assertThat(order.getStatus()).isEqualTo("CREATED");
}
}
Anti-Patterns
❌ Testing Implementation Details
// WRONG - testing internal structure
@Test
void shouldSetFieldsCorrectly() {
Order order = orderService.createOrder(command);
assertThat(order.getInternalState()).isEqualTo("INITIALIZED");
assertThat(order.getAuditFields().getCreatedBy()).isNotNull();
}
// ✅ CORRECT - test behavior
@Test
void shouldCreateOrder() {
Order order = orderService.createOrder(command);
assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED);
assertThat(order.getId()).isNotNull();
}
❌ Excessive Mocking
// WRONG - mocking everything
@Test
void overMocked() {
when(step1.execute()).thenReturn(result1);
when(step2.process(result1)).thenReturn(result2);
when(step3.validate(result2)).thenReturn(true);
when(step4.finalize(result2)).thenReturn(result3);
// Test becomes a mirror of implementation
}
// ✅ CORRECT - integration test
@SpringBootTest
@Test
void shouldProcessOrder() {
Order result = orderProcessor.process(validOrder);
assertThat(result.isProcessed()).isTrue();
}