What Testcontainers does in a Spring Boot test and who it is for
Testcontainers is a library that starts real infrastructure in Docker containers from your test code. When a Spring Boot integration test runs, it can bring up an actual PostgreSQL server, Kafka broker or Redis instance, point the application context at it, and tear it down when the run ends. Your repositories, migrations and message listeners then talk to the same software they will meet in production, not an in-memory stand-in such as H2 or an in-process broker.
Substitutes pass tests that production would fail. H2 in compatibility mode accepts much of PostgreSQL's syntax, but it is a different engine. A query using jsonb operators, ON CONFLICT upserts or a partial index may parse differently or not at all. A Flyway or Liquibase migration written for PostgreSQL may never run against the substitute, so a broken migration surfaces on deploy. Driver behavior also differs: timestamp and time zone mapping, transaction isolation defaults, and error codes your exception translation depends on. A real container moves these failures from the deploy pipeline to your laptop.
This tutorial is for backend and platform engineers who own data access layers, messaging code, caching or other infrastructure integration. If your bugs tend to appear where your service meets a database or broker, this applies to you.
Keep these tests focused on those boundaries: queries, mappings, migrations, serialization and consumer wiring. Pricing rules, validation and other business logic belong in plain unit tests that run in milliseconds without Docker.
Wiring containers with @ServiceConnection instead of @DynamicPropertySource
The traditional setup declares a static container field annotated with @Container, then adds a static method annotated with @DynamicPropertySource. Inside it, you register spring.datasource.url, spring.datasource.username and spring.datasource.password against the DynamicPropertyRegistry, each pointing at a getter on the container. This works, but every service adds more lines of property names. Each name is a string that can drift from the real configuration key, and the mapping from container to properties is copied into every test base class.
@ServiceConnection removes that mapping. Annotate the same static container field with it and delete the property method. Spring Boot inspects the container type and registers a ConnectionDetails bean, such as JdbcConnectionDetails for a PostgreSQL container. Auto-configuration then reads the JDBC URL, username, password and driver details from that bean. These values take precedence over matching spring.datasource.* properties, so a stray value in application.yml cannot quietly point tests at the wrong database.
The annotation covers only connection details. You still set connection pool sizing, spring.jpa.* options, Flyway or Liquibase locations, and application-specific properties in configuration as usual.
Detection depends on recognizing the service. If you use a generic container class or a custom image whose name Spring does not recognize, add a hint with the annotation's name attribute. For services that Spring Boot has no connection-details support for, keep @DynamicPropertySource for those properties. Both approaches can coexist in the same test class.
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@SpringBootTest
@Testcontainers
class OrderRepositoryIT {
// BEFORE: hand-written mapping, repeated in every test base class
// @DynamicPropertySource
// static void props(DynamicPropertyRegistry registry) {
// registry.add("spring.datasource.url", postgres::getJdbcUrl);
// registry.add("spring.datasource.username", postgres::getUsername);
// registry.add("spring.datasource.password", postgres::getPassword);
// }
// AFTER: Spring Boot sees a PostgreSQLContainer and registers
// JdbcConnectionDetails (URL, username, password) automatically.
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
// A GenericContainer has no service type to inspect, so give
// Spring a hint via 'name' to get Redis connection details.
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
// Pool sizing, spring.jpa.*, Flyway locations etc. still come from
// application.yml or test properties as usual.
@Test
void contextLoads() {
}
}
A test class that replaces the hand-written @DynamicPropertySource datasource mapping with @ServiceConnection on a PostgreSQL container, and uses the name attribute to wire a generic Redis container.
Where startup time goes: container lifecycle and the Spring test context cache
An integration test run pays for three things, each on a different schedule:
- Image pull. Paid once per machine or CI runner, then served from the local Docker image cache. On ephemeral CI runners it may recur on every build.
- Container start. Creating the container and waiting until the service accepts connections. A database container has to initialize before its wait strategy passes, so this step is often slower than people expect.
- Application context creation. Bean wiring, auto-configuration, schema migrations and connection pool startup.
When containers are declared as @Bean methods in a @TestConfiguration, they share the lifecycle of the Spring context that owns them. Spring's TestContext framework caches contexts by their merged configuration: configuration classes, active profiles, property sources, initializers and context customizers. If two test classes produce the same key, the second class reuses both the cached context and its containers.
OrderRepoTest ─┐
PaymentApiTest ─┼─► [ctx A] ──► postgres, kafka (started once)
InvoiceTest ─┘
RefundTest @MockitoBean ─► [ctx B] ──► postgres, kafka (started again)
LegacyTest @ActiveProfiles("x") ─► [ctx C] ──► postgres, kafka (again)
The extra contexts appear without any warning. Each of these changes alters the cache key:
- a different
@ActiveProfilesvalue - a class-level
propertiesattribute on@SpringBootTest - a
@DynamicPropertySourcemethod - a mock or spy bean override that only some test classes declare
Spring then builds a new context, and the container beans start again.
Illustrative estimate (assumed numbers, not a benchmark):
total ≈ pull + contexts × (container start + context start)
Assume pull = 0 s (cached), container start = 6 s, context start = 4 s:
- one shared context: 1 × (6 + 4) = 10 s
- three contexts: 3 × (6 + 4) = 30 s
To keep this cost down, standardize mock bean declarations in a shared base class or configuration so that test classes produce the same cache key.
TEST CLASSES SPRING TEST CONTEXT CACHE CONTAINERS
(key = merged config)
+----------------+
| OrderRepoTest |--+
+----------------+ | +-----------------------+ +------------+
+----------------+ | | ctx A | | postgres |
| PaymentApiTest |--+---->| base config, no mocks |--->| kafka |
+----------------+ | | (built once, reused) | | started 1x |
+----------------+ | +-----------------------+ +------------+
| InvoiceTest |--+
+----------------+
+----------------+ +-----------------------+ +------------+
| RefundTest |------->| ctx B |--->| postgres |
| @MockitoBean | | key differs: mock | | kafka |
+----------------+ | override | | started 2x |
+-----------------------+ +------------+
+----------------+ +-----------------------+ +------------+
| LegacyTest |------->| ctx C |--->| postgres |
| @ActiveProfiles| | key differs: profile | | kafka |
| ("x") | | "x" | | started 3x |
+----------------+ +-----------------------+ +------------+
Cost per context = container start + context start
Image pull is paid once per machine/runner, outside the cache
Test classes with identical merged configuration share one cached context and its containers; a mock override or a different profile creates a new context and restarts the containers.
Sharing containers across test classes and reusing them for local development
Pattern 1: a base class with static containers
Declare the containers as static fields on an abstract base class and start them in a static initializer, not with a per-class lifecycle annotation. That way they start once per JVM, and every subclass sees the same host and port. Testcontainers' cleanup container removes them when the JVM exits. The drawback is that inheritance is the only way to share them, and a class that needs a different base class cannot use them.
Pattern 2: a shared @TestConfiguration
Declare each container as a @Bean annotated with @ServiceConnection in one @TestConfiguration class, and pull it in with @Import on every test class. Spring then manages the container lifecycle along with the context. Because the imported configuration is part of the context cache key, using the same import everywhere means the cache returns one context with one set of containers. A test that imports a different combination gets its own context, and with it new containers.
The coupling risk
Shared containers mean shared state. If one test truncates a table, changes a sequence, or leaves messages on a topic, it can break an unrelated test that happens to run later. Give each test responsibility for its own data: use per-test transactions that roll back, unique keys, or explicit cleanup. Parallel execution makes this worse because writes interleave. Isolate by schema or tenant, or keep parallel tests on separate data.
Running the app locally
Add a main method under the test sources that calls SpringApplication.from(Application::main).with(TestcontainersConfiguration.class).run(args). This starts the real application against the same container beans the tests use, so the team does not maintain a separate docker-compose file for those services.
// src/test/java/com/example/TestcontainersConfiguration.java
// One shared config. Import this exact class everywhere so the context cache key matches.
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {
@Bean
@ServiceConnection // Boot derives spring.datasource.* from the running container
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"));
}
}
// src/test/java/com/example/OrderServiceTests.java
// Same @Import as other tests, so the cached context and its container are reused.
@SpringBootTest
@Import(TestcontainersConfiguration.class)
@Transactional // each test rolls back its own writes, so the shared database is left unchanged
class OrderServiceTests {
// tests...
}
// src/test/java/com/example/TestApplication.java
// Runs the real app against the same container beans, with no separate docker-compose file.
public class TestApplication {
public static void main(String[] args) {
SpringApplication.from(Application::main)
.with(TestcontainersConfiguration.class)
.run(args);
}
}
A shared @TestConfiguration declares a @ServiceConnection PostgreSQL container that every test imports the same way, so the cached context is reused. A test-scoped main method runs the application against that same container for local development.
Enabling reusable containers safely
Reuse needs two opt-ins, and Testcontainers ignores it unless both are present. The first is per developer. Add testcontainers.reuse.enable=true to .testcontainers.properties in your home directory. The second is in code: call withReuse(true) on the container definition. The property lives outside the repository on purpose. A CI agent that never sets it gets normal disposable containers, even though the code asks for reuse.
Testcontainers decides whether to reuse a container by hashing the container's configuration and looking for a running container with the same hash. Any change produces a new hash and therefore a new container, and the old one keeps running. That includes the image tag, environment variables, exposed ports, and init scripts.
Testcontainers' automatic cleanup does not apply to reused containers. They stay up and keep using memory and CPU until you remove them. Testcontainers labels the containers it creates, so you can list them with docker ps --filter "label=org.testcontainers" and remove stale ones with docker rm -f <container-id>.
Because state now survives between runs, tests must not assume a clean slate:
- Idempotent migrations. Flyway or Liquibase will find their history table already populated. Hand-written setup SQL should use
IF NOT EXISTS. - Truncate per test. Clear the tables a test touches before it runs, not after. A crashed run never reaches its cleanup.
- Unique names. Suffix Kafka topics, queue names, and cache keys with a random value so leftover messages from an earlier run are not consumed.
- Never assume an empty database. Assert on the rows the test created, not on total counts.
# Opt-in 1 (per developer, outside the repo): enable reuse in the
# Testcontainers properties file in your home directory.
# CI agents never run this, so they keep getting disposable containers.
grep -q '^testcontainers.reuse.enable=true' ~/.testcontainers.properties 2>/dev/null \
|| echo 'testcontainers.reuse.enable=true' >> ~/.testcontainers.properties
# Opt-in 2 lives in code: .withReuse(true) on the container definition.
# Without both, Testcontainers ignores reuse.
# List every container Testcontainers started. The hash column shows the
# configuration hash used for reuse matching. Two containers for the same
# image with different hashes usually means the config changed and the
# old one was left behind.
docker ps --filter "label=org.testcontainers" \
--format 'table {{.ID}}\t{{.Image}}\t{{.RunningFor}}\t{{.Label "org.testcontainers.hash"}}'
# See what the leftovers are costing you in memory and CPU right now.
ids=$(docker ps -q --filter "label=org.testcontainers")
[ -n "$ids" ] && docker stats --no-stream $ids
# Remove one stale container by ID (taken from the listing above).
docker rm -f 3f2a9c1b7d4e
# Or remove all of them, for example before switching branches.
# Do not run this while a test run is in progress, because it also
# removes the containers that run is using.
ids=$(docker ps -aq --filter "label=org.testcontainers")
[ -n "$ids" ] && docker rm -f $ids
Shell steps that set the per-developer reuse property, then list the containers Testcontainers created along with their reuse hash and resource use, and remove stale ones that automatic cleanup will not remove.
When to avoid reuse, CI guidance, and the alternatives
Reuse pays off on a developer machine, where the same containers survive dozens of test runs in a day. In CI it does not. A fresh runner has no container to reuse, so you get no speedup. On persistent self-hosted agents, reused containers carry state between unrelated builds, and the cleanup sidecar does not remove them. Leave reuse disabled in CI and rely on two things that work everywhere: the Spring test context cache, which avoids rebuilding the application context, and shared static containers, which start once per JVM.
Pin image tags explicitly, such as a specific Postgres minor version, rather than latest. If you leave tags floating, a laptop with a cached image and a CI runner that pulled a newer one will test against different databases, and failures will appear on only one of them.
Some CI environments restrict Docker. They might block socket access, disallow privileged containers, or run builds inside containers without a Docker daemon. Confirm Docker access before you commit to Testcontainers. If it is unavailable, you can use a remote Docker host or a separate test stage with a compatible runner.
| Approach | Realism | Speed | Isolation | Cleanup | CI suitability |
|---|---|---|---|---|---|
| In-memory DB / embedded broker | Low: dialect and behaviour differ | Fastest | High | Automatic | Good, no Docker needed |
| docker-compose outside tests | High | Fast after startup | Low: shared state | Manual | Fair: extra orchestration step |
| Per-class containers | High | Slowest | Highest | Automatic | Good but costly |
| Shared static containers | High | Fast | Medium: needs data hygiene | Automatic at JVM exit | Best default |
| Reusable containers | High | Fastest locally | Low | Manual | Avoid |
Impact on development cost and engineering time
Testcontainers saves engineering time in three ways:
- Fewer production-only bugs. Tests run against the real database engine, so SQL dialect differences, migration ordering problems, and constraint violations show up in the build. An in-memory substitute would let them through to production.
- One source of truth. You don't need a separate compose file for tests, so there is nothing to keep in sync with the test configuration.
- Shorter feedback loops. The Spring context cache combined with shared or reused containers means most test classes skip startup entirely.
It also adds cost:
- Every developer machine and CI runner needs a working Docker daemon.
- Image pulls on a cold cache are slow.
- The suite will always be slower than unit tests.
- Shared state between tests can leak and cause failures that are hard to trace.
- Reuse is normally off in CI. Teams that assume it is on end up investigating slow builds in the wrong place.
Illustrative estimate (assumed numbers, not a benchmark). Assume 40 test classes, 2 services (PostgreSQL and a message broker), 1 JVM fork, and 10 local runs per day.
| Configuration | Formula | Startups per developer per day |
|---|---|---|
| Per-class containers | classes × services × runs | 40 × 2 × 10 = 800 |
| Shared static containers | services × forks × runs | 2 × 1 × 10 = 20 |
| Reusable containers (local) | services × (container recreations) | 2 × 1 = 2 |
To estimate time lost to startup, multiply the startup count by your measured average startup time. In CI, use the shared-static row unless you have deliberately enabled reuse there. If you run several forks, multiply by the fork count.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to spring boot testcontainers, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.