Testcontainers + Spring Boot 4: The Integration Test Setup Teams Actually Use
Not a getting-started guide. The opinionated patterns teams converge on once the novelty wears off and the suite has to run in CI a hundred times a day.
Every Testcontainers tutorial teaches the same first step, annotate a field with @Container, drop in @Testcontainers, watch a database spin up. That setup works for one test class. It falls apart at the tenth, when your suite is spending more time starting Postgres than running assertions. This is the setup that teams actually land on once that happens, updated for Spring Boot 4 and Spring Framework 7, which changed more about test startup than most migration guides mention.
Pattern One: Singleton Containers, Not @Container Lifecycle
The default JUnit 5 extension, @Testcontainers plus @Container, starts a container before a test class and stops it after. That’s fine once. It’s expensive across twenty test classes, and Testcontainers’ own documentation is explicit that this extension has only been tested against sequential execution, parallel use is unsupported and can have unintended side effects. The pattern teams actually converge on skips that extension entirely for shared infrastructure, and starts the container exactly once, in a static initializer on a base class every integration test extends.
abstract class AbstractIntegrationTest {
@ServiceConnection
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:17-alpine");
static {
POSTGRES.start();
}
}
class OrderServiceTest extends AbstractIntegrationTest {
// reuses the same running container, no @Testcontainers, no @Container
}
The mistake everyone makes once: combining this pattern with @Testcontainers and @Container on the same base class. The extension stops the container after the first test class finishes, so every subsequent class inherits a base class pointing at a dead container. Spring’s test context caching keeps the old context around, and you get connection failures that look like flakiness but are actually a lifecycle bug in the setup itself. The fix is to leave those two annotations off entirely for anything meant to be shared.

Pattern Two: @ServiceConnection Instead of @DynamicPropertySource
Before @ServiceConnection existed, wiring a container’s connection details into the Spring context meant a @DynamicPropertySource method, manually reading the container’s JDBC URL, username, and password, and pushing each one into a property key by hand. @ServiceConnection replaces all of that with one annotation on the container field itself. Spring Boot detects the container type, or the Docker image name for a generic container, and automatically creates the matching ConnectionDetails bean, no property keys to get wrong.
| Old approach | @ServiceConnection |
|---|---|
Manual @DynamicPropertySource method per container | One annotation on the container field or bean |
| Property keys typed out by hand, easy to typo | Connection details generated automatically from the container type |
| Breaks silently if a property key changes upstream | Tracks the auto-configuration directly, no property keys involved |
| Works for whatever you wire up manually | Built-in support for Postgres, MySQL, MongoDB, Redis, Kafka, RabbitMQ, and a growing list of others in Spring Boot 4 |
Spring Boot 4 keeps extending that built-in list, MongoDB’s Atlas local container support shipped as a @ServiceConnection target in this release specifically. The annotation isn’t tied to the @Testcontainers extension either, it works just as well on a manually started static field, which is exactly what makes it compatible with the singleton pattern above.
Pattern Three: Reuse Is Not a Substitute for Singleton
Testcontainers ships a separate feature called container reuse, .withReuse(true) plus testcontainers.reuse.enable=true in a local ~/.testcontainers.properties file, which keeps a container alive after the JVM exits so the next test run can find and reuse it instead of starting fresh. It’s genuinely useful, and it’s also frequently confused with the singleton pattern, to the point that Testcontainers’ own documentation states directly that reuse does not replace a proper shared instance for improving test performance.
The distinction matters because the two solve different problems. The singleton pattern eliminates redundant startups within a single test runacross separate test runs, useful on a developer’s laptop running the same suite repeatedly during TDD, but explicitly unsuited for CI, where you generally want a clean container per run rather than one that’s been accumulating state from every previous build. Teams that reach for reuse as their primary speedup mechanism in CI are optimizing for the wrong axis.
Where each mechanism actually belongs
- Singleton base class: always, it’s what makes a multi-class suite fast in both CI and local runs.
- Container reuse: local development only, layered on top of the singleton pattern, never relied on as the sole speedup in a pipeline that spins up a fresh environment each run.
- @ServiceConnection: always, it’s strictly less code than manual property wiring with no real downside.
Pattern Four: Parallel Isolation, Told Honestly
Running integration tests in parallel is where most of the advice online quietly stops being precise. Yes, JUnit 5 supports parallel execution through junit.jupiter.execution.parallel.enabled=true. Yes, a shared singleton container works fine with it, in the sense that it will still be running and reachable from multiple threads. What doesn’t automatically follow is data isolation, a shared Postgres container doesn’t know or care that two test classes are hitting the same tables at the same time, and Testcontainers’ own JUnit 5 extension documentation, again, only claims to have been validated for sequential runs.
The pattern that actually holds up in practice separates two concerns that tutorials tend to blur together: container-level concurrency, which the singleton pattern already handles by not relying on the untested extension lifecycle, and data-level concurrency, which is entirely your application’s responsibility. A configuration teams reach for often is parallelizing across classes while keeping methods within a class sequential:
# junit-platform.properties junit.jupiter.execution.parallel.enabled=true junit.jupiter.execution.parallel.mode.default=same_thread junit.jupiter.execution.parallel.mode.classes.default=concurrent
That configuration alone doesn’t stop two classes from stepping on the same row. The actual isolation still has to come from the test data itself, cleaning tables in a @BeforeEach, wrapping each test in a transaction that rolls back, or giving each test class its own schema or keyspace inside the shared container. None of that is automatic, and any article that implies parallel execution plus a shared container is isolation by itself is skipping the part that actually takes engineering effort.
What Spring Boot 4’s AOT Changes Do to Test Startup
This is the part of the migration most teams underestimate. Spring Boot 4 and Spring Framework 7 lean much further into Ahead-of-Time processing than Spring Boot 3 did, and it changes the cost profile of every @SpringBootTest that loads a full application context.

Two changes do most of the work. Spring Data AOT turns repository query methods into generated source code at build time instead of parsing them via reflection when the context starts, which is where the widely cited 50 to 70 percent faster startup figures for Spring Boot 4 actually come from. Separately, Spring Boot 4’s autoconfiguration modules were broken apart into smaller, more focused jars, which means less classpath scanning during context startup regardless of how many repositories your test actually touches. Combined with BeanRegistrar, a reflection-free way to register beans that the AOT compiler can process at build time, the net effect for a heavy @SpringBootTest suite is a noticeably shorter wait before your containers and your context are both actually ready.
The gotcha teams hit during migration: Spring Boot 4.1 removed the ability to skip AOT processing of tests with Maven’s -DskipTests flag. The Spring Boot Maven plugin now only reacts to maven.test.skip, so a CI step that used to skip test compilation with -DskipTests can silently behave differently after upgrading, worth checking explicitly rather than discovering it mid-pipeline.
What We Learned
The Testcontainers setup teams actually run once a suite matures looks different from the getting-started tutorial in four specific ways. Containers are started once, in a static initializer on a base class, deliberately bypassing the @Testcontainers/@Container lifecycle that Testcontainers itself only claims to have validated for sequential execution. @ServiceConnection replaces manual property wiring outright, with no real downside, and it works fine on that same manually managed static field.
Container reuse is a genuinely useful local-development optimization that Testcontainers’ own documentation is explicit is not a substitute for the singleton pattern, and treating it as your CI speedup strategy is a category error. Parallel execution is safe at the container level once you’ve adopted the singleton pattern, but data isolation between concurrently running tests is still entirely on you, no configuration flag does that part for you. And Spring Boot 4’s AOT investment, Spring Data AOT repositories and modularized autoconfiguration in particular, genuinely shortens the time between “container is up” and “context is ready,” with one real migration gotcha around Maven’s test-skipping flag worth checking before you assume your pipeline still behaves the way it used to.




