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 collectParentTestInstances(ExtensionContext context) { + List allInstances = new ArrayList<>(context.getRequiredTestInstances().getAllInstances()); + Collections.reverse(allInstances); + return new LinkedHashSet<>(allInstances); + } + + private TestDescription testDescriptionFrom(ExtensionContext context) { + return new TestcontainersTestDescription( + context.getUniqueId(), + FilesystemFriendlyNameGenerator.filesystemFriendlyNameOf(context) + ); + } + + /** + * An adapter for {@link Startable} that implements {@link CloseableResource}, + * letting JUnit automatically stop containers once the {@link ExtensionContext} is closed. + * + *

When stored in the root store, {@code close()} is called at the very end of the + * test suite (JVM shutdown), ensuring shared containers live for the full session.

+ */ + private static class StoreAdapter implements CloseableResource, AutoCloseable { + + @Getter + private final String key; + + private final Startable container; + + private StoreAdapter(Class declaringClass, String fieldName, Startable container) { + this.key = declaringClass.getName() + "." + fieldName; + this.container = container; + } + + private StoreAdapter start() { + container.start(); + return this; + } + + @Override + public void close() { + container.stop(); + } + } +} diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersBaseTest.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersBaseTest.java new file mode 100644 index 00000000000..7332a0dc11a --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersBaseTest.java @@ -0,0 +1,26 @@ +package org.testcontainers.junit.jupiter; + +import org.testcontainers.containers.GenericContainer; + +import java.util.concurrent.atomic.AtomicReference; + +/** + * Base class for tests that verify {@link SharedContainers} shares containers across test classes. + * + *

The {@code observedContainerId} captures the first container ID seen by any subclass. + * Each subclass then verifies that its container has the same ID, proving the container was + * started only once across all test classes in the JVM session.

+ */ +@SharedContainers +abstract class SharedContainersBaseTest { + + @Container + static final GenericContainer SHARED = new GenericContainer<>(JUnitJupiterTestImages.HTTPD_IMAGE) + .withExposedPorts(80); + + /** + * Tracks the container ID observed by the first test class to run. + * Subsequent test classes compare their container ID against this value. + */ + static final AtomicReference observedContainerId = new AtomicReference<>(); +} diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersTestClassA.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersTestClassA.java new file mode 100644 index 00000000000..d825488ea1b --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersTestClassA.java @@ -0,0 +1,33 @@ +package org.testcontainers.junit.jupiter; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * First of two test classes extending {@link SharedContainersBaseTest}. + * + *

Together with {@link SharedContainersTestClassB}, this verifies that {@link SharedContainers} + * starts the container only once across test class boundaries.

+ */ +class SharedContainersTestClassA extends SharedContainersBaseTest { + + @Test + void container_is_running() { + assertThat(SHARED.isRunning()).isTrue(); + } + + @Test + void container_id_is_consistent_across_classes() { + String currentId = SHARED.getContainerId(); + assertThat(currentId).isNotBlank(); + + // If this is the first class to run, record the ID. + // If another class already ran, verify we see the same container. + if (!observedContainerId.compareAndSet(null, currentId)) { + assertThat(currentId) + .as("SharedContainers should reuse the same container across test classes") + .isEqualTo(observedContainerId.get()); + } + } +} diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersTestClassB.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersTestClassB.java new file mode 100644 index 00000000000..fcbf5c30943 --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersTestClassB.java @@ -0,0 +1,33 @@ +package org.testcontainers.junit.jupiter; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Second of two test classes extending {@link SharedContainersBaseTest}. + * + *

Together with {@link SharedContainersTestClassA}, this verifies that {@link SharedContainers} + * starts the container only once across test class boundaries.

+ */ +class SharedContainersTestClassB extends SharedContainersBaseTest { + + @Test + void container_is_running() { + assertThat(SHARED.isRunning()).isTrue(); + } + + @Test + void container_id_is_consistent_across_classes() { + String currentId = SHARED.getContainerId(); + assertThat(currentId).isNotBlank(); + + // If this is the first class to run, record the ID. + // If another class already ran, verify we see the same container. + if (!observedContainerId.compareAndSet(null, currentId)) { + assertThat(currentId) + .as("SharedContainers should reuse the same container across test classes") + .isEqualTo(observedContainerId.get()); + } + } +} From da358b16310bccd6963b62983c1e33bad0651688 Mon Sep 17 00:00:00 2001 From: jonghun Date: Fri, 10 Apr 2026 12:03:10 +0900 Subject: [PATCH 2/5] test(junit-jupiter): add unit tests and docs for @SharedContainers - Add package-private isDockerAvailable() hook to SharedContainersExtension so that unit tests can control Docker availability without a real daemon, following the same pattern as TestcontainersExtension - Add SharedContainersExtensionTests covering disabledWithoutDocker=true/false scenarios via a subclass that overrides isDockerAvailable() - Document @SharedContainers in junit_5.md under a new "Shared containers across test classes" section Co-Authored-By: Claude Sonnet 4.6 --- docs/test_framework_integration/junit_5.md | 16 +++++ .../jupiter/SharedContainersExtension.java | 6 +- .../SharedContainersExtensionTests.java | 66 +++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersExtensionTests.java diff --git a/docs/test_framework_integration/junit_5.md b/docs/test_framework_integration/junit_5.md index aed2d515af9..b2115c9ef2c 100644 --- a/docs/test_framework_integration/junit_5.md +++ b/docs/test_framework_integration/junit_5.md @@ -50,6 +50,22 @@ This is because nested test classes have to be defined non-static and can't ther [Shared Container](../../modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/MixedLifecycleTests.java) lines:18-23,32-33,35-36 +## Shared containers across test classes + +When multiple test classes extend a common base class, using `@Testcontainers` causes containers +to be stopped and restarted for each test class. To share containers for the entire JVM session +instead, use `@SharedContainers`. + +`@SharedContainers` stores `static` `@Container` fields in a JVM-wide store so they are started +only once and stopped automatically at the end of the test suite. + + +[Shared Across Classes](../../modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersBaseTest.java) + + +**Note:** Do not combine `@SharedContainers` with `@Testcontainers` on the same class hierarchy, +as this may produce unexpected lifecycle behaviour. + ## Singleton containers Note that the [singleton container pattern](manual_lifecycle_control.md#singleton-containers) is also an option when 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 50aa514015c..107fb1b0c08 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 @@ -108,7 +108,7 @@ public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext con private ConditionEvaluationResult evaluate(SharedContainers annotation) { if (annotation.disabledWithoutDocker()) { - if (dockerDetector.isDockerAvailable()) { + if (isDockerAvailable()) { return ConditionEvaluationResult.enabled("Docker is available"); } return ConditionEvaluationResult.disabled("disabledWithoutDocker is true and Docker is not available"); @@ -116,6 +116,10 @@ private ConditionEvaluationResult evaluate(SharedContainers annotation) { return ConditionEvaluationResult.enabled("disabledWithoutDocker is false"); } + boolean isDockerAvailable() { + return this.dockerDetector.isDockerAvailable(); + } + private Optional findSharedContainersAnnotation(ExtensionContext context) { Optional current = Optional.of(context); while (current.isPresent()) { 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 new file mode 100644 index 00000000000..8706451d814 --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersExtensionTests.java @@ -0,0 +1,66 @@ +package org.testcontainers.junit.jupiter; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.junit.jupiter.api.extension.ExtensionContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class SharedContainersExtensionTests { + + @Test + void whenDisabledWithoutDockerAndDockerIsAvailableTestsAreEnabled() { + ConditionEvaluationResult result = new TestSharedContainersExtension(true) + .evaluateExecutionCondition(extensionContext(DisabledWithoutDocker.class)); + assertThat(result.isDisabled()).isFalse(); + } + + @Test + void whenDisabledWithoutDockerAndDockerIsUnavailableTestsAreDisabled() { + ConditionEvaluationResult result = new TestSharedContainersExtension(false) + .evaluateExecutionCondition(extensionContext(DisabledWithoutDocker.class)); + assertThat(result.isDisabled()).isTrue(); + } + + @Test + void whenEnabledWithoutDockerAndDockerIsAvailableTestsAreEnabled() { + ConditionEvaluationResult result = new TestSharedContainersExtension(true) + .evaluateExecutionCondition(extensionContext(EnabledWithoutDocker.class)); + assertThat(result.isDisabled()).isFalse(); + } + + @Test + void whenEnabledWithoutDockerAndDockerIsUnavailableTestsAreEnabled() { + ConditionEvaluationResult result = new TestSharedContainersExtension(false) + .evaluateExecutionCondition(extensionContext(EnabledWithoutDocker.class)); + assertThat(result.isDisabled()).isFalse(); + } + + private ExtensionContext extensionContext(Class clazz) { + ExtensionContext extensionContext = mock(ExtensionContext.class); + when(extensionContext.getRequiredTestClass()).thenReturn(clazz); + return extensionContext; + } + + @SharedContainers(disabledWithoutDocker = true) + static final class DisabledWithoutDocker {} + + @SharedContainers + static final class EnabledWithoutDocker {} + + static final class TestSharedContainersExtension extends SharedContainersExtension { + + private final boolean dockerAvailable; + + private TestSharedContainersExtension(boolean dockerAvailable) { + this.dockerAvailable = dockerAvailable; + } + + @Override + boolean isDockerAvailable() { + return dockerAvailable; + } + } +} From 6591347f5c4600f802064a87f686ccb8f561893e Mon Sep 17 00:00:00 2001 From: jonghun Date: Fri, 10 Apr 2026 12:06:46 +0900 Subject: [PATCH 3/5] fix(junit-jupiter): signal afterTest() to TestLifecycleAware shared containers SharedContainersExtension was missing AfterAllCallback, so containers implementing TestLifecycleAware never received afterTest() after each test class completed. Fix by: - Adding AfterAllCallback to the implements list - Storing lifecycle-aware containers in the class-level store in beforeAll() - Implementing afterAll() to retrieve and signal afterTest() to them This matches the behaviour of TestcontainersExtension for shared containers. Co-Authored-By: Claude Sonnet 4.6 --- .../jupiter/SharedContainersExtension.java | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) 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 107fb1b0c08..544abb1dc79 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 @@ -1,6 +1,7 @@ package org.testcontainers.junit.jupiter; import lombok.Getter; +import org.junit.jupiter.api.extension.AfterAllCallback; import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.BeforeAllCallback; import org.junit.jupiter.api.extension.BeforeEachCallback; @@ -39,10 +40,13 @@ * {@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 { +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 + ); + } + } +}