Back to Articles
February 22, 2026 11 min read

Spring Boot Testing Strategies: From Unit Tests to Consumer-Driven Contracts

Comprehensive testing for Spring Boot applications. Master JUnit 5, Testcontainers, Mockito, and integration testing for enterprise-grade quality assurance.

Spring Boot 3 Testing JUnit 5 Testcontainers Integration Testing
Spring Boot Testing Strategies: From Unit Tests to Consumer-Driven Contracts

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:

  1. Integration tests with Testcontainers (60% of effort) — Catch real-world issues.
  2. Unit tests for complex business logic (30%) — Fast feedback on algorithms, calculations, rules engines.
  3. Contract tests for service boundaries (10%) — Prevent API drift without E2E overhead.
  4. 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

Looking for Your Next Role?

Let us help you find the perfect software engineering opportunity.

Explore Opportunities