Skip to main content

Spring Boot Testing Guidelines

Comprehensive testing strategies for Spring Boot applications using the Testing Honeycomb model with JUnit 5, TestContainers, and mutation testing.

Overview

This guide covers all levels of testing for Spring Boot applications using JUnit 5, TestContainers, MockMvc, and PITest, with emphasis on integration tests using real dependencies rather than mocks. For the Testing Honeycomb model rationale and general testing principles, see Testing Strategy.

Spring Boot applications follow the Testing Honeycomb distribution: 50-60% integration tests (TestContainers, MockMvc), 25-35% unit tests (pure business logic with Mockito), 10-15% contract tests (OpenAPI validation, Pact), and <5% E2E tests. This distribution works well because modern tooling (TestContainers, test slices) makes integration tests fast enough for frequent execution while catching issues that unit tests miss: SQL dialect problems, transaction boundaries, constraint violations, and serialization issues.


Core Principles

  • Integration tests first: Test with real dependencies (database, message queues)
  • TestContainers: Use Docker containers for real databases, not H2
  • Mutation testing: Validate test quality with PITest
  • Contract testing: Validate API contracts with OpenAPI
  • Fast feedback: Tests run in <10 minutes

Testing Strategy Overview


Unit Testing

When to Write Unit Tests

Unit tests are for isolated business logic:

  • Complex calculations (tax, interest, fees)
  • Business rules and validation
  • State machines and workflows
  • Pure functions without external dependencies

JUnit 5 + Mockito + AssertJ

@ExtendWith(MockitoExtension.class)
class PaymentServiceTest {

@Mock
private PaymentRepository paymentRepository;

@Mock
private PaymentGateway paymentGateway;

@Mock
private AuditService auditService;

@InjectMocks
private PaymentService paymentService;

@Test
@DisplayName("Should process payment successfully")
void shouldProcessPaymentSuccessfully() {
// Arrange
var request = new PaymentRequest(
"customer-123",
new BigDecimal("100.00"),
"USD",
"Payment for order #456"
);

var payment = Payment.builder()
.id("payment-789")
.customerId("customer-123")
.amount(new BigDecimal("100.00"))
.currency("USD")
.status(PaymentStatus.PENDING)
.build();

var gatewayResult = new PaymentGatewayResult(
"txn-abc",
PaymentStatus.SUCCESS
);

when(paymentRepository.save(any(Payment.class))).thenReturn(payment);
when(paymentGateway.process(any())).thenReturn(gatewayResult);

// Act
var result = paymentService.processPayment(request);

// Assert
assertThat(result)
.isNotNull()
.satisfies(response -> {
assertThat(response.paymentId()).isEqualTo("payment-789");
assertThat(response.status()).isEqualTo(PaymentStatus.SUCCESS);
assertThat(response.amount()).isEqualByComparingTo("100.00");
});

verify(paymentRepository).save(any(Payment.class));
verify(paymentGateway).process(any());
verify(auditService).logPayment(any());
verifyNoMoreInteractions(paymentRepository, paymentGateway, auditService);
}

@Test
@DisplayName("Should throw exception when payment amount is negative")
void shouldThrowExceptionForNegativeAmount() {
// Arrange
var request = new PaymentRequest(
"customer-123",
new BigDecimal("-100.00"),
"USD",
"Invalid payment"
);

// Act & Assert
assertThatThrownBy(() -> paymentService.processPayment(request))
.isInstanceOf(ValidationException.class)
.hasMessage("Payment amount must be positive");

verifyNoInteractions(paymentRepository, paymentGateway, auditService);
}

@ParameterizedTest
@CsvSource({
"0.00, false",
"0.01, true",
"100.00, true",
"-1.00, false"
})
@DisplayName("Should validate payment amounts correctly")
void shouldValidatePaymentAmounts(BigDecimal amount, boolean expected) {
assertThat(paymentService.isValidAmount(amount)).isEqualTo(expected);
}
}

Test Data Builders

Use builder pattern for test data:

public class PaymentTestBuilder {
private String id = "payment-" + UUID.randomUUID();
private String customerId = "customer-123";
private BigDecimal amount = new BigDecimal("100.00");
private String currency = "USD";
private PaymentStatus status = PaymentStatus.PENDING;
private String description = "Test payment";

public static PaymentTestBuilder aPayment() {
return new PaymentTestBuilder();
}

public PaymentTestBuilder withId(String id) {
this.id = id;
return this;
}

public PaymentTestBuilder withCustomerId(String customerId) {
this.customerId = customerId;
return this;
}

public PaymentTestBuilder withAmount(BigDecimal amount) {
this.amount = amount;
return this;
}

public PaymentTestBuilder withStatus(PaymentStatus status) {
this.status = status;
return this;
}

public Payment build() {
return Payment.builder()
.id(id)
.customerId(customerId)
.amount(amount)
.currency(currency)
.status(status)
.description(description)
.createdAt(Instant.now())
.updatedAt(Instant.now())
.build();
}
}

// Usage in tests
var payment = aPayment()
.withCustomerId("customer-456")
.withAmount(new BigDecimal("250.00"))
.withStatus(PaymentStatus.SUCCESS)
.build();

Integration Testing

TestContainers Setup

Use TestContainers for real database integration tests instead of in-memory databases like H2.

Why TestContainers over H2?

H2 is tempting because it's fast and requires no setup, but it creates a dangerous illusion of correctness:

  1. SQL dialect differences: H2's SQL differs from PostgreSQL. Tests pass with H2 but fail in production
  2. Missing features: PostgreSQL features (JSON operators, CTEs, window functions) don't exist in H2
  3. Different constraints: Foreign key behavior, transaction isolation levels differ between databases
  4. Schema differences: Auto-increment vs sequences, data type mappings are inconsistent

TestContainers runs your actual production database (PostgreSQL) in a Docker container during tests. Containers start in 2-3 seconds and are automatically cleaned up. This ensures your tests validate against the same database behavior as production.

Use TestContainers for real database integration tests:

build.gradle:

dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation platform('org.testcontainers:testcontainers-bom:1.20.4')
testImplementation 'org.testcontainers:postgresql'
testImplementation 'org.testcontainers:junit-jupiter'
}

Base Integration Test Class

@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
@Transactional
public abstract class BaseIntegrationTest {

@Container
@ServiceConnection // Spring Boot 3.1+: auto-configures datasource from container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true); // Reuse container across test classes
}

Repository Integration Tests

class PaymentRepositoryIntegrationTest extends BaseIntegrationTest {

@Autowired
private PaymentRepository paymentRepository;

@Test
@DisplayName("Should save and retrieve payment")
void shouldSaveAndRetrievePayment() {
// Arrange
var payment = Payment.builder()
.id(UUID.randomUUID().toString())
.customerId("customer-123")
.amount(new BigDecimal("100.00"))
.currency("USD")
.status(PaymentStatus.PENDING)
.createdAt(Instant.now())
.updatedAt(Instant.now())
.build();

// Act
var saved = paymentRepository.save(payment);
var retrieved = paymentRepository.findById(saved.getId());

// Assert
assertThat(retrieved)
.isPresent()
.get()
.satisfies(p -> {
assertThat(p.getId()).isEqualTo(payment.getId());
assertThat(p.getCustomerId()).isEqualTo("customer-123");
assertThat(p.getAmount()).isEqualByComparingTo("100.00");
assertThat(p.getStatus()).isEqualTo(PaymentStatus.PENDING);
});
}

@Test
@DisplayName("Should find payments by customer ID")
void shouldFindPaymentsByCustomerId() {
// Arrange
var payment1 = aPayment().withCustomerId("customer-123").build();
var payment2 = aPayment().withCustomerId("customer-123").build();
var payment3 = aPayment().withCustomerId("customer-456").build();

paymentRepository.saveAll(List.of(payment1, payment2, payment3));

// Act
var payments = paymentRepository.findByCustomerId("customer-123");

// Assert
assertThat(payments)
.hasSize(2)
.extracting(Payment::getCustomerId)
.containsOnly("customer-123");
}

@Test
@DisplayName("Should filter payments by status and date range")
void shouldFilterPaymentsByStatusAndDateRange() {
// Arrange
var now = Instant.now();
var payment1 = aPayment()
.withStatus(PaymentStatus.SUCCESS)
.withCreatedAt(now.minus(2, ChronoUnit.DAYS))
.build();
var payment2 = aPayment()
.withStatus(PaymentStatus.SUCCESS)
.withCreatedAt(now.minus(1, ChronoUnit.DAYS))
.build();
var payment3 = aPayment()
.withStatus(PaymentStatus.FAILED)
.withCreatedAt(now.minus(1, ChronoUnit.DAYS))
.build();

paymentRepository.saveAll(List.of(payment1, payment2, payment3));

// Act
var payments = paymentRepository.findByStatusAndCreatedAtBetween(
PaymentStatus.SUCCESS,
now.minus(36, ChronoUnit.HOURS),
now
);

// Assert
assertThat(payments)
.hasSize(1)
.first()
.satisfies(p -> assertThat(p.getStatus()).isEqualTo(PaymentStatus.SUCCESS));
}
}

REST API Integration Tests with MockMvc

@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
@ActiveProfiles("test")
class PaymentControllerIntegrationTest extends BaseIntegrationTest {

@Autowired
private MockMvc mockMvc;

@Autowired
private PaymentRepository paymentRepository;

@Autowired
private ObjectMapper objectMapper;

@Test
@DisplayName("POST /api/v1/payments should create payment")
void shouldCreatePayment() throws Exception {
// Arrange
var request = new PaymentRequest(
"customer-123",
new BigDecimal("100.00"),
"USD",
"Payment for order #456"
);

// Act & Assert
mockMvc.perform(post("/api/v1/payments")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.paymentId").exists())
.andExpect(jsonPath("$.status").value("PENDING"))
.andExpect(jsonPath("$.amount").value(100.00))
.andExpect(jsonPath("$.currency").value("USD"));

// Verify database
var payments = paymentRepository.findByCustomerId("customer-123");
assertThat(payments).hasSize(1);
}

@Test
@DisplayName("GET /api/v1/payments/{id} should return payment")
void shouldGetPayment() throws Exception {
// Arrange
var payment = aPayment().withCustomerId("customer-123").build();
paymentRepository.save(payment);

// Act & Assert
mockMvc.perform(get("/api/v1/payments/{id}", payment.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.paymentId").value(payment.getId()))
.andExpect(jsonPath("$.customerId").value("customer-123"))
.andExpect(jsonPath("$.amount").value(payment.getAmount().doubleValue()));
}

@Test
@DisplayName("GET /api/v1/payments/{id} should return 404 for non-existent payment")
void shouldReturn404ForNonExistentPayment() throws Exception {
// Act & Assert
mockMvc.perform(get("/api/v1/payments/{id}", "non-existent-id"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.title").value("Not Found"))
.andExpect(jsonPath("$.detail").exists());
}

@Test
@DisplayName("POST /api/v1/payments should validate request")
void shouldValidatePaymentRequest() throws Exception {
// Arrange
var invalidRequest = new PaymentRequest(
"", // Empty customer ID
new BigDecimal("-100.00"), // Negative amount
"", // Empty currency
null
);

// Act & Assert
mockMvc.perform(post("/api/v1/payments")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(invalidRequest)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors").isArray())
.andExpect(jsonPath("$.errors", hasSize(greaterThan(0))));
}

@Test
@DisplayName("GET /api/v1/payments should support pagination")
void shouldSupportPagination() throws Exception {
// Arrange
var payments = IntStream.range(0, 25)
.mapToObj(i -> aPayment().withCustomerId("customer-" + i).build())
.toList();
paymentRepository.saveAll(payments);

// Act & Assert - Page 0
mockMvc.perform(get("/api/v1/payments")
.param("page", "0")
.param("size", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content", hasSize(10)))
.andExpect(jsonPath("$.totalElements").value(25))
.andExpect(jsonPath("$.totalPages").value(3))
.andExpect(jsonPath("$.number").value(0));

// Act & Assert - Page 2
mockMvc.perform(get("/api/v1/payments")
.param("page", "2")
.param("size", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content", hasSize(5)))
.andExpect(jsonPath("$.number").value(2));
}
}

External Service Integration with WireMock

@SpringBootTest
@AutoConfigureWireMock(port = 0) // Random port
@Testcontainers
@ActiveProfiles("test")
class PaymentGatewayIntegrationTest extends BaseIntegrationTest {

@Autowired
private PaymentGatewayClient gatewayClient;

@Test
@DisplayName("Should process payment successfully via gateway")
void shouldProcessPaymentViaGateway() {
// Arrange
var request = new PaymentRequest(
"customer-123",
new BigDecimal("100.00"),
"USD",
"Test payment"
);

stubFor(post(urlEqualTo("/api/payments"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{
"transactionId": "txn-123",
"status": "SUCCESS",
"timestamp": "2025-01-28T10:00:00Z"
}
""")));

// Act
var result = gatewayClient.processPayment(request);

// Assert
assertThat(result)
.isNotNull()
.satisfies(r -> {
assertThat(r.transactionId()).isEqualTo("txn-123");
assertThat(r.status()).isEqualTo(PaymentStatus.SUCCESS);
});

verify(postRequestedFor(urlEqualTo("/api/payments"))
.withHeader("Content-Type", equalTo("application/json"))
.withRequestBody(matchingJsonPath("$.amount", equalTo("100.00"))));
}

@Test
@DisplayName("Should handle gateway timeout")
void shouldHandleGatewayTimeout() {
// Arrange
stubFor(post(urlEqualTo("/api/payments"))
.willReturn(aResponse()
.withFixedDelay(5000) // 5 second delay
.withStatus(500)));

var request = new PaymentRequest(
"customer-123",
new BigDecimal("100.00"),
"USD",
"Test payment"
);

// Act & Assert
assertThatThrownBy(() -> gatewayClient.processPayment(request))
.isInstanceOf(PaymentGatewayException.class)
.hasMessageContaining("timeout");
}
}

Mutation Testing with PITest

Configuration

build.gradle:

plugins {
id 'info.solidsoft.pitest' version '1.15.0'
}

pitest {
targetClasses = ['com.bank.payments.*']
excludedClasses = [
'com.bank.payments.config.*',
'com.bank.payments.*Application',
'com.bank.payments.*.dto.*',
'com.bank.payments.*.entity.*'
]
targetTests = ['com.bank.payments.*Test']

mutators = ['STRONGER'] // Use stronger mutators

// Thresholds
mutationThreshold = 80
coverageThreshold = 85

timestampedReports = false
outputFormats = ['HTML', 'XML']

// Performance
threads = 4
enableDefaultIncrementalAnalysis = true
historyInputLocation = '.pitest/history'
historyOutputLocation = '.pitest/history'

// Exclude generated code
avoidCallsTo = [
'java.util.logging',
'org.slf4j',
'org.apache.log4j'
]
}

Run Mutation Tests

# Run mutation tests
./gradlew pitest

# View report
open build/reports/pitest/index.html

Analyzing Mutation Test Results

//  BAD: Weak test (mutation survives)
@Test
void shouldValidateAmount() {
var result = paymentService.isValidAmount(new BigDecimal("100.00"));
assertThat(result).isTrue();
}

// GOOD: Strong test (mutation killed)
@ParameterizedTest
@CsvSource({
"0.00, false", // Boundary
"0.01, true", // Just above boundary
"100.00, true", // Valid amount
"-0.01, false", // Just below boundary
"-100.00, false" // Negative
})
void shouldValidateAmount(BigDecimal amount, boolean expected) {
var result = paymentService.isValidAmount(amount);
assertThat(result).isEqualTo(expected);
}

Contract Testing

OpenAPI Contract Validation

Provider-Side (Backend):

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@ActiveProfiles("test")
class OpenApiContractTest extends BaseIntegrationTest {

@LocalServerPort
private int port;

@Autowired
private PaymentRepository paymentRepository;

@Test
@DisplayName("Should validate API responses against OpenAPI spec")
void shouldValidateAgainstOpenApiSpec() {
// Load OpenAPI spec
var openApiSpec = OpenApiInteractionValidator
.createFor("src/main/resources/openapi/payment-api.yaml")
.build();

// Create test payment
var payment = aPayment().withCustomerId("customer-123").build();
paymentRepository.save(payment);

// Make request
var response = RestAssured.given()
.port(port)
.when()
.get("/api/v1/payments/{id}", payment.getId())
.then()
.extract().response();

// Validate against OpenAPI spec
var validationReport = openApiSpec.validate(
SimpleRequest.Builder
.get("/api/v1/payments/" + payment.getId())
.build(),
SimpleResponse.Builder
.status(response.statusCode())
.withBody(response.body().asString())
.withContentType("application/json")
.build()
);

assertThat(validationReport.hasErrors())
.as("API response should match OpenAPI spec")
.isFalse();
}
}

Pact Consumer-Driven Contracts

Consumer Test (Frontend/Service):

@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "payment-service", port = "8080")
class PaymentServiceConsumerPactTest {

@Pact(consumer = "loan-service")
public RequestResponsePact createPaymentPact(PactDslWithProvider builder) {
return builder
.given("A payment exists")
.uponReceiving("A request to get payment by ID")
.path("/api/v1/payments/payment-123")
.method("GET")
.willRespondWith()
.status(200)
.headers(Map.of("Content-Type", "application/json"))
.body(newJsonBody(body -> body
.stringType("paymentId", "payment-123")
.stringValue("customerId", "customer-123")
.decimalType("amount", 100.00)
.stringValue("currency", "USD")
.stringValue("status", "SUCCESS")
).build())
.toPact();
}

@Test
@PactTestFor(pactMethod = "createPaymentPact")
void testGetPayment(MockServer mockServer) {
// Arrange
var client = new PaymentServiceClient(mockServer.getUrl());

// Act
var payment = client.getPayment("payment-123");

// Assert
assertThat(payment)
.isNotNull()
.satisfies(p -> {
assertThat(p.paymentId()).isEqualTo("payment-123");
assertThat(p.amount()).isEqualByComparingTo("100.00");
});
}
}

Provider Test (Backend):

@Provider("payment-service")
@PactBroker(host = "pact-broker.bank.com", scheme = "https")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class PaymentServiceProviderPactTest extends BaseIntegrationTest {

@LocalServerPort
private int port;

@Autowired
private PaymentRepository paymentRepository;

@BeforeEach
void setUp(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port));
}

@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void pactVerificationTestTemplate(PactVerificationContext context) {
context.verifyInteraction();
}

@State("A payment exists")
void paymentExists() {
var payment = aPayment()
.withId("payment-123")
.withCustomerId("customer-123")
.withAmount(new BigDecimal("100.00"))
.withStatus(PaymentStatus.SUCCESS)
.build();
paymentRepository.save(payment);
}
}

Test Configuration

application-test.yml:

spring:
datasource:
# Configured automatically by @ServiceConnection with TestContainers
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
properties:
hibernate:
format_sql: true

logging:
level:
com.bank.payments: DEBUG
org.hibernate.SQL: DEBUG
org.testcontainers: INFO

payment:
gateway:
base-url: http://localhost:${wiremock.server.port}
timeout: 1000
retry-attempts: 1

Further Reading

Testing Guidelines:

Related Spring Boot Topics:

External Resources:


Summary

Key Takeaways:

  1. Testing Honeycomb: Integration tests (50-60%), unit tests (25-35%), contract tests (10-15%)
  2. TestContainers: Use real databases, not H2 or mocks
  3. Mutation testing: PITest with ≥80% mutation coverage threshold
  4. Contract testing: Validate APIs with OpenAPI specs and Pact
  5. MockMvc: Test REST APIs with full Spring context
  6. Test builders: Use builder pattern for test data
  7. AssertJ: Fluent assertions for readable tests
  8. @Transactional: Automatic rollback in integration tests
  9. WireMock: Mock external HTTP services
  10. Fast feedback: All tests run in <10 minutes