Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/test_framework_integration/junit_5.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<!--/codeinclude-->

## 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.

<!--codeinclude-->
[Shared Across Classes](../../modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainersBaseTest.java)
<!--/codeinclude-->

**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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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).</p>
*
* <p>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.</p>
*
* <p>Containers declared as instance fields will still be started and stopped for every test
* method, just like with {@link Testcontainers}.</p>
*
* <p>The annotation {@code @SharedContainers} can be used on a superclass in the test hierarchy.
* All subclasses will automatically inherit support for the extension.</p>
*
* <p>Example:</p>
*
* <pre>
* &#64;SharedContainers
* abstract class AbstractIntegrationTest {
*
* // started once for the entire test suite, shared across all subclasses
* &#64;Container
* static final MySQLContainer&lt;?&gt; MY_SQL = new MySQLContainer&lt;&gt;("mysql:8");
* }
*
* class UserServiceTest extends AbstractIntegrationTest {
* &#64;Test
* void test() {
* assertTrue(MY_SQL.isRunning());
* }
* }
*
* class OrderServiceTest extends AbstractIntegrationTest {
* &#64;Test
* void test() {
* // same MY_SQL instance as UserServiceTest
* assertTrue(MY_SQL.isRunning());
* }
* }
* </pre>
*
* @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;
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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 <em>root</em> store so that
* they are started at most once per JVM session and live until the JVM exits.</p>
*/
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<StoreAdapter> sharedContainersStoreAdapters = findSharedContainers(testClass);

startContainersInStore(sharedContainersStoreAdapters, rootStore, context);

List<TestLifecycleAware> 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<StoreAdapter> restartContainers = collectParentTestInstances(context)
.parallelStream()
.flatMap(this::findRestartContainers)
.collect(Collectors.toList());

List<TestLifecycleAware> 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<SharedContainers> findSharedContainersAnnotation(ExtensionContext context) {
Optional<ExtensionContext> current = Optional.of(context);
while (current.isPresent()) {
Optional<SharedContainers> 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<StoreAdapter> adapters, Store store, ExtensionContext context) {
if (adapters.isEmpty()) {
return;
}
if (isParallelExecutionEnabled(context)) {
Stream<Startable> 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<TestLifecycleAware> startContainersAndCollectLifecycleAware(
List<StoreAdapter> 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<StoreAdapter> findSharedContainers(Class<?> testClass) {
return ReflectionSupport
.findFields(testClass, isSharedContainer(), HierarchyTraversalMode.TOP_DOWN)
.stream()
.map(f -> getContainerInstance(null, f))
.collect(Collectors.toList());
}

private Predicate<Field> isSharedContainer() {
return isContainer().and(ModifierSupport::isStatic);
}

private Stream<StoreAdapter> findRestartContainers(Object testInstance) {
return ReflectionSupport
.findFields(testInstance.getClass(), isRestartContainer(), HierarchyTraversalMode.TOP_DOWN)
.stream()
.map(f -> getContainerInstance(testInstance, f));
}

private Predicate<Field> isRestartContainer() {
return isContainer().and(ModifierSupport::isNotStatic);
}

private static Predicate<Field> 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<TestLifecycleAware> containers, TestDescription description) {
containers.forEach(c -> c.beforeTest(description));
}

private void signalAfterTestToContainersFor(String storeKey, ExtensionContext context) {
List<TestLifecycleAware> lifecycleAwareContainers = (List<TestLifecycleAware>) context
.getStore(NAMESPACE)
.get(storeKey);
if (lifecycleAwareContainers != null) {
TestDescription description = testDescriptionFrom(context);
Optional<Throwable> throwable = context.getExecutionException();
lifecycleAwareContainers.forEach(container -> container.afterTest(description, throwable));
}
}

private Set<Object> collectParentTestInstances(ExtensionContext context) {
List<Object> 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.
*
* <p>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.</p>
*/
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();
}
}
}
Loading