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/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..c68af0bbefd --- /dev/null +++ b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/SharedContainersExtension.java @@ -0,0 +1,287 @@ +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; +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, 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(); + + @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()); + + // 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) { + signalAfterTestToContainersFor(SHARED_LIFECYCLE_AWARE_CONTAINERS, 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) { + signalAfterTestToContainersFor(LOCAL_LIFECYCLE_AWARE_CONTAINERS, context); + } + + @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 (isDockerAvailable()) { + return ConditionEvaluationResult.enabled("Docker is available"); + } + return ConditionEvaluationResult.disabled("disabledWithoutDocker is true and Docker is not available"); + } + 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()) { + 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 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); + 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/SharedContainersExtensionTests.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersExtensionTests.java new file mode 100644 index 00000000000..b4244d684e7 --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersExtensionTests.java @@ -0,0 +1,67 @@ +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.doReturn; +import static org.mockito.Mockito.mock; + + +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); + doReturn(clazz).when(extensionContext).getRequiredTestClass(); + 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; + } + } +} 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 + ); + } + } +} 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()); + } + } +}