From 7c8aaecf131612770bc43b557438081da5284aea Mon Sep 17 00:00:00 2001
From: jonghun
Date: Fri, 10 Apr 2026 11:57:02 +0900
Subject: [PATCH 1/5] feat(junit-jupiter): add @SharedContainers for
cross-class container sharing
Introduce @SharedContainers annotation and SharedContainersExtension to
enable static @Container fields to be shared across multiple test classes
in the same JVM session.
The core issue with @Testcontainers is that containers are stored in a
class-scoped ExtensionContext.Store, causing them to stop and restart for
each test class even when declared as static fields on a shared base class.
SharedContainersExtension solves this by storing containers in the root
ExtensionContext.Store (context.getRoot().getStore()), which persists for
the entire JVM session. Containers are started at most once and cleaned up
automatically at JVM shutdown via Ryuk or JUnit's CloseableResource.
Resolves: https://github.com/testcontainers/testcontainers-java/issues/1441
Co-Authored-By: Claude Sonnet 4.6
---
.../junit/jupiter/SharedContainers.java | 80 ++++++
.../jupiter/SharedContainersExtension.java | 268 ++++++++++++++++++
.../jupiter/SharedContainersBaseTest.java | 26 ++
.../jupiter/SharedContainersTestClassA.java | 33 +++
.../jupiter/SharedContainersTestClassB.java | 33 +++
5 files changed, 440 insertions(+)
create mode 100644 modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/SharedContainers.java
create mode 100644 modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/SharedContainersExtension.java
create mode 100644 modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersBaseTest.java
create mode 100644 modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersTestClassA.java
create mode 100644 modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersTestClassB.java
diff --git a/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/SharedContainers.java b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/SharedContainers.java
new file mode 100644
index 00000000000..f18feb903ca
--- /dev/null
+++ b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/SharedContainers.java
@@ -0,0 +1,80 @@
+package org.testcontainers.junit.jupiter;
+
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * {@code @SharedContainers} is a JUnit Jupiter extension to activate automatic startup of
+ * containers that are shared across multiple test classes.
+ *
+ *
Unlike {@link Testcontainers}, which manages container lifecycle per test class,
+ * {@code @SharedContainers} uses the root {@link org.junit.jupiter.api.extension.ExtensionContext}
+ * store so that containers declared as {@code static} fields annotated with {@link Container} are
+ * started only once for the entire test suite (JVM session) and stopped automatically when the
+ * JVM exits (via Ryuk or JVM shutdown).
+ *
+ *
This is the recommended approach when multiple test classes extend a common base class and
+ * need to share the same container instances. Using {@link Testcontainers} in this scenario causes
+ * containers to be restarted for each test class.
+ *
+ *
Containers declared as instance fields will still be started and stopped for every test
+ * method, just like with {@link Testcontainers}.
+ *
+ *
The annotation {@code @SharedContainers} can be used on a superclass in the test hierarchy.
+ * All subclasses will automatically inherit support for the extension.
+ *
+ *
Example:
+ *
+ *
+ * @SharedContainers
+ * abstract class AbstractIntegrationTest {
+ *
+ * // started once for the entire test suite, shared across all subclasses
+ * @Container
+ * static final MySQLContainer<?> MY_SQL = new MySQLContainer<>("mysql:8");
+ * }
+ *
+ * class UserServiceTest extends AbstractIntegrationTest {
+ * @Test
+ * void test() {
+ * assertTrue(MY_SQL.isRunning());
+ * }
+ * }
+ *
+ * class OrderServiceTest extends AbstractIntegrationTest {
+ * @Test
+ * void test() {
+ * // same MY_SQL instance as UserServiceTest
+ * assertTrue(MY_SQL.isRunning());
+ * }
+ * }
+ *
+ *
+ * @see Container
+ * @see Testcontainers
+ */
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+@ExtendWith(SharedContainersExtension.class)
+@Inherited
+public @interface SharedContainers {
+ /**
+ * Whether tests should be disabled (rather than failing) when Docker is not available.
+ * Defaults to {@code false}.
+ *
+ * @return if the tests should be disabled when Docker is not available
+ */
+ boolean disabledWithoutDocker() default false;
+
+ /**
+ * Whether containers should start in parallel. Defaults to {@code false}.
+ *
+ * @return if the containers should start in parallel
+ */
+ boolean parallel() default false;
+}
diff --git a/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/SharedContainersExtension.java b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/SharedContainersExtension.java
new file mode 100644
index 00000000000..50aa514015c
--- /dev/null
+++ b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/SharedContainersExtension.java
@@ -0,0 +1,268 @@
+package org.testcontainers.junit.jupiter;
+
+import lombok.Getter;
+import org.junit.jupiter.api.extension.AfterEachCallback;
+import org.junit.jupiter.api.extension.BeforeAllCallback;
+import org.junit.jupiter.api.extension.BeforeEachCallback;
+import org.junit.jupiter.api.extension.ConditionEvaluationResult;
+import org.junit.jupiter.api.extension.ExecutionCondition;
+import org.junit.jupiter.api.extension.ExtensionConfigurationException;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
+import org.junit.jupiter.api.extension.ExtensionContext.Store;
+import org.junit.jupiter.api.extension.ExtensionContext.Store.CloseableResource;
+import org.junit.platform.commons.support.AnnotationSupport;
+import org.junit.platform.commons.support.HierarchyTraversalMode;
+import org.junit.platform.commons.support.ModifierSupport;
+import org.junit.platform.commons.support.ReflectionSupport;
+import org.testcontainers.lifecycle.Startable;
+import org.testcontainers.lifecycle.Startables;
+import org.testcontainers.lifecycle.TestDescription;
+import org.testcontainers.lifecycle.TestLifecycleAware;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * JUnit Jupiter Extension that backs the {@link SharedContainers} annotation.
+ *
+ *
Unlike {@link TestcontainersExtension}, which stores containers in a class-scoped
+ * {@link ExtensionContext.Store} (causing restart per test class), this extension stores
+ * {@code static} {@link Container}-annotated fields in the root store so that
+ * they are started at most once per JVM session and live until the JVM exits.
+ */
+public class SharedContainersExtension implements BeforeEachCallback, BeforeAllCallback, AfterEachCallback, ExecutionCondition {
+
+ private static final Namespace NAMESPACE = Namespace.create(SharedContainersExtension.class);
+
+ private static final String LOCAL_LIFECYCLE_AWARE_CONTAINERS = "localLifecycleAwareContainers";
+
+ private final DockerAvailableDetector dockerDetector = new DockerAvailableDetector();
+
+ @Override
+ public void beforeAll(ExtensionContext context) {
+ Class> testClass = context
+ .getTestClass()
+ .orElseThrow(() -> new ExtensionConfigurationException("SharedContainersExtension is only supported for classes."));
+
+ // Use ROOT store so containers survive across test class boundaries.
+ Store rootStore = context.getRoot().getStore(NAMESPACE);
+ List sharedContainersStoreAdapters = findSharedContainers(testClass);
+
+ startContainersInStore(sharedContainersStoreAdapters, rootStore, context);
+
+ List lifecycleAwareContainers = sharedContainersStoreAdapters
+ .stream()
+ .filter(this::isTestLifecycleAware)
+ .map(adapter -> (TestLifecycleAware) adapter.container)
+ .collect(Collectors.toList());
+
+ signalBeforeTestToContainers(lifecycleAwareContainers, testDescriptionFrom(context));
+ }
+
+ @Override
+ public void beforeEach(ExtensionContext context) {
+ Store store = context.getStore(NAMESPACE);
+
+ List restartContainers = collectParentTestInstances(context)
+ .parallelStream()
+ .flatMap(this::findRestartContainers)
+ .collect(Collectors.toList());
+
+ List lifecycleAwareContainers = startContainersAndCollectLifecycleAware(
+ restartContainers,
+ store,
+ context
+ );
+
+ store.put(LOCAL_LIFECYCLE_AWARE_CONTAINERS, lifecycleAwareContainers);
+ signalBeforeTestToContainers(lifecycleAwareContainers, testDescriptionFrom(context));
+ }
+
+ @Override
+ public void afterEach(ExtensionContext context) {
+ List containers = (List) context
+ .getStore(NAMESPACE)
+ .get(LOCAL_LIFECYCLE_AWARE_CONTAINERS);
+ if (containers != null) {
+ TestDescription description = testDescriptionFrom(context);
+ Optional throwable = context.getExecutionException();
+ containers.forEach(c -> c.afterTest(description, throwable));
+ }
+ }
+
+ @Override
+ public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
+ return findSharedContainersAnnotation(context)
+ .map(this::evaluate)
+ .orElseThrow(() -> new ExtensionConfigurationException("@SharedContainers not found"));
+ }
+
+ private ConditionEvaluationResult evaluate(SharedContainers annotation) {
+ if (annotation.disabledWithoutDocker()) {
+ if (dockerDetector.isDockerAvailable()) {
+ return ConditionEvaluationResult.enabled("Docker is available");
+ }
+ return ConditionEvaluationResult.disabled("disabledWithoutDocker is true and Docker is not available");
+ }
+ return ConditionEvaluationResult.enabled("disabledWithoutDocker is false");
+ }
+
+ private Optional findSharedContainersAnnotation(ExtensionContext context) {
+ Optional current = Optional.of(context);
+ while (current.isPresent()) {
+ Optional annotation = AnnotationSupport.findAnnotation(
+ current.get().getRequiredTestClass(),
+ SharedContainers.class
+ );
+ if (annotation.isPresent()) {
+ return annotation;
+ }
+ current = current.get().getParent();
+ }
+ return Optional.empty();
+ }
+
+ private boolean isParallelExecutionEnabled(ExtensionContext context) {
+ return findSharedContainersAnnotation(context).map(SharedContainers::parallel).orElse(false);
+ }
+
+ private void startContainersInStore(List adapters, Store store, ExtensionContext context) {
+ if (adapters.isEmpty()) {
+ return;
+ }
+ if (isParallelExecutionEnabled(context)) {
+ Stream startables = adapters.stream().map(adapter -> {
+ store.getOrComputeIfAbsent(adapter.getKey(), k -> adapter);
+ return adapter.container;
+ });
+ Startables.deepStart(startables).join();
+ } else {
+ adapters.forEach(adapter -> store.getOrComputeIfAbsent(adapter.getKey(), k -> adapter.start()));
+ }
+ }
+
+ private List startContainersAndCollectLifecycleAware(
+ List adapters,
+ Store store,
+ ExtensionContext context
+ ) {
+ startContainersInStore(adapters, store, context);
+ return adapters
+ .stream()
+ .filter(this::isTestLifecycleAware)
+ .map(adapter -> (TestLifecycleAware) adapter.container)
+ .collect(Collectors.toList());
+ }
+
+ private List findSharedContainers(Class> testClass) {
+ return ReflectionSupport
+ .findFields(testClass, isSharedContainer(), HierarchyTraversalMode.TOP_DOWN)
+ .stream()
+ .map(f -> getContainerInstance(null, f))
+ .collect(Collectors.toList());
+ }
+
+ private Predicate isSharedContainer() {
+ return isContainer().and(ModifierSupport::isStatic);
+ }
+
+ private Stream findRestartContainers(Object testInstance) {
+ return ReflectionSupport
+ .findFields(testInstance.getClass(), isRestartContainer(), HierarchyTraversalMode.TOP_DOWN)
+ .stream()
+ .map(f -> getContainerInstance(testInstance, f));
+ }
+
+ private Predicate isRestartContainer() {
+ return isContainer().and(ModifierSupport::isNotStatic);
+ }
+
+ private static Predicate isContainer() {
+ return field -> {
+ boolean isAnnotatedWithContainer = AnnotationSupport.isAnnotated(field, Container.class);
+ if (isAnnotatedWithContainer) {
+ boolean isStartable = Startable.class.isAssignableFrom(field.getType());
+ if (!isStartable) {
+ throw new ExtensionConfigurationException(
+ String.format("FieldName: %s does not implement Startable", field.getName())
+ );
+ }
+ return true;
+ }
+ return false;
+ };
+ }
+
+ private static StoreAdapter getContainerInstance(Object testInstance, Field field) {
+ try {
+ field.setAccessible(true);
+ Startable containerInstance = (Startable) field.get(testInstance);
+ if (containerInstance == null) {
+ throw new ExtensionConfigurationException("Container " + field.getName() + " needs to be initialized");
+ }
+ return new StoreAdapter(field.getDeclaringClass(), field.getName(), containerInstance);
+ } catch (IllegalAccessException e) {
+ throw new ExtensionConfigurationException("Can not access container defined in field " + field.getName());
+ }
+ }
+
+ private boolean isTestLifecycleAware(StoreAdapter adapter) {
+ return adapter.container instanceof TestLifecycleAware;
+ }
+
+ private void signalBeforeTestToContainers(List containers, TestDescription description) {
+ containers.forEach(c -> c.beforeTest(description));
+ }
+
+ private Set
*/
-public class SharedContainersExtension implements BeforeEachCallback, BeforeAllCallback, AfterEachCallback, ExecutionCondition {
+public class SharedContainersExtension
+ implements BeforeEachCallback, BeforeAllCallback, AfterEachCallback, AfterAllCallback, ExecutionCondition {
private static final Namespace NAMESPACE = Namespace.create(SharedContainersExtension.class);
+ private static final String SHARED_LIFECYCLE_AWARE_CONTAINERS = "sharedLifecycleAwareContainers";
+
private static final String LOCAL_LIFECYCLE_AWARE_CONTAINERS = "localLifecycleAwareContainers";
private final DockerAvailableDetector dockerDetector = new DockerAvailableDetector();
@@ -65,9 +69,23 @@ public void beforeAll(ExtensionContext context) {
.map(adapter -> (TestLifecycleAware) adapter.container)
.collect(Collectors.toList());
+ // Store in class-level store so afterAll can retrieve and signal afterTest()
+ context.getStore(NAMESPACE).put(SHARED_LIFECYCLE_AWARE_CONTAINERS, lifecycleAwareContainers);
signalBeforeTestToContainers(lifecycleAwareContainers, testDescriptionFrom(context));
}
+ @Override
+ public void afterAll(ExtensionContext context) {
+ List containers = (List) context
+ .getStore(NAMESPACE)
+ .get(SHARED_LIFECYCLE_AWARE_CONTAINERS);
+ if (containers != null) {
+ TestDescription description = testDescriptionFrom(context);
+ Optional throwable = context.getExecutionException();
+ containers.forEach(c -> c.afterTest(description, throwable));
+ }
+ }
+
@Override
public void beforeEach(ExtensionContext context) {
Store store = context.getStore(NAMESPACE);
From 79599b17f144de94e2e8cbc1c4c1801bc44d48e7 Mon Sep 17 00:00:00 2001
From: jonghun
Date: Wed, 15 Apr 2026 13:38:35 +0900
Subject: [PATCH 4/5] feat(junit-jupiter): use doReturn for ExtensionContext
stubbing
Update SharedContainersExtensionTests to use the doReturn(...).when(...) syntax for mocking ExtensionContext. This approach is generally preferred over when(...).thenReturn(...) to avoid unintended side effects or type-safety issues during stubbing.
---
.../junit/jupiter/SharedContainersExtensionTests.java | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersExtensionTests.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersExtensionTests.java
index 8706451d814..b4244d684e7 100644
--- a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersExtensionTests.java
+++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersExtensionTests.java
@@ -5,8 +5,9 @@
import org.junit.jupiter.api.extension.ExtensionContext;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
+
public class SharedContainersExtensionTests {
@@ -40,7 +41,7 @@ void whenEnabledWithoutDockerAndDockerIsUnavailableTestsAreEnabled() {
private ExtensionContext extensionContext(Class> clazz) {
ExtensionContext extensionContext = mock(ExtensionContext.class);
- when(extensionContext.getRequiredTestClass()).thenReturn(clazz);
+ doReturn(clazz).when(extensionContext).getRequiredTestClass();
return extensionContext;
}
From 8ae581bf159af74e40c1f0d93df245d1b293be2b Mon Sep 17 00:00:00 2001
From: jonghun
Date: Wed, 15 Apr 2026 13:46:08 +0900
Subject: [PATCH 5/5] refactor(junit-jupiter): extract
signalAfterTestToContainersFor helper and add lifecycle test
Extract duplicate afterAll/afterEach signal logic into signalAfterTestToContainersFor()
helper method, consistent with TestcontainersExtension pattern.
Add SharedContainersLifecycleAwareTest to verify beforeTest()/afterTest() callbacks
are correctly signalled to TestLifecycleAware shared containers.
Co-Authored-By: Claude Sonnet 4.6
---
.../jupiter/SharedContainersExtension.java | 29 ++++-----
.../SharedContainersLifecycleAwareTest.java | 59 +++++++++++++++++++
2 files changed, 72 insertions(+), 16 deletions(-)
create mode 100644 modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersLifecycleAwareTest.java
diff --git a/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/SharedContainersExtension.java b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/SharedContainersExtension.java
index 544abb1dc79..c68af0bbefd 100644
--- a/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/SharedContainersExtension.java
+++ b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/SharedContainersExtension.java
@@ -76,14 +76,7 @@ public void beforeAll(ExtensionContext context) {
@Override
public void afterAll(ExtensionContext context) {
- List containers = (List) context
- .getStore(NAMESPACE)
- .get(SHARED_LIFECYCLE_AWARE_CONTAINERS);
- if (containers != null) {
- TestDescription description = testDescriptionFrom(context);
- Optional throwable = context.getExecutionException();
- containers.forEach(c -> c.afterTest(description, throwable));
- }
+ signalAfterTestToContainersFor(SHARED_LIFECYCLE_AWARE_CONTAINERS, context);
}
@Override
@@ -107,14 +100,7 @@ public void beforeEach(ExtensionContext context) {
@Override
public void afterEach(ExtensionContext context) {
- List containers = (List) context
- .getStore(NAMESPACE)
- .get(LOCAL_LIFECYCLE_AWARE_CONTAINERS);
- if (containers != null) {
- TestDescription description = testDescriptionFrom(context);
- Optional throwable = context.getExecutionException();
- containers.forEach(c -> c.afterTest(description, throwable));
- }
+ signalAfterTestToContainersFor(LOCAL_LIFECYCLE_AWARE_CONTAINERS, context);
}
@Override
@@ -245,6 +231,17 @@ private void signalBeforeTestToContainers(List containers, T
containers.forEach(c -> c.beforeTest(description));
}
+ private void signalAfterTestToContainersFor(String storeKey, ExtensionContext context) {
+ List lifecycleAwareContainers = (List) context
+ .getStore(NAMESPACE)
+ .get(storeKey);
+ if (lifecycleAwareContainers != null) {
+ TestDescription description = testDescriptionFrom(context);
+ Optional throwable = context.getExecutionException();
+ lifecycleAwareContainers.forEach(container -> container.afterTest(description, throwable));
+ }
+ }
+
private Set collectParentTestInstances(ExtensionContext context) {
List allInstances = new ArrayList<>(context.getRequiredTestInstances().getAllInstances());
Collections.reverse(allInstances);
diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersLifecycleAwareTest.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersLifecycleAwareTest.java
new file mode 100644
index 00000000000..c6ca67f2699
--- /dev/null
+++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersLifecycleAwareTest.java
@@ -0,0 +1,59 @@
+package org.testcontainers.junit.jupiter;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.junit.jupiter.api.extension.AfterAllCallback;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.extension.ExtensionContext;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Verifies that {@link SharedContainersExtension} correctly signals
+ * {@link org.testcontainers.lifecycle.TestLifecycleAware#beforeTest} and
+ * {@link org.testcontainers.lifecycle.TestLifecycleAware#afterTest} to shared containers.
+ */
+// The order of @ExtendWith and @SharedContainers is crucial:
+// AfterAllVerifier.afterAll() must run after SharedContainersExtension.afterAll()
+@ExtendWith(SharedContainersLifecycleAwareTest.AfterAllVerifier.class)
+@SharedContainers
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class SharedContainersLifecycleAwareTest {
+
+ @Container
+ static final TestLifecycleAwareContainerMock SHARED_CONTAINER = new TestLifecycleAwareContainerMock();
+
+ @BeforeAll
+ static void beforeAll() {
+ assertThat(SHARED_CONTAINER.getLifecycleMethodCalls())
+ .containsExactly(TestLifecycleAwareContainerMock.BEFORE_TEST);
+ }
+
+ @Test
+ @Order(1)
+ void beforeTest_should_be_called_before_tests() {
+ assertThat(SHARED_CONTAINER.getLifecycleMethodCalls())
+ .containsExactly(TestLifecycleAwareContainerMock.BEFORE_TEST);
+ }
+
+ @Test
+ @Order(2)
+ void afterTest_should_be_called_after_all_tests() {
+ // Verified by AfterAllVerifier below after all tests complete
+ }
+
+ static class AfterAllVerifier implements AfterAllCallback {
+
+ @Override
+ public void afterAll(ExtensionContext context) {
+ assertThat(SHARED_CONTAINER.getLifecycleMethodCalls())
+ .containsExactly(
+ TestLifecycleAwareContainerMock.BEFORE_TEST,
+ TestLifecycleAwareContainerMock.AFTER_TEST
+ );
+ }
+ }
+}