From 02a7a3f6948fb2f90c21da8349e6f9d0f27604ee Mon Sep 17 00:00:00 2001 From: Hyun Lee Date: Wed, 16 Sep 2026 12:45:50 -0500 Subject: [PATCH] fix(junit): retain failures for shared containers Preserve method and nested-context failures until shared containers receive their final lifecycle callback. Keep class-level failures and instance callbacks unchanged. Fixes #10270 --- modules/junit-jupiter/build.gradle | 1 + .../jupiter/TestcontainersExtension.java | 40 +++- .../jupiter/SharedContainerFailureTest.java | 220 ++++++++++++++++++ 3 files changed, 257 insertions(+), 4 deletions(-) create mode 100644 modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainerFailureTest.java diff --git a/modules/junit-jupiter/build.gradle b/modules/junit-jupiter/build.gradle index b9ac700cea4..c1ea9edea0b 100644 --- a/modules/junit-jupiter/build.gradle +++ b/modules/junit-jupiter/build.gradle @@ -7,6 +7,7 @@ dependencies { testImplementation project(':testcontainers-mysql') testImplementation project(':testcontainers-postgresql') + testImplementation 'org.junit.platform:junit-platform-launcher' testImplementation 'com.zaxxer:HikariCP:7.0.2' testImplementation 'redis.clients:jedis:7.5.3' testImplementation 'org.apache.httpcomponents:httpclient:4.5.14' 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..74bbf815858 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 @@ -28,6 +28,7 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -52,6 +53,8 @@ public void beforeAll(ExtensionContext context) { }); Store store = context.getStore(NAMESPACE); + // A unique key prevents nested contexts from inheriting another class's failure holder. + store.put(context.getUniqueId(), new AtomicReference()); List sharedContainersStoreAdapters = findSharedContainers(testClass); startContainers(sharedContainersStoreAdapters, store, context); @@ -86,7 +89,15 @@ private void startContainers(List storeAdapters, Store store, Exte @Override public void afterAll(ExtensionContext context) { - signalAfterTestToContainersFor(SHARED_LIFECYCLE_AWARE_CONTAINERS, context); + captureSharedContainerFailure(context); + AtomicReference failure = context + .getStore(NAMESPACE) + .get(context.getUniqueId(), AtomicReference.class); + Optional throwable = context.getExecutionException(); + if (!throwable.isPresent() && failure != null) { + throwable = Optional.ofNullable(failure.get()); + } + signalAfterTestToContainersFor(SHARED_LIFECYCLE_AWARE_CONTAINERS, context, throwable); } @Override @@ -128,7 +139,25 @@ private boolean isParallelExecutionEnabled(ExtensionContext context) { @Override public void afterEach(ExtensionContext context) { - signalAfterTestToContainersFor(LOCAL_LIFECYCLE_AWARE_CONTAINERS, context); + captureSharedContainerFailure(context); + signalAfterTestToContainersFor(LOCAL_LIFECYCLE_AWARE_CONTAINERS, context, context.getExecutionException()); + } + + private void captureSharedContainerFailure(ExtensionContext context) { + context + .getExecutionException() + .ifPresent(throwable -> { + ExtensionContext current = context; + while (current != null) { + AtomicReference failure = current + .getStore(NAMESPACE) + .get(current.getUniqueId(), AtomicReference.class); + if (failure != null) { + failure.compareAndSet(null, throwable); + } + current = current.getParent().orElse(null); + } + }); } private void signalBeforeTestToContainers( @@ -138,13 +167,16 @@ private void signalBeforeTestToContainers( lifecycleAwareContainers.forEach(container -> container.beforeTest(testDescription)); } - private void signalAfterTestToContainersFor(String storeKey, ExtensionContext context) { + private void signalAfterTestToContainersFor( + String storeKey, + ExtensionContext context, + Optional throwable + ) { 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)); } } diff --git a/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainerFailureTest.java b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainerFailureTest.java new file mode 100644 index 00000000000..b90affc0a72 --- /dev/null +++ b/modules/junit-jupiter/src/test/java/org/testcontainers/junit/jupiter/SharedContainerFailureTest.java @@ -0,0 +1,220 @@ +package org.testcontainers.junit.jupiter; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.ClassOrderer; +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.TestClassOrder; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.platform.engine.discovery.DiscoverySelectors; +import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder; +import org.junit.platform.launcher.core.LauncherFactory; +import org.junit.platform.launcher.listeners.SummaryGeneratingListener; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class SharedContainerFailureTest { + + @Test + void retainsFirstMethodFailureWithoutChangingInstanceCallbacks() { + execute(MethodFailures.class, 2); + + assertThat(MethodFailures.SHARED.getCapturedThrowable()).isSameAs(MethodFailures.FIRST); + assertSingleLifecycle(MethodFailures.SHARED); + assertThat(MethodFailures.LOCAL).hasSize(3); + assertThat(MethodFailures.LOCAL.get(0).getCapturedThrowable()).isSameAs(MethodFailures.FIRST); + assertThat(MethodFailures.LOCAL.get(1).getCapturedThrowable()).isNull(); + assertThat(MethodFailures.LOCAL.get(2).getCapturedThrowable()).isSameAs(MethodFailures.SECOND); + MethodFailures.LOCAL.forEach(this::assertSingleLifecycle); + } + + @Test + void nestedFailureReachesEnclosingSharedContainerButNotSibling() { + execute(NestedFailures.class, 1); + + assertThat(NestedFailures.SHARED.getCapturedThrowable()).isSameAs(NestedFailures.FAILURE); + assertThat(FailingNestedContainers.SHARED.getCapturedThrowable()).isSameAs(NestedFailures.FAILURE); + assertThat(PassingNestedContainers.SHARED.getCapturedThrowable()).isNull(); + assertSingleLifecycle(NestedFailures.SHARED); + assertSingleLifecycle(FailingNestedContainers.SHARED); + assertSingleLifecycle(PassingNestedContainers.SHARED); + } + + @Test + void classFailureTakesPrecedenceOverMethodFailure() { + execute(ClassFailure.class, 2); + + assertThat(ClassFailure.SHARED.getCapturedThrowable()).isSameAs(ClassFailure.CLASS_FAILURE); + assertSingleLifecycle(ClassFailure.SHARED); + } + + @Test + void nestedClassFailureReachesEnclosingSharedContainer() { + execute(NestedClassFailure.class, 1); + + assertThat(NestedClassFailure.SHARED.getCapturedThrowable()).isSameAs(NestedClassFailure.FAILURE); + assertSingleLifecycle(NestedClassFailure.SHARED); + } + + @Test + void successfulClassHasNoFailure() { + execute(Successful.class, 0); + + assertThat(Successful.SHARED.getCapturedThrowable()).isNull(); + assertSingleLifecycle(Successful.SHARED); + } + + private void execute(Class testClass, long failures) { + SummaryGeneratingListener listener = new SummaryGeneratingListener(); + LauncherFactory + .create() + .execute( + LauncherDiscoveryRequestBuilder + .request() + .selectors(DiscoverySelectors.selectClass(testClass)) + .configurationParameter("junit.jupiter.execution.parallel.enabled", "false") + .build(), + listener + ); + assertThat(listener.getSummary().getTotalFailureCount()).isEqualTo(failures); + assertThat(listener.getSummary().getTestsStartedCount()).isPositive(); + } + + private void assertSingleLifecycle(TestLifecycleAwareContainerMock container) { + assertThat(container.getLifecycleMethodCalls()).containsExactly("beforeTest", "afterTest"); + } + + @Testcontainers + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + static class MethodFailures { + + static final RuntimeException FIRST = new RuntimeException("first method failure"); + + static final RuntimeException SECOND = new RuntimeException("second method failure"); + + static final List LOCAL = new ArrayList<>(); + + @Container + static final TestLifecycleAwareContainerMock SHARED = new TestLifecycleAwareContainerMock(); + + @Container + final TestLifecycleAwareContainerMock local = new TestLifecycleAwareContainerMock(); + + MethodFailures() { + LOCAL.add(local); + } + + @Test + @Order(1) + void firstFailure() { + throw FIRST; + } + + @Test + @Order(2) + void success() {} + + @Test + @Order(3) + void secondFailure() { + throw SECOND; + } + } + + static class FailingNestedContainers { + + @Container + static final TestLifecycleAwareContainerMock SHARED = new TestLifecycleAwareContainerMock(); + } + + static class PassingNestedContainers { + + @Container + static final TestLifecycleAwareContainerMock SHARED = new TestLifecycleAwareContainerMock(); + } + + @Testcontainers + @TestClassOrder(ClassOrderer.OrderAnnotation.class) + static class NestedFailures { + + static final RuntimeException FAILURE = new RuntimeException("nested method failure"); + + @Container + static final TestLifecycleAwareContainerMock SHARED = new TestLifecycleAwareContainerMock(); + + @Nested + @Order(1) + class Failing extends FailingNestedContainers { + + @Test + void failure() { + throw FAILURE; + } + } + + @Nested + @Order(2) + class Passing extends PassingNestedContainers { + + @Test + void success() {} + } + } + + @Testcontainers + static class ClassFailure { + + static final RuntimeException CLASS_FAILURE = new RuntimeException("class failure"); + + @Container + static final TestLifecycleAwareContainerMock SHARED = new TestLifecycleAwareContainerMock(); + + @Test + void failure() { + throw new RuntimeException("method failure"); + } + + @AfterAll + static void afterAll() { + throw CLASS_FAILURE; + } + } + + @Testcontainers + static class NestedClassFailure { + + static final RuntimeException FAILURE = new RuntimeException("nested class failure"); + + @Container + static final TestLifecycleAwareContainerMock SHARED = new TestLifecycleAwareContainerMock(); + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class Failing { + + @Test + void success() {} + + @AfterAll + void afterAll() { + throw FAILURE; + } + } + } + + @Testcontainers + static class Successful { + + @Container + static final TestLifecycleAwareContainerMock SHARED = new TestLifecycleAwareContainerMock(); + + @Test + void success() {} + } +}