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.properties global 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.

@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:

ScenarioFirst runSubsequent runs (cold)With reuse
No singleton45s (15 starts × 3s)45s
Singleton only12s (1 start each)12s
Singleton + reuse12s12s1–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 @BeforeEach cleanup
  • 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.enable unset 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.

Next: Parallel Test Execution with Testcontainers