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 @Testcontainers and @Container work at the JUnit 5 extension level
  • The difference between static and instance-level @Container fields
  • How to use @BeforeAll for 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:

  1. JUnit 5 discovers all @Container annotated fields
  2. Before the test class runs, TestcontainersExtension starts containers on static fields
  3. Before each test method, it starts containers on instance fields
  4. After each test method, instance containers are stopped
  5. 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.

@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

PatternContainer startsContainer stopsWhen to use
static @ContainerOnce per test classAfter test classDefault choice — fast within a class
Instance @ContainerBefore each test methodAfter each test methodRarely — when you need truly isolated state per method
Singleton base classOnce per JVMOn JVM exitMultiple test classes sharing infrastructure
@ImportTestcontainersOnce per contextOn context stopSpring 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().

Next: Understanding Wait Strategies