DEV Community

Cover image for Stop Lying to Your Tests: Real Infrastructure Testing with Testcontainers (Spring Boot 4)
Simanta Sarma
Simanta Sarma

Posted on

Stop Lying to Your Tests: Real Infrastructure Testing with Testcontainers (Spring Boot 4)

In the age of AI-assisted development, writing code has never been faster.
But here is the uncomfortable truth that most teams discover only after production incidents:

AI generates implementations. It cannot decide what your tests should verify.

That responsibility of knowing what to test, with what fidelity, and why belongs to engineers with platform knowledge and architectural judgment. Nowhere is this more visible than in integration testing.

This article is about a shift I see as non-negotiable for teams building reliable backend services in 2026.


The Deceptive Comfort of H2

For years, the default Spring Boot integration testing pattern looked like this.

Add H2 to the classpath, point your tests at an in-memory database, and tests run fast. No Docker. No external dependencies. Everything green.

The problem is that H2 is not your production database.

It does not enforce the same constraint semantics. It does not support all SQL constructs your migrations use. It does not run your schema through the same type coercion rules. And when your Flyway migrations grow in complexity, partial indexes, custom check constraints, advisory locks, partitioned tables, H2 quietly silences the errors that PostgreSQL would immediately surface.

You are not testing your system. You are testing a shadow of it.

I have seen services pass hundreds of H2-backed integration tests and then fail their first production deployment because a Flyway migration had a PostgreSQL-specific syntax that H2 accepted without complaint.


The Architectural Shift: Real Infrastructure, Every Run

The shift I advocate is straightforward in principle but meaningful in execution.

Instead of this:

Integration Test → H2 In-Memory → Schema (simplified)

Every integration test run connects to:

Integration Test → PostgreSQL Container (Testcontainers) → Real Flyway Migrations → Real Schema

Testcontainers starts a real PostgreSQL instance inside Docker for each test suite. Your Flyway migrations run exactly as they would in production. Hibernate validates the resulting schema. If any migration has a flaw, the test suite fails before a single test method executes.

This is not just better testing. It is a feedback loop that catches infrastructure regressions within seconds of committing code.


The Abstract Base Class Pattern

The architecture decision I consistently make in Spring Boot services is to centralize the Testcontainers setup in a single abstract base class that all integration tests extend.

AbstractContainerIT  (base)
       │
       ├── OrderContextIT        @Order(1)  — smoke: context loads, health endpoint
       ├── MerchantApiIT         @Order(2)  — merchant CRUD, pagination
       ├── TransactionFlowIT     @Order(3)  — auth + capture + settlement
       └── SettlementBatchIT     @Order(4)  — batch close, ordering-dependent
Enter fullscreen mode Exit fullscreen mode

One container. One Spring context. Shared across all test classes in the JVM.

The base class declares the container as public static final, meaning the PostgreSQL container starts once and is reused. This is the key performance decision. Without it, you pay the container startup cost for every test class — which on a large service can add minutes to your test suite.

public static final PostgreSQLContainer postgresContainer =
    new PostgreSQLContainer("postgres:17")
        .withDatabaseName("service_test")
        .withUsername("testuser")
        .withPassword("testpass")
        .withReuse(true);
Enter fullscreen mode Exit fullscreen mode

.withReuse(true) goes one step further — on a developer's local machine, Testcontainers will reuse the already-running container from a previous test run. The first run takes 3–5 seconds to start the container. Every subsequent run is instant. This matters enormously for developer experience.


@DynamicPropertySource: The Right Hook for Dynamic Configuration

A critical implementation decision is where you start the container and how you wire its dynamic properties, like JDBC URL, username, password into the Spring Environment.

The correct approach is @DynamicPropertySource:

@DynamicPropertySource
public static void configureProperties(DynamicPropertyRegistry registry) {
    postgresContainer.start();
    registry.add("spring.datasource.url", postgresContainer::getJdbcUrl);
    registry.add("spring.datasource.username", postgresContainer::getUsername);
    registry.add("spring.datasource.password", postgresContainer::getPassword);
}
Enter fullscreen mode Exit fullscreen mode

Starting the container here, inside the @DynamicPropertySource method, guarantees the container is running before Spring assembles its Environment. The properties Spring reads to configure the DataSource bean come directly from the live container.

Some implementations use a static {} block to start the container, which also works mechanically, but @DynamicPropertySource is a Spring Test first-class hook, integrates cleanly with context caching, and makes the intent explicit the container lifecycle and property contribution are co-located.

This is a small decision with architectural implications: when the codebase has 50 integration test classes, clarity about lifecycle order prevents subtle startup failures.


Test Profile Isolation

A pattern I enforce consistently: integration tests must not depend on the main application configuration to be runnable.

In practice, this means creating a dedicated test profile that overrides environment-specific settings. For services that are OAuth2 resource servers, this typically means overriding the JWT issuer configuration:

The main application configuration points to a live authorization server for OIDC discovery:

spring.security.oauth2.resourceserver.jwt.issuer-uri=http://auth-server:8080
Enter fullscreen mode Exit fullscreen mode

When the Spring Boot test context starts, it attempts to contact this URL to fetch /.well-known/openid-configuration. In CI and in local development when the auth server is not running this fails immediately and the entire test suite crashes before any test runs.

The fix is a test profile that switches from discovery to a static JWK set URI:

# application-test-postgres.yaml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: ~                        # disables OIDC discovery
          jwk-set-uri: http://auth-server:8080/oauth2/jwks
Enter fullscreen mode Exit fullscreen mode

The test context still configures OAuth2 correctly. JWT validation logic still works. But the context no longer depends on a live external service at startup time.

This is a non-obvious but essential step. A test suite that cannot start in an isolated environment is not a reliable test suite.


Dependency Hygiene: What You Include Shapes Your Schema

This is the insight that trips most teams, and it has become more important in the AI era because AI tooling tends to include dependencies liberally.

In Spring Boot 4 (and Spring Modulith), some dependencies register JPA entities automatically the moment they appear on the classpath. No configuration required. No opt-in.

spring-modulith-starter-jpa, for example, registers event_publication and event_publication_archive as JPA entities. If you run with spring.jpa.hibernate.ddl-auto=validate — which you should in integration tests — Hibernate will fail at startup with:

Schema validation: missing table [event_publication]
Enter fullscreen mode Exit fullscreen mode

This is entirely correct behavior. Hibernate is doing its job. But if your service has not yet implemented any async cross-module events (the reason you would include that dependency), including it prematurely causes every test run to fail until you either add the DDL or remove the dependency.

The architectural principle:

Include a dependency when you need its capability. Not before.

In a stateless JWT-authenticated API gateway, spring-session-jdbc has no place. It is designed for server-side session persistence. The exact opposite of stateless token authentication. Including it because a similar project had it is a maintenance liability.

These are not "pom.xml hygiene" decisions. They are architectural decisions about what your service does and does not need, with direct consequences for your schema, your startup time, and your test reliability.


Spring Boot 4: Test Starters Are Now Decomposed

This is a concrete migration point worth noting as teams move to Spring Boot 4.

In Spring Boot 3, spring-boot-starter-test included testing support for the full web stack. In Spring Boot 4, web MVC testing was extracted into a separate starter.

@AutoConfigureMockMvc moved to a new package:

// Spring Boot 3
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;

// Spring Boot 4
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
Enter fullscreen mode Exit fullscreen mode

And requires an additional dependency:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc-test</artifactId>
    <scope>test</scope>
</dependency>
Enter fullscreen mode Exit fullscreen mode

If you upgrade to Spring Boot 4 and see @AutoConfigureMockMvc unresolved in your IDE, this is the reason. It is not a bug, it is intentional decomposition that gives teams finer-grained control over test dependencies.


@SpringBootTest vs RANDOM_PORT — The Context Cache Question

A common question when setting up integration tests: should you use @SpringBootTest (default MOCK environment) or @SpringBootTest(webEnvironment = RANDOM_PORT)?

For REST API integration tests, the MOCK environment with MockMvc is almost always the better choice. Here is why.

Spring caches application contexts across test classes. If two test classes share the same context configuration — same properties, same beans, same profiles — they reuse the same context. This means Flyway runs once, the connection pool initializes once, and all subsequent test classes start in milliseconds.

RANDOM_PORT allocates a real HTTP port per test run. The variation in port introduces subtle context cache mismatches. You can end up paying the full context startup cost more often than necessary.

MockMvc performs full request dispatch through the DispatcherServlet, invoking all filters, interceptors, and exception handlers. For the vast majority of REST API tests, it provides identical verification coverage at lower cost.

Reserve RANDOM_PORT for tests that specifically require real HTTP semantics — for example, testing WebSocket upgrades, chunked transfer encoding, or HTTP/2 server push.


The First Integration Test: Smoke and Actuator

The first integration test I write for every service is a smoke test with exactly two assertions.

1. The Spring context loads successfully.
2. GET /actuator/health returns HTTP 200.
Enter fullscreen mode Exit fullscreen mode

This is deliberately minimal. Its purpose is not to test business logic. Its purpose is to verify that:

  • All Spring beans wire up without circular dependencies or missing configurations
  • Flyway migrations execute cleanly against the real PostgreSQL schema
  • Hibernate validates the schema successfully
  • The Actuator health endpoint responds — confirming the full HTTP pipeline is operational

If this test passes, the infrastructure is solid. Every subsequent integration test builds on that foundation.

When this test fails, the failure message immediately tells you which layer broke — bean creation, migration, schema validation, or HTTP. That specificity is what makes it valuable in CI.


What AI Changes — and What It Does Not

AI tools in 2026 can generate a complete integration test class, wire up Testcontainers, and scaffold the Spring Boot configuration in seconds. This is genuinely useful.

What AI cannot do is make the architectural decisions.

  • Should this service use a real database or H2 for tests? (Real database.)
  • Should the container start in a static block or @DynamicPropertySource? (The Spring-idiomatic hook.)
  • Should spring-session-jdbc be included in a stateless API gateway? (No.)
  • Will spring-modulith-starter-jpa fail your Hibernate validation before you have the DDL? (Yes.)
  • Does RANDOM_PORT break context caching in your specific test setup? (Depends on your configuration.)

These decisions require knowledge of the platform, the architecture, and the specific service's contract. They are not implementation details. They are engineering judgment.

In the AI era, the engineers who deliver reliably are not the ones who write the most code. They are the ones who ask the right questions about what to build, what to include, and what each piece does to the system.

Testing infrastructure is not ceremony. It is the foundation that tells you, every time you push code, whether what you built actually works.


Summary

Decision Recommendation
Database for integration tests Real PostgreSQL via Testcontainers
Container lifecycle Start inside @DynamicPropertySource
Container reuse public static final + .withReuse(true)
Test profile Dedicated profile; override all external service URLs
Web environment @SpringBootTest (MOCK) + MockMvc for REST APIs
Dependency hygiene Include only what the service currently needs
First test Smoke: context loads + /actuator/health returns 200
Spring Boot 4 Add spring-boot-starter-webmvc-test for @AutoConfigureMockMvc

Final Thoughts

The conversation in software engineering has shifted. Velocity is no longer the constraint. AI handles the implementation baseline quickly. What remains scarce is architectural clarity: knowing which tools belong in your system, what they do to your runtime behavior, and how to structure test infrastructure that provides real confidence rather than false reassurance.

Getting integration testing right is not a detail. It is the foundation that allows teams to move fast with high confidence, catch infrastructure regressions before production, and build systems that behave predictably under change.

Build the foundation correctly once. Everything built on top of it benefits permanently.


If this was useful, share it with a teammate who is wrestling with flaky integration tests or an H2-versus-real-database debate. The conversation is worth having early.

Top comments (0)