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:
- SQL dialect differences: H2's SQL differs from PostgreSQL. Tests pass with H2 but fail in production
- Missing features: PostgreSQL features (JSON operators, CTEs, window functions) don't exist in H2
- Different constraints: Foreign key behavior, transaction isolation levels differ between databases
- 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:
- Unit Testing - Unit testing best practices across languages
- Integration Testing - Integration testing patterns with TestContainers
- Contract Testing - Consumer-driven contract testing with Pact
- Mutation Testing - PITest detailed guide and configuration
- Test Data Management - Managing test data effectively
- CI Testing - CI pipeline testing best practices
Related Spring Boot Topics:
- Spring Boot Data Access - For database and JPA testing patterns
- Spring Boot API Design - For REST API contract testing
- Spring Boot Security - For security testing approaches
External Resources:
Summary
Key Takeaways:
- Testing Honeycomb: Integration tests (50-60%), unit tests (25-35%), contract tests (10-15%)
- TestContainers: Use real databases, not H2 or mocks
- Mutation testing: PITest with ≥80% mutation coverage threshold
- Contract testing: Validate APIs with OpenAPI specs and Pact
- MockMvc: Test REST APIs with full Spring context
- Test builders: Use builder pattern for test data
- AssertJ: Fluent assertions for readable tests
- @Transactional: Automatic rollback in integration tests
- WireMock: Mock external HTTP services
- Fast feedback: All tests run in <10 minutes