Testcontainers & Real Dependencies
Testcontainers & Real Dependencies
Every testing layer you have used so far trades accuracy for speed. @DataJpaTest runs against H2, @MockBean replaces entire services, and @WebMvcTest never touches the database. Those trade-offs are often correct — but sometimes they hide real bugs. A query that works on H2's SQL dialect fails silently on PostgreSQL 16. A Flyway migration incompatible with your production schema never gets exercised. An index that matters for performance is absent because the in-memory engine ignores it.
Testcontainers closes that gap by spinning up real, throwaway Docker containers — PostgreSQL, MySQL, Redis, Kafka, or anything else — directly inside your JUnit 5 test run. Your code connects to the same engine it will face in production, yet the container lifecycle is managed automatically: it starts before the first test and is destroyed after the last.
Why Not Just Use H2?
H2 is fast and requires zero infrastructure, which makes it ideal for unit-level persistence tests. But its compatibility mode is incomplete. Common failure modes when switching from H2 to a real engine include:
- PostgreSQL-specific functions (
gen_random_uuid(), JSON operators, window functions) that H2 does not implement. - Case-sensitive identifiers — PostgreSQL folds unquoted identifiers to lowercase; H2 folds to uppercase.
- Strict foreign-key enforcement timing differences.
- Flyway or Liquibase migrations that include database-specific DDL rejected by H2.
- Hibernate 6 dialect differences — the same JPQL compiles to different SQL on different dialects, and the real dialect may hit query planner issues invisible in H2.
Adding the Dependency
Testcontainers publishes a Spring Boot integration module that wires everything together automatically. Add it to pom.xml (test scope only):
Spring Boot manages the Testcontainers BOM version through its own dependency management, so you do not need to specify versions explicitly when using the Spring Boot parent POM.
The ServiceConnection Approach (Spring Boot 3.1+)
The cleanest way to integrate Testcontainers with Spring Boot 3.1 or later is the @ServiceConnection annotation. You declare a container bean in a test configuration class, annotate it with @ServiceConnection, and Spring Boot automatically overrides spring.datasource.* (or the relevant property set for other services) to point at the running container. No manual property overrides, no DynamicPropertySource boilerplate.
Declaring the container static is deliberate. A static container is started once for all tests in the class and reused across test methods, which is far cheaper than creating a fresh container per test.
A Complete Integration Test
Here is a realistic example. The application has an Order entity, an OrderRepository extending JpaRepository, and a Flyway migration that creates the schema. We want to verify that a save-and-reload round trip through the real PostgreSQL engine works correctly, and that the Flyway migration is compatible.
(1) @AutoConfigureTestDatabase(replace = NONE) tells @DataJpaTest not to replace the configured DataSource with its own embedded one. Without this, Spring Boot would still swap in H2 even though you declared a container.
replace = NONE. It is the single most common mistake when combining @DataJpaTest with Testcontainers. If you omit it, your tests pass against H2 while silently ignoring the PostgreSQL container you just started.
Container Reuse Across Test Classes
Starting a PostgreSQL container takes roughly 2–4 seconds — acceptable for one class but painful if you have 20 test classes each spinning up their own container. The standard pattern is a shared base class that holds the container as a static field, which JUnit 5 + Testcontainers keeps alive for the duration of the JVM process:
Because POSTGRES is a static final field on the parent class, all subclasses share the same container instance. The @Testcontainers extension on the parent manages its lifecycle.
postgres:16-alpine rather than postgres:latest. Using latest means a Docker Hub update can silently change the engine your tests run against between CI runs, causing flaky failures that are very hard to diagnose.
Performance Trade-offs
Testcontainers tests are slower than pure unit tests or H2-backed @DataJpaTest tests. Here is a realistic comparison for a medium-sized module:
- Unit test (Mockito, no Spring context): ~5–50 ms per class.
- @DataJpaTest with H2: ~1–3 s to load the slice context.
- @DataJpaTest with Testcontainers (shared container): ~3–6 s for first class, then near-zero overhead for subsequent classes sharing the same container.
- @SpringBootTest with Testcontainers (full context): ~8–15 s for first class.
These numbers make clear that container reuse (the shared base class pattern above) and Spring context caching are both essential. Spring caches the application context across tests that use identical configuration, so the context is loaded once and reused, not recreated per test class.
What Else Can Testcontainers Run?
Testcontainers is not limited to relational databases. The same pattern works for any service that has a Docker image:
KafkaContainer— test event-driven code against a real broker.GenericContainer("redis:7-alpine")— integration-test your caching layer.LocalStackContainer— AWS services (S3, SQS, SNS) locally.MongoDBContainer— test MongoDB repositories without Atlas.
The @ServiceConnection annotation supports many of these out of the box; for others you fall back to the older @DynamicPropertySource mechanism.
Summary
Testcontainers fills the gap between fast-but-inaccurate in-memory tests and slow-but-accurate tests against shared external infrastructure. The key steps are: add the three Maven dependencies, declare a static @Container @ServiceConnection field, add @AutoConfigureTestDatabase(replace = NONE) when using @DataJpaTest, and extract a shared base class to avoid restarting the container for every test class. The result is a test suite that exercises real SQL, real migrations, and the real Hibernate dialect — catching a whole class of bugs that H2 silently masks.