diff --git a/modules/core/src/test/java/org/apache/ignite/internal/testframework/StopOnAfterEachFailureExtensionTest.java b/modules/core/src/test/java/org/apache/ignite/internal/testframework/StopOnAfterEachFailureExtensionTest.java new file mode 100644 index 000000000000..383c206eb191 --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/internal/testframework/StopOnAfterEachFailureExtensionTest.java @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.ignite.internal.testframework; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Disabled; +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.Timeout; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.platform.engine.discovery.DiscoverySelectors; +import org.junit.platform.testkit.engine.EngineTestKit; + +/** + * Tests for {@link StopOnAfterEachFailureExtension}. + */ +class StopOnAfterEachFailureExtensionTest { + + @Test + void testAllTestsRunWhenAfterEachSucceeds() { + StopOnAfterEachFailureExtension.resetGlobalState(); + + var results = EngineTestKit + .engine("junit-jupiter") + .selectors(DiscoverySelectors.selectClass( + TestClassWithSuccessfulAfterEach.class)) + .configurationParameter("junit.jupiter.conditions.deactivate", "org.junit.*DisabledCondition") + .execute(); + + results.testEvents() + .assertStatistics(stats -> stats.started(3).succeeded(3).skipped(0)); + } + + @Test + void testAfterEachTimeoutDetected() { + StopOnAfterEachFailureExtension.resetGlobalState(); + + var results = EngineTestKit + .engine("junit-jupiter") + .selectors(DiscoverySelectors.selectClass( + TestClassWithTimeoutInAfterEach.class)) + .configurationParameter("junit.jupiter.conditions.deactivate", "org.junit.*DisabledCondition") + .execute(); + + results.testEvents() + .assertStatistics(stats -> stats.started(1).skipped(1).failed(1)); + } + + /** + * Test class where @AfterEach always succeeds. + */ + @Disabled("https://issues.apache.org/jira/browse/IGNITE-28031 Only for EngineTestKit execution") + @ExtendWith(StopOnAfterEachFailureExtension.class) + static class TestClassWithSuccessfulAfterEach { + @AfterEach + void cleanup() { + // Always succeeds. + } + + @Test + void testA() { + } + + @Test + void testB() { + } + + @Test + void testC() { + } + } + + /** + * Test class where @AfterEach times out. + */ + @Disabled("https://issues.apache.org/jira/browse/IGNITE-28031 Only for EngineTestKit execution") + @ExtendWith(StopOnAfterEachFailureExtension.class) + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) + static class TestClassWithTimeoutInAfterEach { + @AfterEach + @Timeout(1) + void cleanup() throws Exception { + Thread.sleep(2000); + } + + @Test + @Order(1) + void testOne() { + // First test passes but cleanup times out. + } + + @Test + @Order(2) + void testTwo() { + // Should be skipped. + } + } +} diff --git a/modules/core/src/testFixtures/java/org/apache/ignite/internal/testframework/StopOnAfterEachFailureExtension.java b/modules/core/src/testFixtures/java/org/apache/ignite/internal/testframework/StopOnAfterEachFailureExtension.java new file mode 100644 index 000000000000..ad34e642c28f --- /dev/null +++ b/modules/core/src/testFixtures/java/org/apache/ignite/internal/testframework/StopOnAfterEachFailureExtension.java @@ -0,0 +1,112 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.ignite.internal.testframework; + +import org.apache.ignite.internal.logger.IgniteLogger; +import org.apache.ignite.internal.logger.Loggers; +import org.jetbrains.annotations.TestOnly; +import org.junit.jupiter.api.extension.AfterEachCallback; +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.ExtensionContext; +import org.opentest4j.TestAbortedException; + +/** + * JUnit 5 extension that stops all subsequent tests if an {@code @AfterEach} method fails. + * This is particularly useful for integration tests where a failed cleanup (like cluster shutdown timeout) + * can cause cascading failures in subsequent tests due to resource leaks (e.g., ports still in use). + */ +public class StopOnAfterEachFailureExtension implements + BeforeEachCallback, + AfterEachCallback, + ExecutionCondition { + + private static final IgniteLogger LOG = Loggers.forClass(StopOnAfterEachFailureExtension.class); + + /** Global flag to track failures across ALL test classes. */ + private static volatile boolean globalAfterEachFailed = false; + + /** Global failure message with details. */ + private static volatile String globalFailureMessage = null; + + /** + * Reset the global failure state. This is primarily for testing purposes. + */ + @TestOnly + static void resetGlobalState() { + globalAfterEachFailed = false; + globalFailureMessage = null; + } + + @Override + public void beforeEach(ExtensionContext context) throws Exception { + if (globalAfterEachFailed) { + String msg = "Test skipped because a previous test's @AfterEach cleanup failed. " + + "This prevents cascading failures across all integration tests. " + + "Original failure: " + globalFailureMessage; + LOG.warn("Skipping test '{}': {}", context.getDisplayName(), msg); + throw new TestAbortedException(msg); + } + } + + @Override + public void afterEach(ExtensionContext context) { + if (context.getExecutionException().isPresent()) { + Throwable exception = context.getExecutionException().get(); + + String testName = context.getDisplayName(); + String testClassName = context.getTestClass().map(Class::getName).orElse("Unknown"); + String failureMsg = buildFailureMessage(testName, testClassName, exception); + + LOG.error("CRITICAL: @AfterEach failed in test '{}' ({}), aborting ALL remaining integration tests!", + testName, testClassName); + LOG.error("Failure details: {}", failureMsg); + LOG.error("This typically indicates resource leak (e.g., ports not released). " + + "All subsequent tests will be skipped to prevent cascade failures."); + + globalAfterEachFailed = true; + globalFailureMessage = failureMsg; + } + } + + @Override + public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { + if (globalAfterEachFailed) { + return ConditionEvaluationResult.disabled( + "Test skipped because a previous test's @AfterEach cleanup failed in another test class. " + + "This prevents cascading failures across all integration tests. " + + "Original failure: " + globalFailureMessage + ); + } + + return ConditionEvaluationResult.enabled("No previous @AfterEach failures detected"); + } + + /** + * Builds a descriptive failure message. + */ + private String buildFailureMessage(String testName, String testClassName, Throwable exception) { + StringBuilder msg = new StringBuilder(); + msg.append("Test '").append(testName).append("' in class '").append(testClassName).append("' cleanup failed: "); + msg.append(exception.getClass().getSimpleName()).append(": "); + msg.append(exception.getMessage() != null ? exception.getMessage() : "(no message)"); + + return msg.toString(); + } +} diff --git a/modules/runner/src/testFixtures/java/org/apache/ignite/internal/ClusterPerTestIntegrationTest.java b/modules/runner/src/testFixtures/java/org/apache/ignite/internal/ClusterPerTestIntegrationTest.java index 322f9025a341..bee47154ca03 100644 --- a/modules/runner/src/testFixtures/java/org/apache/ignite/internal/ClusterPerTestIntegrationTest.java +++ b/modules/runner/src/testFixtures/java/org/apache/ignite/internal/ClusterPerTestIntegrationTest.java @@ -41,6 +41,7 @@ import org.apache.ignite.internal.network.InternalClusterNode; import org.apache.ignite.internal.storage.impl.TestMvTableStorage; import org.apache.ignite.internal.testframework.BaseIgniteAbstractTest; +import org.apache.ignite.internal.testframework.StopOnAfterEachFailureExtension; import org.apache.ignite.internal.testframework.WorkDirectory; import org.apache.ignite.internal.testframework.WorkDirectoryExtension; import org.apache.ignite.internal.testframework.junit.DumpThreadsOnTimeout; @@ -63,6 +64,7 @@ */ @SuppressWarnings("ALL") @ExtendWith(WorkDirectoryExtension.class) +@ExtendWith(StopOnAfterEachFailureExtension.class) public abstract class ClusterPerTestIntegrationTest extends BaseIgniteAbstractTest { private static final IgniteLogger LOG = Loggers.forClass(ClusterPerTestIntegrationTest.class);