From 5454ea95aed086441ba1cd53fa49d8f309403eb4 Mon Sep 17 00:00:00 2001 From: Semyon Levin Date: Mon, 13 Apr 2026 17:12:36 +0400 Subject: [PATCH] Start shared containers in postProcessTestInstance for PER_CLASS lifecycle With PER_CLASS, postProcessTestInstance runs before beforeAll. This change starts shared containers early so they are available in non-static @MethodSource factory methods and other TestInstancePostProcessor extensions. StoreAdapter now implements Startable with synchronized idempotent start/stop to support Startables.deepStart() and prevent redundant container lifecycle calls. --- .../jupiter/TestcontainersExtension.java | 65 +++++++++++---- .../TestcontainersInstanceLifecycleTest.java | 83 +++++++++++++++++++ ...stcontainersPerClassPostProcessorTest.java | 61 ++++++++++++++ 3 files changed, 195 insertions(+), 14 deletions(-) create mode 100644 modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestcontainersInstanceLifecycleTest.java create mode 100644 modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestcontainersPerClassPostProcessorTest.java diff --git a/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/TestcontainersExtension.java b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/TestcontainersExtension.java index 89adba6033f..37e14ecee96 100644 --- a/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/TestcontainersExtension.java +++ b/modules/junit-jupiter/src/main/java/org/testcontainers/junit/jupiter/TestcontainersExtension.java @@ -1,6 +1,8 @@ package org.testcontainers.junit.jupiter; import lombok.Getter; +import lombok.Synchronized; +import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.extension.AfterAllCallback; import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.BeforeAllCallback; @@ -12,6 +14,7 @@ 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.jupiter.api.extension.TestInstancePostProcessor; import org.junit.platform.commons.support.AnnotationSupport; import org.junit.platform.commons.support.HierarchyTraversalMode; import org.junit.platform.commons.support.ModifierSupport; @@ -33,7 +36,13 @@ import java.util.stream.Stream; public class TestcontainersExtension - implements BeforeEachCallback, BeforeAllCallback, AfterEachCallback, AfterAllCallback, ExecutionCondition { + implements + BeforeEachCallback, + BeforeAllCallback, + AfterEachCallback, + AfterAllCallback, + ExecutionCondition, + TestInstancePostProcessor { private static final Namespace NAMESPACE = Namespace.create(TestcontainersExtension.class); @@ -43,8 +52,23 @@ public class TestcontainersExtension private final DockerAvailableDetector dockerDetector = new DockerAvailableDetector(); + @Override + public void postProcessTestInstance(Object testInstance, ExtensionContext context) { + TestInstance.Lifecycle lifecycle = context.getTestInstanceLifecycle().orElse(null); + if (lifecycle == TestInstance.Lifecycle.PER_CLASS) { + beforeAllImpl(context); + } + } + @Override public void beforeAll(ExtensionContext context) { + TestInstance.Lifecycle lifecycle = context.getTestInstanceLifecycle().orElse(null); + if (lifecycle != TestInstance.Lifecycle.PER_CLASS) { + beforeAllImpl(context); + } + } + + private void beforeAllImpl(ExtensionContext context) { Class testClass = context .getTestClass() .orElseThrow(() -> { @@ -71,16 +95,14 @@ private void startContainers(List storeAdapters, Store store, Exte return; } + List storedAdapters = storeAdapters + .stream() + .map(adapter -> (StoreAdapter) store.getOrComputeIfAbsent(adapter.getKey(), k -> adapter)) + .collect(Collectors.toList()); if (isParallelExecutionEnabled(context)) { - Stream startables = storeAdapters - .stream() - .map(storeAdapter -> { - store.getOrComputeIfAbsent(storeAdapter.getKey(), k -> storeAdapter); - return storeAdapter.container; - }); - Startables.deepStart(startables).join(); + Startables.deepStart(storedAdapters).join(); } else { - storeAdapters.forEach(adapter -> store.getOrComputeIfAbsent(adapter.getKey(), k -> adapter.start())); + storedAdapters.forEach(StoreAdapter::start); } } @@ -260,7 +282,7 @@ private static StoreAdapter getContainerInstance(final Object testInstance, fina * thereby letting the JUnit automatically stop containers once the current * {@link ExtensionContext} is closed. */ - private static class StoreAdapter implements CloseableResource, AutoCloseable { + private static class StoreAdapter implements Startable, CloseableResource, AutoCloseable { @Getter private String key; @@ -272,14 +294,29 @@ private StoreAdapter(Class declaringClass, String fieldName, Startable contai this.container = container; } - private StoreAdapter start() { - container.start(); - return this; + private boolean started; + + @Override + @Synchronized + public void start() { + if (!started) { + container.start(); + started = true; + } + } + + @Override + @Synchronized + public void stop() { + if (started) { + container.stop(); + started = false; + } } @Override public void close() { - container.stop(); + stop(); } } } diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestcontainersInstanceLifecycleTest.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestcontainersInstanceLifecycleTest.java new file mode 100644 index 00000000000..b16c107fb04 --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestcontainersInstanceLifecycleTest.java @@ -0,0 +1,83 @@ +package org.testcontainers.junit.jupiter; + +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; +import org.testcontainers.lifecycle.Startable; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that instance {@link Container @Container} fields are started exactly once + * per test instance for both {@link TestInstance.Lifecycle#PER_CLASS} and + * {@link TestInstance.Lifecycle#PER_METHOD} lifecycles. + */ +@Testcontainers +class TestcontainersInstanceLifecycleTest { + + @Container + private static final StartCountingMock staticContainer = new StartCountingMock(); + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + class PerClass { + + @Container + private final StartCountingMock instanceContainer = new StartCountingMock(); + + @Test + @Order(1) + void first_test() { + assertThat(staticContainer.starts).isEqualTo(1); + assertThat(instanceContainer.starts).isEqualTo(1); + } + + @Test + @Order(2) + void second_test() { + assertThat(staticContainer.starts).as("Static container should be started exactly once").isEqualTo(1); + assertThat(instanceContainer.starts) + .as("PER_CLASS instance container should be started for every test") + .isEqualTo(2); + } + } + + @Nested + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + class PerMethod { + + @Container + private final StartCountingMock instanceContainer = new StartCountingMock(); + + @Test + @Order(1) + void first_test() { + assertThat(staticContainer.starts).isEqualTo(1); + assertThat(instanceContainer.starts).isEqualTo(1); + } + + @Test + @Order(2) + void second_test() { + assertThat(staticContainer.starts).as("Static container should be started exactly once").isEqualTo(1); + assertThat(instanceContainer.starts).isEqualTo(1); + } + } + + static class StartCountingMock implements Startable { + + int starts; + + @Override + public void start() { + starts++; + } + + @Override + public void stop() {} + } +} diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestcontainersPerClassPostProcessorTest.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestcontainersPerClassPostProcessorTest.java new file mode 100644 index 00000000000..8fab09e2e82 --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/TestcontainersPerClassPostProcessorTest.java @@ -0,0 +1,61 @@ +package org.testcontainers.junit.jupiter; + +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.testcontainers.lifecycle.Startable; + +import java.util.UUID; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that static {@link Container @Container} fields are available in non-static + * {@link MethodSource @MethodSource} factory methods with + * {@link TestInstance.Lifecycle#PER_CLASS PER_CLASS} lifecycle. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Testcontainers +class TestcontainersPerClassPostProcessorTest { + + @Container + private static final StartTrackingMock staticContainer = new StartTrackingMock(); + + @Container + private final StartTrackingMock instanceContainer = new StartTrackingMock(); + + private boolean staticStartedDuringMethodSource; + + private boolean instanceStartedDuringMethodSource; + + Stream arguments() { + staticStartedDuringMethodSource = staticContainer.containerId != null; + instanceStartedDuringMethodSource = instanceContainer.containerId != null; + return Stream.of("a"); + } + + @ParameterizedTest + @MethodSource("arguments") + void containers_are_started_before_method_source(String argument) { + assertThat(staticStartedDuringMethodSource) + .as("Static container should be started before @MethodSource resolution") + .isTrue(); + assertThat(instanceStartedDuringMethodSource) + .as("Instance container should NOT be started before @MethodSource resolution") + .isFalse(); + } + + static class StartTrackingMock implements Startable { + + String containerId; + + @Override + public void start() { + containerId = UUID.randomUUID().toString(); + } + + @Override + public void stop() {} + } +}