Spring Boot Testing Strategies: From Unit Tests to Consumer-Driven Contracts
It's 4:47 PM on a Friday, and you're staring at the merge button. Your CI pipeline is green. Your code coverage sits at a comfortable 87%. But that nagging feeling persists: "Will this break in production?"
We've all been burned by the "works on my machine" phenomenon. The real challenge in 2026 isn't achieving code coverage; it's building confidence and velocity.
A robust testing strategy means deploying features faster because your test suite actually catches regressions before they reach production. This guide walks through the testing pyramid for modern Spring Boot 3 applications—from isolated unit tests to consumer-driven contracts that prevent microservice drift.
What we'll cover: Unit tests with JUnit 5 and Mockito, slice testing with @WebMvcTest and @DataJpaTest, integration testing with Testcontainers, and consumer-driven contracts with Spring Cloud Contract. No "hello world" examples—just production-grade patterns.
The Foundation: JUnit 5 & Modern Mocking
Your service layer contains your business logic. Testing it in isolation—without loading the entire Spring context—keeps your feedback loop fast.
Pure Unit Tests
For service classes that coordinate between repositories and external APIs, we want to verify the logic without touching any infrastructure. JUnit 5's @ExtendWith(MockitoExtension.class) and Mockito's @Mock annotation handle the boilerplate.
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private PaymentGateway paymentGateway;
@InjectMocks
private OrderService orderService;
@Test
void shouldProcessValidOrder() {
// Arrange
var order = new Order(100L, BigDecimal.valueOf(49.99));
when(paymentGateway.charge(any())).thenReturn(new PaymentResult(true));
when(orderRepository.save(any())).thenReturn(order);
// Act
var result = orderService.processOrder(order);
// Assert
assertThat(result.isConfirmed()).isTrue();
verify(orderRepository).save(order);
}
@ParameterizedTest
@CsvSource({
"0.00, false",
"-10.00, false",
"0.01, true"
})
void shouldValidateOrderAmounts(BigDecimal amount, boolean expected) {
var order = new Order(null, amount);
assertThat(orderService.isValid(order)).isEqualTo(expected);
}
}
OneCube Insight: Notice we're not using
@SpringBootTest. Loading the full application context for a service test adds 5-10 seconds to your test run. Multiply that by 200 tests, and your CI feedback loop becomes painful. Keep unit tests fast by avoiding Spring altogether.
When to Use @MockitoBean
If you do need to load a Spring slice (more on that next), use @MockitoBean to replace specific beans in the context. This is the modern replacement for the deprecated @MockBean in Spring Boot 3.4+.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private OrderService orderService;
@Test
void shouldReturn201OnValidOrder() throws Exception {
when(orderService.processOrder(any()))
.thenReturn(new OrderResult(true, "ORDER-123"));
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"amount": 49.99}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.orderId").value("ORDER-123"));
}
}
Slice Testing: Focusing the Lens
Spring Boot's slice tests load only the components relevant to a specific layer. This is faster than @SpringBootTest but still gives you confidence that your controller serialization or JPA queries work correctly.
@WebMvcTest for Controllers
Load only the web layer—controllers, @ControllerAdvice, JSON converters—without touching the database or service implementations.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private OrderService orderService;
@Test
void shouldValidateRequiredFields() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors").isArray());
}
@Test
void shouldHandleServiceExceptions() throws Exception {
when(orderService.processOrder(any()))
.thenThrow(new PaymentDeclinedException("Insufficient funds"));
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"amount": 49.99}
"""))
.andExpect(status().isPaymentRequired())
.andExpect(jsonPath("$.message").value("Insufficient funds"));
}
}
@DataJpaTest for Repositories
Test your custom queries and projections against an actual database. Spring Boot will auto-configure an embedded database (H2 by default) and roll back transactions after each test.
@DataJpaTest
class OrderRepositoryTest {
@Autowired
private OrderRepository orderRepository;
@Test
void shouldFindOrdersByDateRange() {
// Arrange
var start = LocalDate.of(2026, 1, 1);
var end = LocalDate.of(2026, 1, 31);
orderRepository.saveAll(List.of(
new Order(start.atStartOfDay(), BigDecimal.TEN),
new Order(start.plusDays(15).atStartOfDay(), BigDecimal.TEN),
new Order(end.plusDays(1).atStartOfDay(), BigDecimal.TEN)
));
// Act
var results = orderRepository.findByCreatedAtBetween(
start.atStartOfDay(),
end.atTime(23, 59, 59)
);
// Assert
assertThat(results).hasSize(2);
}
}
Warning: Slice tests are great for quick feedback, but don't over-rely on them. An H2 database doesn't behave exactly like PostgreSQL—especially for native queries, JSON columns, or window functions.
Integration Testing: The "Real" Environment
This is where Testcontainers changed everything. Instead of mocking your database or using H2 (which has subtle differences from PostgreSQL), you spin up a real database in a Docker container for each test run.
The Old Way: H2 In-Memory Database
// application-test.properties
spring.datasource.url=jdbc:h2:mem:testdb
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
This works until you hit a PostgreSQL-specific feature—JSONB columns, ARRAY types, DISTINCT ON, or custom functions. Your tests pass, but production fails.
The New Way: Testcontainers with @ServiceConnection
Spring Boot 3.1 introduced @ServiceConnection, which automatically configures your datasource, Redis, Kafka, or other infrastructure from a Testcontainers instance. No manual property overrides.
@SpringBootTest
@Testcontainers
class OrderIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
"postgres:16-alpine"
);
@Autowired
private OrderService orderService;
@Autowired
private OrderRepository orderRepository;
@Test
void shouldPersistOrderWithJsonMetadata() {
var metadata = Map.of(
"source", "mobile-app",
"deviceId", "abc-123"
);
var order = new Order(BigDecimal.valueOf(99.99), metadata);
var saved = orderService.processOrder(order);
var retrieved = orderRepository.findById(saved.id()).orElseThrow();
assertThat(retrieved.metadata()).containsEntry("source", "mobile-app");
}
}
The magic: @ServiceConnection detects the PostgreSQL container and auto-configures spring.datasource.* properties. No more @DynamicPropertySource workarounds. Your Spring context connects to a real database running in Docker with zero manual configuration.
Performance Considerations
Testcontainers add 2-5 seconds of startup overhead. For a suite of 50 integration tests, that's negligible compared to the confidence gain. If you're running hundreds of integration tests, consider:
- Reusing containers across test classes with
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)and a shared static container. - Parallel execution with JUnit 5's
@Execution(SAME_THREAD)for classes that share state.
Consumer-Driven Contracts: Solving Microservice Drift
You have 15 microservices. The "Orders" service consumes the "Inventory" API. Your team deploys a breaking change to Inventory, and Orders breaks in production. Sound familiar?
End-to-end (E2E) tests that spin up all 15 services are slow, flaky, and expensive to maintain. Consumer-Driven Contract Testing is the alternative—catching API drift during development, not after deployment.
The Core Idea
The consumer (Orders service) defines the contract: "When I call GET /api/products/123, I expect a 200 response with this JSON structure." The provider (Inventory service) generates tests from that contract to ensure it honors the agreement.
Spring Cloud Contract Workflow
// orders-service/src/test/resources/contracts/inventory/get_product.groovy
Contract.make {
description "Should return product details"
request {
method GET()
url "/api/products/123"
}
response {
status 200
headers {
contentType(applicationJson())
}
body([
id: 123,
name: "Widget",
inStock: true
])
}
}
On the Consumer Side (Orders service):
The contract generates a WireMock stub. Your tests call the stub instead of the real Inventory service.
@SpringBootTest
@AutoConfigureStubRunner(
ids = "com.onecube:inventory-service:+:stubs:8080",
stubsMode = StubRunnerProperties.StubsMode.LOCAL
)
class OrderServiceContractTest {
@Autowired
private OrderService orderService;
@Test
void shouldCheckInventoryBeforeOrder() {
var order = new Order(123L, 1);
var result = orderService.processOrder(order);
assertThat(result.isConfirmed()).isTrue();
}
}
On the Provider Side (Inventory service):
Spring Cloud Contract generates a test that verifies your controller matches the contract.
// Auto-generated by Spring Cloud Contract Maven/Gradle plugin
@SpringBootTest(webEnvironment = WebEnvironment.MOCK)
@AutoConfigureMockMvc
class ContractVerifierTest {
@Autowired
private MockMvc mockMvc;
@Test
void validate_get_product() throws Exception {
mockMvc.perform(get("/api/products/123"))
.andExpect(status().is(200))
.andExpect(content().json("""
{"id":123,"name":"Widget","inStock":true}
"""));
}
}
If the Inventory team breaks the contract (e.g., renames inStock to available), their build fails before they deploy.
Contract Testing vs. E2E Testing
| Aspect | E2E Tests | Contract Tests |
|---|---|---|
| Setup | Spin up all services | Run in isolation |
| Speed | 10+ minutes | < 1 minute |
| Flakiness | High (network, timing) | Low (mocked dependencies) |
| Ownership | Centralized QA team | Each team owns their contracts |
Contract tests don't replace E2E tests entirely—you still want a smoke suite in staging—but they catch API drift during development, not after deployment.
The Testing Trophy vs. Pyramid
The traditional testing pyramid says: "Lots of unit tests, some integration tests, few E2E tests." The Testing Trophy (popularized by Kent C. Dodds) shifts weight toward integration tests.
Our Take: For Spring Boot applications in 2026, we prioritize:
- Integration tests with Testcontainers (60% of effort) — Catch real-world issues.
- Unit tests for complex business logic (30%) — Fast feedback on algorithms, calculations, rules engines.
- Contract tests for service boundaries (10%) — Prevent API drift without E2E overhead.
- E2E tests (smoke suite only) — Verify critical paths in staging.
The key is confidence per second of CI time. A flaky Selenium test that takes 5 minutes and fails 20% of the time has terrible ROI.
Conclusion
A robust testing strategy isn't about hitting 100% code coverage. It's about deploying on Friday afternoon without anxiety.
The pragmatic approach: Start with integration tests (Testcontainers) for your critical flows. Add contract tests when you have multiple teams working on interconnected services. Reserve E2E tests for smoke suites in staging. The tools exist—Testcontainers, Spring Cloud Contract, JUnit 5's parameterized tests—to build confidence without sacrificing velocity.
Want to work with teams that treat testing as a first-class engineering practice? OneCube Staffing connects senior engineers with companies that value TDD, CI/CD, and sustainable velocity. Explore our open engineering roles.
Frequently Asked Questions
When should I use @SpringBootTest versus @WebMvcTest?
Use @SpringBootTest for integration tests that need the full application context or Testcontainers. Use @WebMvcTest for isolated controller tests—verifying JSON serialization, HTTP status codes, and request validation. Slice tests are faster and more focused.
Is H2 still relevant for testing in 2026?
H2 works for prototyping or simple CRUD apps, but Testcontainers has made it obsolete for production-grade applications. If you're using PostgreSQL-specific features (JSONB, array columns, window functions), your tests must run against PostgreSQL—not an in-memory database that behaves differently.
How does Contract Testing differ from E2E testing?
E2E tests spin up all services and verify workflows end-to-end. They're slow and flaky. Contract tests verify that a consumer's expectations match the provider's implementation without running both services. The consumer tests against a generated stub; the provider validates against the contract. You catch breaking changes during development, not in production.
What is the performance impact of Testcontainers?
Expect 2-5 seconds of startup overhead per container. For a suite of 50 integration tests, that's negligible. Running hundreds of tests? Reuse containers across test classes with static fields and shared Spring contexts. Modern CI runners with Docker layer caching make this even faster.
Should I write tests before or after writing the code?
The honest answer: it depends. TDD works exceptionally well for algorithmic problems, API contracts, and refactoring legacy code. For exploratory work or UI prototyping, writing tests after is more pragmatic. What matters: fast feedback and catching regressions—not the order you wrote them.
How do I test Spring Boot applications that use Kafka or RabbitMQ?
Use Testcontainers with @ServiceConnection. Spring Boot 3.1+ supports automatic configuration for Kafka, RabbitMQ, Redis, MongoDB, and more. Declare a KafkaContainer or RabbitMQContainer in your test class, annotate it with @ServiceConnection, and Spring Boot will wire the connection properties automatically. No manual configuration required.
References
- Spring Boot Testing Documentation: Official Spring Boot Testing Guide
- Testcontainers: Testcontainers Official Site
- Spring Cloud Contract: Spring Cloud Contract Documentation
- JUnit 5 User Guide: JUnit 5 Documentation
- Testing Trophy: Kent C. Dodds on the Testing Trophy