JUnit 5 Integration — Lifecycle and Annotations
The biggest performance factor in a Testcontainers test suite is how many times you start and stop containers. Start a PostgreSQL container once per JVM and your tests add 2 seconds to total CI time. Start it once per test method and 50 tests cost 100 seconds. Understanding Testcontainers lifecycle management is how you write fast tests without sacrificing isolation.
This article covers all the container lifecycle options in JUnit 5 — from the simplest @Container annotation to the singleton pattern that shares one container across your entire test suite.
What You’ll Learn
- How
@Testcontainersand@Containerwork at the JUnit 5 extension level - The difference between
staticand instance-level@Containerfields - How to use
@BeforeAllfor manual lifecycle control - The singleton pattern for sharing containers across multiple test classes
- How Spring TestContext caching interacts with container lifecycle
- Parallel container startup with
Startables.deepStart()
How the JUnit 5 Extension Works
@Testcontainers registers a JUnit 5 extension: TestcontainersExtension. This extension hooks into the JUnit 5 lifecycle and manages containers annotated with @Container.
When a test class annotated with @Testcontainers is loaded:
- JUnit 5 discovers all
@Containerannotated fields - Before the test class runs,
TestcontainersExtensionstarts containers onstaticfields - Before each test method, it starts containers on instance fields
- After each test method, instance containers are stopped
- After the test class finishes, static containers are stopped
Test Class Lifecycle (with @Testcontainers)
────────────────────────────────────────────
@BeforeAll ←── static @Container starts here
│
│ Test Method 1
│ @BeforeEach ←── instance @Container starts here (if any)
│ test body
│ @AfterEach ←── instance @Container stops here
│
│ Test Method 2
│ @BeforeEach
│ test body
│ @AfterEach
│
@AfterAll ←── static @Container stops here
Static vs Instance Containers
The most important decision in Testcontainers lifecycle management is whether to declare your container as static or non-static.
Static Container (Class-Level — Recommended)
@SpringBootTest
@Testcontainers
class OrderRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Test
void test1() { /* runs against the same postgres instance */ }
@Test
void test2() { /* runs against the same postgres instance */ }
@Test
void test3() { /* runs against the same postgres instance */ }
}
One container starts before all tests in the class. All tests share it. One container stops after all tests finish. The total container overhead for this class is one start + one stop.
When to use: Almost always. The vast majority of integration tests benefit from a shared container within the test class.
Instance Container (Method-Level)
@Testcontainers
class OrderRepositoryTest {
@Container
PostgreSQLContainer<?> postgres = // NOT static
new PostgreSQLContainer<>("postgres:16-alpine");
@Test
void test1() { /* fresh postgres */ } // container starts before test1, stops after
@Test
void test2() { /* fresh postgres */ } // NEW container for test2
}
A new container starts before every test method and stops after. Each test gets a clean database with no state from previous tests.
When to use: When tests genuinely cannot share a container because they make incompatible persistent changes (like DDL changes). In practice, this is rare. @BeforeEach cleanup achieves the same isolation without the container overhead. Prefer @BeforeEach cleanup over instance containers for data isolation.
Manual Lifecycle with @BeforeAll
You can manage container lifecycle yourself using @BeforeAll and @AfterAll. This gives you the same result as static @Container, with more explicit control:
@SpringBootTest
class OrderRepositoryTest {
static PostgreSQLContainer<?> postgres;
@BeforeAll
static void startContainers() {
postgres = new PostgreSQLContainer<>("postgres:16-alpine");
postgres.start();
}
@AfterAll
static void stopContainers() {
postgres.stop();
}
@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);
}
}
This approach is useful when you need to configure the container before it starts — adding environment variables, copying files, configuring networks. It does not require the @Testcontainers annotation.
The Singleton Pattern
The static and instance approaches both scope the container to a test class. When you have multiple test classes, each class starts and stops its own container. With 10 test classes, you pay 10 container starts.
The singleton pattern shares one container instance across all test classes that extend a base class, for the lifetime of the test JVM.
// 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 configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
}
}
Note: no @Testcontainers or @Container annotations. The container is started in a static initializer block. JVM class loading guarantees this runs once, the first time any class that extends AbstractIntegrationTest is loaded. Subsequent test classes use the same container instance.
Test classes extend the base class:
@SpringBootTest
class OrderRepositoryTest extends AbstractIntegrationTest {
@Autowired
private OrderRepository orderRepository;
@BeforeEach
void cleanup() {
orderRepository.deleteAll();
}
@Test
void shouldFindOrdersByStatus() {
orderRepository.save(new Order("customer-1", OrderStatus.PENDING, BigDecimal.valueOf(50.00)));
orderRepository.save(new Order("customer-2", OrderStatus.CONFIRMED, BigDecimal.valueOf(75.00)));
List<Order> pendingOrders = orderRepository.findByStatus(OrderStatus.PENDING);
assertThat(pendingOrders).hasSize(1);
}
}
@SpringBootTest
class OrderServiceTest extends AbstractIntegrationTest {
@Autowired
private OrderService orderService;
@Test
void shouldCreateOrderAndPersist() {
CreateOrderRequest request = new CreateOrderRequest("customer-1", BigDecimal.valueOf(150.00));
Order created = orderService.createOrder(request);
assertThat(created.getId()).isNotNull();
assertThat(created.getStatus()).isEqualTo(OrderStatus.PENDING);
}
}
Both OrderRepositoryTest and OrderServiceTest share the same PostgreSQL container. The container starts once when the first test class is loaded and stops when the JVM exits (Ryuk handles cleanup).
The Critical Warning
Do not combine @Testcontainers/@Container with the singleton pattern. If you annotate your base class container field with @Container and annotate your test class with @Testcontainers, the TestcontainersExtension will attempt to start the container again when each test class loads — which will fail because the container is already running.
The singleton pattern relies entirely on the static initializer block. No @Testcontainers. No @Container.
Spring TestContext Caching
Spring Boot’s test framework caches the application context between test classes. If two test classes have identical context configurations, Spring reuses the same context rather than creating a new one. This is a significant performance optimization.
For this caching to work correctly with Testcontainers, the container must be started before the Spring context is created, and the container’s connection properties must be available when the context is built.
The singleton pattern and @DynamicPropertySource in a base class satisfy this requirement: the static initializer starts the container before any test runs, and @DynamicPropertySource injects the connection properties before the context is built.
When you verify that context caching is working:
Spring TestContext Framework caches ApplicationContexts:
- Cache size: 1
- Cache hits: 8
- Cache misses: 1
Eight cache hits means eight test classes shared one application context load. Without caching, that would be nine context loads.
You can observe cache statistics by enabling debug logging:
# src/test/resources/application-test.yml
logging:
level:
org.springframework.test.context.cache: DEBUG
Parallel Container Startup
When you have multiple containers that can start independently, starting them in sequence wastes time. Startables.deepStart() starts them in parallel:
public abstract class AbstractIntegrationTest {
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine");
static final KafkaContainer KAFKA =
new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.1"));
static final GenericContainer<?> REDIS =
new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
static {
Startables.deepStart(POSTGRES, KAFKA, REDIS).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);
registry.add("spring.data.redis.host", REDIS::getHost);
registry.add("spring.data.redis.port", () -> REDIS.getMappedPort(6379));
}
}
Startables.deepStart() starts all three containers in parallel, then .join() blocks until all are ready. Without parallel startup, you pay the sequential sum of all container startup times. With parallel startup, you pay the maximum startup time.
For a typical setup with 3 containers:
- Sequential: 2s + 8s + 1s = 11 seconds
- Parallel: max(2s, 8s, 1s) = 8 seconds
Spring Boot 3.1 — @ImportTestcontainers
Spring Boot 3.1 introduced @ImportTestcontainers as an alternative to the base class pattern. Define your container configuration in a separate class:
// TestcontainersConfiguration.java
@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfiguration {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>("postgres:16-alpine");
}
@Bean
@ServiceConnection
KafkaContainer kafkaContainer() {
return new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.1"));
}
}
Import it in test classes with @ImportTestcontainers:
@SpringBootTest
@ImportTestcontainers(TestcontainersConfiguration.class)
class OrderRepositoryTest {
// containers defined in TestcontainersConfiguration
}
@ServiceConnection replaces @DynamicPropertySource entirely — Spring Boot automatically reads the container’s connection details and configures the datasource, Kafka bootstrap servers, and so on. This is the cleanest approach for Spring Boot 3.1+ projects.
Lifecycle Summary
| Pattern | Container starts | Container stops | When to use |
|---|---|---|---|
static @Container | Once per test class | After test class | Default choice — fast within a class |
Instance @Container | Before each test method | After each test method | Rarely — when you need truly isolated state per method |
| Singleton base class | Once per JVM | On JVM exit | Multiple test classes sharing infrastructure |
@ImportTestcontainers | Once per context | On context stop | Spring Boot 3.1+ preference |
Common Pitfalls
Combining singleton and @Testcontainers. The JUnit extension tries to start an already-started container. Use the singleton pattern without @Testcontainers/@Container annotations.
Forgetting @BeforeEach cleanup with static containers. Static containers are shared. If test A inserts data and does not clean up, test B sees unexpected rows. Always add @BeforeEach data cleanup or use @Transactional rollback (covered in Article 17).
Calling container.stop() in @AfterAll with the singleton pattern. You stop the shared container before other test classes have finished. The singleton pattern intentionally does not stop the container explicitly — Ryuk handles cleanup when the JVM exits.
Summary
Container lifecycle management is the key to a fast Testcontainers test suite. Use static @Container fields to share containers within a test class. Use the singleton base class pattern to share containers across the entire test suite. Use Startables.deepStart() when you have multiple containers that can start in parallel. In Spring Boot 3.1+, prefer @ImportTestcontainers with @ServiceConnection.
The next article covers wait strategies — how to reliably wait for a container to be ready before your tests start, without using Thread.sleep().