Testcontainers provides seamless integration with JUnit Jupiter (JUnit 5) through a dedicated extension. The extension automatically manages container lifecycle, starting containers before tests and stopping them after tests complete, with support for both shared and per-test container instances.
The JUnit Jupiter integration provides three main annotations:
@Testcontainers: Enables automatic container lifecycle management for test classes@Container: Marks container fields to be managed by the extension@EnabledIfDockerAvailable: Conditionally enables tests based on Docker availabilityContainer lifecycle depends on field modifiers:
static): Containers shared across all test methods (started once, stopped after all tests)Module: org.testcontainers:testcontainers-junit-jupiter
Maven Dependency:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>2.0.3</version>
<scope>test</scope>
</dependency>Gradle Dependency:
testImplementation 'org.testcontainers:junit-jupiter:2.0.3'Activates automatic container management for a test class. The extension finds all @Container fields and manages their lifecycle.
import org.testcontainers.junit.jupiter.Testcontainers;
/**
* JUnit Jupiter extension to activate automatic startup and stop of containers.
* Finds all fields annotated with @Container and calls their lifecycle methods.
*
* Static @Container fields are shared between test methods.
* Instance @Container fields are restarted for every test method.
*
* Can be used on superclasses - subclasses automatically inherit the extension.
*
* WARNING: Only tested with sequential test execution.
* Parallel test execution is unsupported and may have unintended side effects.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(TestcontainersExtension.class)
@Inherited
public @interface Testcontainers {
/**
* Whether tests should be disabled (rather than failing) when Docker is not available.
* When true, tests are skipped if Docker unavailable.
* When false (default), tests fail if Docker unavailable.
*
* @return if the tests should be disabled when Docker is not available
*/
boolean disabledWithoutDocker() default false;
/**
* Whether containers should start in parallel.
* When true, all @Container fields start concurrently using Startables.deepStart().
* When false (default), containers start sequentially.
*
* Parallel startup can significantly reduce test suite execution time
* when multiple containers are used.
*
* @return if the containers should start in parallel
*/
boolean parallel() default false;
}Key Features:
@Inherited: Subclasses automatically inherit container management@ExtendWith(TestcontainersExtension.class): Automatically registers the JUnit 5 extensionUsage Examples:
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.postgresql.PostgreSQLContainer;
import org.junit.jupiter.api.Test;
// Basic usage
@Testcontainers
class BasicContainerTest {
@Container
private static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@Test
void test() {
// postgres container is running
assertTrue(postgres.isRunning());
}
}
// Disable tests if Docker not available
@Testcontainers(disabledWithoutDocker = true)
class GracefulSkipTest {
@Container
private static GenericContainer<?> container = new GenericContainer<>("redis:7.0");
@Test
void test() {
// Test is skipped (not failed) if Docker unavailable
}
}
// Parallel container startup
@Testcontainers(parallel = true)
class ParallelStartupTest {
@Container
private static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@Container
private static GenericContainer<?> redis = new GenericContainer<>("redis:7.0");
@Test
void test() {
// Both containers started in parallel (faster than sequential)
assertTrue(postgres.isRunning());
assertTrue(redis.isRunning());
}
}Marks container fields to be managed by the Testcontainers extension. Must be used with @Testcontainers on the class.
import org.testcontainers.junit.jupiter.Container;
/**
* Marks containers that should be managed by the Testcontainers extension.
* Used in conjunction with @Testcontainers annotation.
*
* Can be applied to:
* - Fields implementing the Startable interface
* - Static fields (shared across all tests)
* - Instance fields (restarted per test)
* - Used as a meta-annotation for custom container annotations
*/
@Target({ ElementType.FIELD, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface Container {
// No parameters
}Behavior:
Startable interface (all Testcontainers containers do)Usage Examples:
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.mysql.MySQLContainer;
import org.testcontainers.postgresql.PostgreSQLContainer;
@Testcontainers
class ContainerLifecycleTest {
// Shared container - started once, used by all tests
@Container
private static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
// Per-test container - restarted for each test method
@Container
private PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
private static String mysqlContainerId;
@Test
void firstTest() {
// MySQL container ID stays the same across tests
if (mysqlContainerId == null) {
mysqlContainerId = mysql.getContainerId();
} else {
assertEquals(mysqlContainerId, mysql.getContainerId());
}
// PostgreSQL gets a new container for this test
assertTrue(postgres.isRunning());
}
@Test
void secondTest() {
// Same MySQL container as firstTest
assertEquals(mysqlContainerId, mysql.getContainerId());
// Different PostgreSQL container than firstTest
assertTrue(postgres.isRunning());
}
}Conditionally enables tests based on Docker availability. Tests are skipped if Docker is not accessible.
import org.testcontainers.junit.jupiter.EnabledIfDockerAvailable;
/**
* JUnit Jupiter extension to enable tests only if Docker is available.
* Tests or test classes annotated with this annotation are skipped if Docker cannot be accessed.
*
* Can be applied to:
* - Test classes (all tests in the class are conditional)
* - Individual test methods (only that method is conditional)
*/
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ExtendWith(EnabledIfDockerAvailableCondition.class)
public @interface EnabledIfDockerAvailable {
// No parameters
}Behavior:
@Testcontainers(disabledWithoutDocker = true) as it can be applied per-methodUsage Examples:
import org.testcontainers.junit.jupiter.EnabledIfDockerAvailable;
import org.junit.jupiter.api.Test;
// Apply to entire test class
@EnabledIfDockerAvailable
class DockerRequiredTests {
@Test
void dockerTest1() {
// Skipped if Docker unavailable
}
@Test
void dockerTest2() {
// Skipped if Docker unavailable
}
}
// Apply to individual test methods
class MixedTests {
@Test
void regularTest() {
// Always runs (no Docker required)
}
@Test
@EnabledIfDockerAvailable
void dockerRequiredTest() {
// Skipped if Docker unavailable
}
}
// Combine with @Testcontainers
@Testcontainers
@EnabledIfDockerAvailable
class SafeContainerTests {
@Container
private static GenericContainer<?> container = new GenericContainer<>("nginx:alpine");
@Test
void test() {
// Test skipped gracefully if Docker unavailable
assertTrue(container.isRunning());
}
}Containers declared as static fields are shared across all test methods in the class.
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.postgresql.PostgreSQLContainer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeAll;
@Testcontainers
class SharedContainerTest {
@Container
private static final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@BeforeAll
static void setup() {
// Container is already running
System.out.println("JDBC URL: " + postgres.getJdbcUrl());
}
@Test
void test1() {
// Uses shared container
assertNotNull(postgres.getJdbcUrl());
}
@Test
void test2() {
// Uses same container as test1
assertNotNull(postgres.getJdbcUrl());
}
// Container stopped automatically after all tests
}Lifecycle:
beforeAll() -> start container
test1()
test2()
test3()
...
afterAll() -> stop containerWhen to Use:
Containers declared as instance fields are restarted for every test method.
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.containers.GenericContainer;
import org.junit.jupiter.api.Test;
@Testcontainers
class PerTestContainerTest {
@Container
private GenericContainer<?> redis = new GenericContainer<>("redis:7.0")
.withExposedPorts(6379);
@Test
void test1() {
// Fresh Redis container for this test
assertTrue(redis.isRunning());
// Can modify container state without affecting other tests
}
@Test
void test2() {
// New Redis container (different from test1)
assertTrue(redis.isRunning());
// Starts with clean state
}
// Container stopped after each test
}Lifecycle:
beforeEach() -> start container
test1()
afterEach() -> stop container
beforeEach() -> start NEW container
test2()
afterEach() -> stop containerWhen to Use:
Combine static and instance containers for different use cases in the same test class.
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.containers.GenericContainer;
import org.junit.jupiter.api.Test;
@Testcontainers
class MixedLifecycleTest {
// Shared database - read-only, expensive to start
@Container
private static final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
// Per-test cache - modified by tests, needs isolation
@Container
private GenericContainer<?> redis = new GenericContainer<>("redis:7.0")
.withExposedPorts(6379);
@Test
void test1() {
// Shared postgres (same for all tests)
String jdbcUrl = postgres.getJdbcUrl();
// Unique redis (fresh for this test)
assertTrue(redis.isRunning());
}
@Test
void test2() {
// Same postgres as test1
String jdbcUrl = postgres.getJdbcUrl();
// Different redis than test1
assertTrue(redis.isRunning());
}
}The @Testcontainers annotation is @Inherited, so subclasses automatically get container management.
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.containers.GenericContainer;
@Testcontainers
abstract class AbstractContainerTest {
@Container
protected static GenericContainer<?> sharedContainer = new GenericContainer<>("redis:7.0")
.withExposedPorts(6379);
@Container
protected GenericContainer<?> perTestContainer = new GenericContainer<>("nginx:alpine")
.withExposedPorts(80);
}
// Inherits @Testcontainers and all containers
class ConcreteTest extends AbstractContainerTest {
@Container
private static GenericContainer<?> additionalSharedContainer =
new GenericContainer<>("mongo:7");
@Test
void test() {
// Access inherited shared container
assertTrue(sharedContainer.isRunning());
// Access inherited per-test container
assertTrue(perTestContainer.isRunning());
// Access this class's container
assertTrue(additionalSharedContainer.isRunning());
}
}Nested test classes can access parent containers.
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.containers.GenericContainer;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@Testcontainers
class NestedContainerTest {
@Container
private static final GenericContainer<?> parentContainer =
new GenericContainer<>("redis:7.0");
@Test
void parentTest() {
assertTrue(parentContainer.isRunning());
}
@Nested
class NestedTests {
@Test
void nestedTest() {
// Can access parent container
assertTrue(parentContainer.isRunning());
}
@Test
void anotherNestedTest() {
// Same parent container instance
assertTrue(parentContainer.isRunning());
}
}
@Nested
class AnotherNestedGroup {
@Test
void test() {
// Shares parent container with other nested groups
assertTrue(parentContainer.isRunning());
}
}
}Start multiple containers concurrently to reduce test setup time.
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.containers.GenericContainer;
import org.junit.jupiter.api.Test;
@Testcontainers(parallel = true)
class ParallelStartupTest {
@Container
private static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@Container
private static GenericContainer<?> redis = new GenericContainer<>("redis:7.0");
@Container
private static GenericContainer<?> mongo = new GenericContainer<>("mongo:7");
@Test
void test() {
// All three containers started in parallel
// Total startup time ≈ max(postgres, redis, mongo) instead of sum
assertTrue(postgres.isRunning());
assertTrue(redis.isRunning());
assertTrue(mongo.isRunning());
}
}Performance Comparison:
| Configuration | Startup Time (example) |
|---|---|
Sequential (parallel = false) | 45s (15s + 10s + 20s) |
Parallel (parallel = true) | 20s (max of 15s, 10s, 20s) |
Create custom annotations that include @Container.
import org.testcontainers.junit.jupiter.Container;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
// Define custom meta-annotation
@Container
@Retention(RetentionPolicy.RUNTIME)
@interface DatabaseContainer {
}
// Use custom annotation
@Testcontainers
class MetaAnnotationTest {
@DatabaseContainer // Uses @Container internally
private static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@Test
void test() {
assertTrue(postgres.isRunning());
}
}Containers implementing TestLifecycleAware receive callbacks before and after tests.
import org.testcontainers.lifecycle.TestLifecycleAware;
import org.testcontainers.lifecycle.TestDescription;
import org.testcontainers.containers.GenericContainer;
class CustomContainer extends GenericContainer<CustomContainer> implements TestLifecycleAware {
public CustomContainer(String imageName) {
super(imageName);
}
@Override
public void beforeTest(TestDescription description) {
// Called before each test
System.out.println("Starting test: " + description.getTestId());
// Can create test-specific resources
}
@Override
public void afterTest(TestDescription description, Optional<Throwable> throwable) {
// Called after each test
if (throwable.isPresent()) {
System.err.println("Test failed: " + throwable.get().getMessage());
// Can capture logs, screenshots, etc.
}
}
}
@Testcontainers
class TestLifecycleTest {
@Container
private static CustomContainer container = new CustomContainer("myapp:latest");
@Test
void test() {
// beforeTest() called automatically before this test
// afterTest() called automatically after this test
}
}The Testcontainers extension integrates with JUnit 5 lifecycle:
Test Class Lifecycle:
┌────────────────────────────────────────┐
│ @BeforeAll (static setup) │
├────────────────────────────────────────┤
│ TestcontainersExtension.beforeAll() │
│ → Start static @Container fields │
│ → Call TestLifecycleAware.beforeTest │
├────────────────────────────────────────┤
│ @BeforeEach (instance setup) │
├────────────────────────────────────────┤
│ TestcontainersExtension.beforeEach() │
│ → Start instance @Container fields │
│ → Call TestLifecycleAware.beforeTest │
├────────────────────────────────────────┤
│ @Test (test method) │
├────────────────────────────────────────┤
│ TestcontainersExtension.afterEach() │
│ → Call TestLifecycleAware.afterTest │
│ → Stop instance containers │
├────────────────────────────────────────┤
│ @AfterEach (instance cleanup) │
├────────────────────────────────────────┤
│ ... (repeat for each test) ... │
├────────────────────────────────────────┤
│ TestcontainersExtension.afterAll() │
│ → Call TestLifecycleAware.afterTest │
│ → Stop static containers │
├────────────────────────────────────────┤
│ @AfterAll (static cleanup) │
└────────────────────────────────────────┘@Testcontainers
class AutomaticTest {
@Container
private static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@Test
void test() {
// Container automatically started and stopped
assertNotNull(postgres.getJdbcUrl());
}
}Advantages:
TestLifecycleAwareclass ManualTest {
private static PostgreSQLContainer<?> postgres;
@BeforeAll
static void setup() {
postgres = new PostgreSQLContainer<>("postgres:15");
postgres.start();
}
@AfterAll
static void teardown() {
if (postgres != null) {
postgres.stop();
}
}
@Test
void test() {
assertNotNull(postgres.getJdbcUrl());
}
}Disadvantages:
@Testcontainers
class ReadOnlyTests {
// Shared - faster execution
@Container
private static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@Test
void readTest1() { /* read-only */ }
@Test
void readTest2() { /* read-only */ }
}@Testcontainers
class StatefulTests {
// Per-test - ensures isolation
@Container
private GenericContainer<?> redis = new GenericContainer<>("redis:7.0");
@Test
void modifyState1() { /* writes data */ }
@Test
void modifyState2() { /* writes data */ }
}@Testcontainers(parallel = true) // Faster startup
class MultiContainerTests {
@Container
private static PostgreSQLContainer<?> postgres = ...;
@Container
private static GenericContainer<?> redis = ...;
@Container
private static GenericContainer<?> kafka = ...;
}@EnabledIfDockerAvailable // Gracefully skip if no Docker
class DockerOptionalTests {
@Test
void dockerTest() { /* ... */ }
}@Testcontainers
abstract class DatabaseTestBase {
@Container
protected static PostgreSQLContainer<?> postgres = ...;
protected String getJdbcUrl() {
return postgres.getJdbcUrl();
}
}
class UserRepositoryTest extends DatabaseTestBase {
@Test
void testUsers() {
// Uses inherited postgres container
}
}Problem: @Container fields not starting automatically
Solution: Ensure @Testcontainers annotation is present on the test class
// ❌ Wrong - missing @Testcontainers
class BrokenTest {
@Container
private static GenericContainer<?> container = ...;
}
// ✅ Correct
@Testcontainers
class WorkingTest {
@Container
private static GenericContainer<?> container = ...;
}Problem: Tests interfere with each other due to shared container state
Solution: Use instance fields instead of static fields
// ❌ Shared state causes test pollution
@Testcontainers
class ProblematicTest {
@Container
private static GenericContainer<?> redis = ...; // Shared
@Test
void test1() { /* modifies Redis */ }
@Test
void test2() { /* sees test1 modifications */ }
}
// ✅ Isolated per-test instances
@Testcontainers
class IsolatedTest {
@Container
private GenericContainer<?> redis = ...; // Per-test
@Test
void test1() { /* modifies Redis */ }
@Test
void test2() { /* fresh Redis */ }
}Problem: Tests fail with parallel JUnit execution
Solution: The Testcontainers JUnit Jupiter extension is not designed for parallel test execution. Use sequential execution:
// junit-platform.properties
junit.jupiter.execution.parallel.enabled = falseOr use parallel = true parameter for parallel container startup (not test execution):
@Testcontainers(parallel = true) // Parallel CONTAINER startup only
class SequentialTests {
// Tests still run sequentially
}Problem: Tests fail when Docker is not running
Solutions:
@Testcontainers(disabledWithoutDocker = true)
class OptionalDockerTests { /* ... */ }@EnabledIfDockerAvailable@EnabledIfDockerAvailable
class ConditionalTests { /* ... */ }@BeforeAll
static void checkDocker() {
assumeTrue(DockerClientFactory.instance().isDockerAvailable());
}Problem: Containers remain running after tests
Solution: Ensure proper test completion (no hanging assertions). JUnit's ExtensionContext automatically cleans up when context closes. If containers persist:
// Check for test framework issues
@Testcontainers
class CleanupTest {
@Container
private static GenericContainer<?> container = ...;
@Test
void test() {
// Ensure test completes normally
// Avoid: System.exit(), infinite loops, etc.
}
}import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.containers.GenericContainer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Nested;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
@Testcontainers(parallel = true) // Start containers in parallel
class CompleteJUnitJupiterExample {
// Shared database for all tests
@Container
private static final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
// Per-test cache
@Container
private GenericContainer<?> redis = new GenericContainer<>("redis:7.0")
.withExposedPorts(6379);
@BeforeAll
static void setupDatabase() throws Exception {
// Container already running - can execute setup
try (Connection conn = DriverManager.getConnection(
postgres.getJdbcUrl(),
postgres.getUsername(),
postgres.getPassword())) {
conn.createStatement().execute(
"CREATE TABLE users (id SERIAL PRIMARY KEY, name VARCHAR(255))"
);
}
}
@Test
void testDatabaseConnection() throws Exception {
try (Connection conn = DriverManager.getConnection(
postgres.getJdbcUrl(),
postgres.getUsername(),
postgres.getPassword())) {
ResultSet rs = conn.createStatement().executeQuery("SELECT 1");
assertTrue(rs.next());
assertEquals(1, rs.getInt(1));
}
}
@Test
void testRedisConnection() {
// Each test gets a fresh Redis instance
assertTrue(redis.isRunning());
int redisPort = redis.getMappedPort(6379);
assertTrue(redisPort > 0);
}
@Nested
class UserRepositoryTests {
@Test
void testInsertUser() throws Exception {
// Can access parent containers
try (Connection conn = DriverManager.getConnection(
postgres.getJdbcUrl(),
postgres.getUsername(),
postgres.getPassword())) {
conn.createStatement().execute(
"INSERT INTO users (name) VALUES ('Alice')"
);
ResultSet rs = conn.createStatement().executeQuery(
"SELECT COUNT(*) FROM users"
);
assertTrue(rs.next());
assertTrue(rs.getInt(1) > 0);
}
}
}
}This comprehensive JUnit Jupiter integration enables seamless container lifecycle management, reducing boilerplate and ensuring reliable test execution with automatic cleanup.