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
staticinitializer block - Abstract base class design for sharing containers and
@DynamicPropertySource - Why you must not combine
@Testcontainers/@Containerwith the singleton pattern - Spring TestContext caching — how it works and how to maximize reuse
- The
@ImportTestcontainersapproach 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
@Testcontainersannotation — the JUnit extension is not involved - No
@Containerannotation — lifecycle is managed by the JVM, not JUnit - The container starts once in the
staticblock, 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
@DynamicPropertySourcein 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
@TestPropertySourcevalues - Different
@MockBeandeclarations - Different
@Importannotations - 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.