Skip to content
Home / Skills / Spring / Testing
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

AnnotationPurposeSpeed
@SpringBootTestFull contextSlow
@WebMvcTestWeb layer onlyFast
@DataJpaTestJPA layer onlyMedium
@WebFluxTestWebFlux layerFast
@JsonTestJSON serializationFast

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

References