Singleton Containers and Shared Base Classes

With 20 integration test classes, each starting its own PostgreSQL container, you pay 20 container starts at 2 seconds each — 40 seconds of pure overhead before a single assertion runs. The singleton pattern starts one container per JVM, shares it across all test classes, and cuts that 40 seconds to 2. This is the most impactful performance optimization for large Testcontainers test suites.


What You’ll Learn

  • The singleton pattern using a static initializer block
  • Abstract base class design for sharing containers and @DynamicPropertySource
  • Why you must not combine @Testcontainers/@Container with the singleton pattern
  • Spring TestContext caching — how it works and how to maximize reuse
  • The @ImportTestcontainers approach for Spring Boot 3.1+
  • Data isolation strategies for shared containers

The Problem with Per-Class Container Lifecycle

When every test class uses static @Container:

@SpringBootTest
@Testcontainers
class OrderRepositoryTest {
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
    // starts when class loads, stops after class finishes
}

@SpringBootTest
@Testcontainers
class ProductRepositoryTest {
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
    // starts ANOTHER container — different instance
}

With 20 test classes, you start 20 containers. Each start takes 1–3 seconds. The same pattern applies to Kafka, Redis, and other containers.


The Singleton Pattern

The singleton pattern stores the container in a static field of an abstract base class, initializing it in a static initializer block. JVM class loading guarantees the initializer runs exactly once, the first time any test class that extends the base class is loaded.

// src/test/java/com/devopsmonk/orders/AbstractIntegrationTest.java
public abstract class AbstractIntegrationTest {

    static final PostgreSQLContainer<?> POSTGRES;

    static {
        POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("orders_test")
            .withUsername("test")
            .withPassword("test");
        POSTGRES.start();
    }

    @DynamicPropertySource
    static void configureDataSource(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
        registry.add("spring.datasource.username", POSTGRES::getUsername);
        registry.add("spring.datasource.password", POSTGRES::getPassword);
    }
}

Key points:

  • No @Testcontainers annotation — the JUnit extension is not involved
  • No @Container annotation — lifecycle is managed by the JVM, not JUnit
  • The container starts once in the static block, the first time any subclass loads
  • The container stops when the JVM exits — Ryuk handles cleanup

Test classes extend the base class:

@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);
    }
}
@SpringBootTest
class ProductRepositoryTest extends AbstractIntegrationTest {

    @Autowired
    private ProductRepository productRepository;

    @BeforeEach
    void cleanup() {
        productRepository.deleteAll();
    }

    @Test
    void shouldSaveProduct() {
        productRepository.save(new Product("SKU-001", "Widget", 100));
        assertThat(productRepository.count()).isEqualTo(1);
    }
}

Both test classes share the same PostgreSQL container. The container starts once, both test classes use it, and the container stops when the JVM exits.


Multiple Containers in the Base Class

public abstract class AbstractIntegrationTest {

    static final PostgreSQLContainer<?> POSTGRES;
    static final KafkaContainer KAFKA;
    static final GenericContainer<?> REDIS;

    static {
        POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("orders_test");

        KAFKA = new KafkaContainer(
            DockerImageName.parse("confluentinc/cp-kafka:7.6.1")
        );

        REDIS = new GenericContainer<>("redis:7-alpine")
            .withExposedPorts(6379)
            .waitingFor(
                Wait.forLogMessage(".*Ready to accept connections.*\\n", 1)
            );

        // Start all three in parallel
        Startables.deepStart(POSTGRES, KAFKA, REDIS).join();
    }

    @DynamicPropertySource
    static void configureInfrastructure(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);

        registry.add("spring.data.redis.host", REDIS::getHost);
        registry.add("spring.data.redis.port", () -> REDIS.getMappedPort(6379));
    }
}

Parallel startup via Startables.deepStart() further reduces total startup time to the time of the slowest container (usually Kafka, ~8 seconds) rather than the sequential sum (~11 seconds).


Spring TestContext Caching

Spring Boot’s test framework caches the ApplicationContext between test classes. When two test classes have identical context configuration, Spring reuses the same context.

For context caching to work with the singleton pattern:

  • The @DynamicPropertySource in the base class injects the container’s properties before context creation
  • Both test classes use the same properties (same container URL) → same context key → same cached context

Enable debug logging to see caching statistics:

# src/test/resources/application-test.yml
logging:
  level:
    org.springframework.test.context.cache: DEBUG

Output:

Spring TestContext Framework caches ApplicationContexts:
  - Size: 1
  - Hit count: 15
  - Miss count: 1

15 cache hits and 1 miss means 16 test classes shared one application context load.

Context Invalidation

Context caching breaks when test classes have different context configurations:

  • Different @TestPropertySource values
  • Different @MockBean declarations
  • Different @Import annotations
  • Different active profiles

Every unique context configuration creates a new context. Keep configurations consistent across test classes that share the same infrastructure.


The @ImportTestcontainers Approach (Spring Boot 3.1+)

Spring Boot 3.1 introduced an alternative to base class inheritance:

// Define containers in a configuration class
@TestConfiguration(proxyBeanMethods = false)
public class ContainersConfiguration {

    @Bean
    @ServiceConnection
    @RestartScope
    static PostgreSQLContainer<?> postgresContainer() {
        return new PostgreSQLContainer<>("postgres:16-alpine");
    }

    @Bean
    @ServiceConnection
    @RestartScope
    static KafkaContainer kafkaContainer() {
        return new KafkaContainer(
            DockerImageName.parse("confluentinc/cp-kafka:7.6.1")
        );
    }
}

Use it in test classes with @ImportTestcontainers:

@SpringBootTest
@ImportTestcontainers(ContainersConfiguration.class)
class OrderRepositoryTest {

    @Autowired
    private OrderRepository orderRepository;
}
@SpringBootTest
@ImportTestcontainers(ContainersConfiguration.class)
class ProductRepositoryTest {

    @Autowired
    private ProductRepository productRepository;
}

The @ImportTestcontainers approach has the same container-sharing behavior as the base class pattern but without inheritance. Spring manages container lifecycle as Spring beans. @RestartScope enables hot reload with Spring DevTools without restarting containers.


Data Isolation in Shared Containers

Shared containers mean shared state. If test A inserts rows and does not clean up, test B finds unexpected data.

Option 1: @BeforeEach Cleanup

@BeforeEach
void cleanup() {
    orderRepository.deleteAll();
    productRepository.deleteAll();
}

Simple and explicit. Works for most cases.

Option 2: @Transactional Rollback

@DataJpaTest wraps each test in a transaction that rolls back. You can get the same behavior in @SpringBootTest with @Transactional:

@SpringBootTest
@Transactional  // rolls back after each test
class OrderRepositoryTest extends AbstractIntegrationTest {
    // each test runs in a transaction that rolls back
}

Caution: @Transactional on @SpringBootTest rolls back the test’s transaction, but Kafka messages sent during the test are not rolled back. Use @BeforeEach cleanup for tests that mix database and Kafka.

Option 3: Separate Schemas

For tests that need complete isolation (for example, DDL changes), each test class can use a separate database schema:

public abstract class AbstractIntegrationTest {

    static final PostgreSQLContainer<?> POSTGRES;

    static {
        POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
        POSTGRES.start();
    }
}

@SpringBootTest
@TestPropertySource(properties = "spring.datasource.url=${POSTGRES_URL}/order_test_schema")
class OrderRepositoryTest extends AbstractIntegrationTest {
}

Common Pitfalls

Combining @Testcontainers/@Container with the singleton pattern. The JUnit extension tries to start a container that is already running. This causes IllegalStateException. The singleton pattern does not use either annotation.

Stopping the container in @AfterAll. If your base class or a test class calls POSTGRES.stop() in @AfterAll, it stops the container while other test classes still need it. Never explicitly stop singleton containers. Let Ryuk handle cleanup.

Not cleaning data between tests. The shared container retains data between test classes. Always clean in @BeforeEach.


Summary

The singleton pattern shares one container across all test classes, dramatically reducing integration test suite run time. Use an abstract base class with a static initializer block for the singleton container. Use Startables.deepStart() for parallel startup of multiple containers. Use @ImportTestcontainers in Spring Boot 3.1+ as an alternative to inheritance. Always clean data in @BeforeEach to maintain test isolation.

The next article covers container reuse — keeping containers alive between test suite runs for even faster feedback.

Next: Container Reuse for Fast Feedback Loops