Container Reuse for Fast Feedback Loops
The singleton pattern shares a container within a single test run. Container reuse goes further — it keeps the container alive after the JVM exits and reuses it in the next test run. The first run pays the startup cost (8 seconds for Kafka). Every subsequent run skips it entirely. For a developer who runs the test suite dozens of times per day, this saves minutes of waiting.
What You’ll Learn
- Enabling container reuse with
.withReuse(true) - The
~/.testcontainers.propertiesglobal configuration - How Testcontainers decides when to reuse vs restart a container
- Data cleanup strategies for reused containers
- When reuse is safe (local dev) and when it is not (CI)
- Measuring the performance impact
Enabling Container Reuse
Container reuse requires two changes: enabling it on the container definition, and enabling it globally in ~/.testcontainers.properties.
Step 1: Container Configuration
public abstract class AbstractIntegrationTest {
static final PostgreSQLContainer<?> POSTGRES;
static final KafkaContainer KAFKA;
static {
POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("orders_test")
.withUsername("test")
.withPassword("test")
.withReuse(true); // enable reuse
KAFKA = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.1")
).withReuse(true); // enable reuse
Startables.deepStart(POSTGRES, KAFKA).join();
}
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("spring.kafka.bootstrap-servers", KAFKA::getBootstrapServers);
}
}
Step 2: Global Enable
Create or edit ~/.testcontainers.properties:
testcontainers.reuse.enable=true
Both conditions must be true for reuse to take effect. The global property acts as a safety switch — developers can opt in, CI environments stay clean by default.
How Container Matching Works
When .withReuse(true) is set and the global property is enabled, Testcontainers computes a fingerprint of the container configuration:
- Docker image and tag
- Environment variables
- Exposed ports
- Volume bindings
- Container command
- Labels
If a running container matches this fingerprint exactly, it is reused. If no match is found, a new container is started.
First run:
1. No existing container matches fingerprint
2. Start new PostgreSQL container
3. Container stays alive (no Ryuk cleanup)
4. Test run completes
Second run:
1. Existing container matches fingerprint (same image, same config)
2. Skip startup — connect to the running container
3. Tests run immediately
If you change the PostgreSQL version from postgres:16-alpine to postgres:16.2-alpine, the fingerprint changes and a new container is started. The old container is left running (it does not match any current fingerprint).
Data Cleanup with Reused Containers
Reused containers retain data between runs. Without cleanup, test 1 inserts a row, test 2 finds an unexpected row, test 2 fails.
Strategy 1: @BeforeEach Cleanup (Recommended)
@SpringBootTest
class OrderRepositoryTest extends AbstractIntegrationTest {
@Autowired
private OrderRepository orderRepository;
@BeforeEach
void cleanup() {
orderRepository.deleteAll();
}
@Test
void shouldSaveOrder() {
orderRepository.save(new Order("c1", OrderStatus.PENDING, BigDecimal.valueOf(50.00)));
assertThat(orderRepository.count()).isEqualTo(1);
}
}
@BeforeEach cleanup works regardless of whether the container is reused. Clean before each test, not after — a test that fails mid-cleanup leaves the database dirty.
Strategy 2: @Transactional Rollback
@SpringBootTest
@Transactional
class OrderRepositoryTest extends AbstractIntegrationTest {
// each test rolls back — no permanent data in shared DB
}
Transaction rollback is clean and does not depend on deleteAll(). It works correctly with reused containers.
Strategy 3: Flyway Clean on Test Start
For a completely fresh schema on each run (slower but thorough):
@BeforeAll
static void cleanDatabase(@Autowired Flyway flyway) {
flyway.clean();
flyway.migrate();
}
Add spring.flyway.clean-disabled=false in test properties. This drops all tables and re-runs all migrations. It is thorough but adds several seconds per test class.
Kafka Data Cleanup with Reuse
Kafka retains messages based on retention policy (default: 7 days). Reused Kafka containers accumulate messages. If your consumers use auto.offset.reset=earliest, they may replay old messages from previous test runs.
Use a unique consumer group ID per test run to avoid consuming messages from old runs:
@DynamicPropertySource
static void configureKafka(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers", KAFKA::getBootstrapServers);
// Use unique consumer group per test class to avoid offset contamination
registry.add("spring.kafka.consumer.group-id",
() -> "test-group-" + UUID.randomUUID());
}
Or delete and recreate topics before tests:
@BeforeAll
static void resetKafkaTopics(@Autowired KafkaAdmin kafkaAdmin) throws Exception {
AdminClient admin = AdminClient.create(kafkaAdmin.getConfigurationProperties());
admin.deleteTopics(List.of("order-events")).all().get();
admin.createTopics(List.of(new NewTopic("order-events", 1, (short) 1))).all().get();
}
Performance Impact
Typical results for a Spring Boot project with PostgreSQL, Kafka, and Redis:
| Scenario | First run | Subsequent runs (cold) | With reuse |
|---|---|---|---|
| No singleton | 45s (15 starts × 3s) | 45s | — |
| Singleton only | 12s (1 start each) | 12s | — |
| Singleton + reuse | 12s | 12s | 1–2s |
Container reuse reduces subsequent runs from 12 seconds to 1–2 seconds. For a developer running tests 30 times per day, that is 5–10 minutes of time saved daily.
When to Use Reuse
Appropriate for:
- Local developer machines where test isolation between runs is managed with
@BeforeEachcleanup - Projects with slow-starting containers (Keycloak, Elasticsearch) that significantly impact developer feedback time
./mvnw test -pl .style incremental testing during development
Not appropriate for:
- CI environments (each CI run should start fresh — leave
testcontainers.reuse.enableunset in CI) - Tests that make incompatible schema changes between runs
- Multi-developer shared CI pipelines where container state is unpredictable
The simplest CI rule: do not create ~/.testcontainers.properties in the CI environment. The global property defaults to disabled, so reuse is automatically off in CI.
Cleaning Up Orphaned Reused Containers
Over time, old reused containers accumulate when you change container configurations. Clean them up:
# List all containers with testcontainers labels
docker ps -a --filter "label=org.testcontainers.reuse=true"
# Remove all stopped testcontainers
docker rm $(docker ps -a -q --filter "label=org.testcontainers=true" --filter "status=exited")
# Nuclear option: remove all stopped containers
docker container prune -f
Project-Level vs Global Configuration
The global ~/.testcontainers.properties applies to all projects on the machine. For per-project configuration:
// In your base class, check an environment variable
static {
boolean reuse = Boolean.parseBoolean(
System.getenv().getOrDefault("TESTCONTAINERS_REUSE", "true")
);
POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(reuse);
POSTGRES.start();
}
Set TESTCONTAINERS_REUSE=false in CI to disable reuse.
Common Pitfalls
Forgetting the global property. .withReuse(true) alone does not enable reuse. You also need testcontainers.reuse.enable=true in ~/.testcontainers.properties. If reuse is not working, check the global property first.
State leakage from no cleanup. Reused containers retain data. Tests that pass on a clean container fail on a reused one because they find unexpected existing data. Always add @BeforeEach cleanup.
Reuse in CI breaking test isolation. If your CI agents are long-lived (not ephemeral Docker containers), reuse may be active across builds. Explicitly disable reuse in CI environments.
Summary
Container reuse eliminates startup overhead on repeated test runs by keeping containers alive between JVM invocations. Enable it with .withReuse(true) and the global testcontainers.reuse.enable=true property. Use @BeforeEach data cleanup to maintain test isolation. Disable reuse in CI environments where fresh starts matter.
The next article covers parallel test execution — running tests concurrently with Testcontainers and maintaining data isolation between parallel tests.