Skip to content
Open
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
24 changes: 21 additions & 3 deletions src/main/java/io/vertx/junit5/VertxExtension.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,17 @@
package io.vertx.junit5;

import io.vertx.core.Vertx;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.extension.*;
import org.junit.jupiter.api.extension.DynamicTestInvocationContext;
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.InvocationInterceptor;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.api.extension.ParameterResolver;
import org.junit.jupiter.api.extension.ReflectiveInvocationContext;

import java.lang.reflect.Method;
import java.util.ArrayList;
Expand Down Expand Up @@ -164,12 +171,23 @@ public void interceptDynamicTest(Invocation<Void> invocation, DynamicTestInvocat
@Override
public void interceptAfterEachMethod(Invocation<Void> invocation, ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext) throws Throwable {
invocation.proceed();
joinActiveTestContexts(extensionContext);
joinActiveTestContexts(invocationContext, extensionContext);
}

private void joinActiveTestContexts(ExtensionContext extensionContext) throws Exception {
joinActiveTestContexts(null, extensionContext);
}

private void joinActiveTestContexts(ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext) throws Exception {
if (extensionContext.getExecutionException().isPresent()) {
return;
final boolean isNotInAfterEachMethod = Optional.ofNullable(invocationContext)
.map(ReflectiveInvocationContext::getExecutable)
.map(executable -> executable.getAnnotation(AfterEach.class))
.isEmpty();

if (isNotInAfterEachMethod) {
return;
}
Comment on lines +183 to +190
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might fix the problem for @AfterEach methods, but not for @AfterAll ones.

The extension creates a new VertxTestContext for each intercepted method. I think the criterion for waiting the completion of the context should be that the execution exception hasn't changed during the invocation

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no such problem for @AfterAll - the extension context which is passed into

  public void interceptAfterAllMethod(Invocation<Void> invocation, ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext) throws Throwable {

does not contain the exception thrown in the @Test method.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. Can you please rework the test so that it passes?

Copy link
Author

@alsin alsin Oct 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's possible - the thing is it has to fail the VertxTestContext in the test method so that the bug occurs in the @AfterEach method and we have something to test. Otherwise, if the test context is succeeded, the bug never reveals itself in the @AfterEach. It's kind of a paradox - I don't really know how to test this properly to be honest as the test should fail in order to pass 🤔 What I can do is remove the test completely but then any possible regression could be overlooked in the future. Can you suggest me something here, please?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I might have an idea how to write such a test. Let me try on it, I'll let you know soon if I find a way :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, looking forward to it!

Copy link
Author

@alsin alsin Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I think I've got a working solution. Actually, I didn't notice that there are already some other JUnit tests in the project that are excluded from running by the surefire plugin using the @Tag solution but are run programmatically from another tests without those tags. I decided to go a bit different way using the @Disabled annotation which is then deactivated in another test. Please, check it out! Thank you!

}

ContextList currentContexts = store(extensionContext).remove(TEST_CONTEXT_KEY, ContextList.class);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package io.vertx.junit5.tests;

import org.junit.jupiter.api.Test;
import org.junit.platform.engine.DiscoverySelector;
import org.junit.platform.engine.TestExecutionResult;
import org.junit.platform.engine.discovery.DiscoverySelectors;
import org.junit.platform.engine.support.descriptor.ClassSource;
import org.junit.platform.launcher.EngineFilter;
import org.junit.platform.launcher.Launcher;
import org.junit.platform.launcher.LauncherDiscoveryRequest;
import org.junit.platform.launcher.TestExecutionListener;
import org.junit.platform.launcher.TestIdentifier;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.launcher.core.LauncherFactory;
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
import org.junit.platform.launcher.listeners.TestExecutionSummary;

import java.util.concurrent.atomic.AtomicReference;

import static org.junit.jupiter.api.Assertions.assertEquals;

class RunAfterEachContextCheckTest {

@Test
void runsWaitForContextInAfterEachMethodTestAndChecksAfterAllSucceeded() {
// Select only the target class
final DiscoverySelector selector = DiscoverySelectors.selectClass(WaitForContextInAfterEachMethodTest.class);

// Collect a summary for assertions
final SummaryGeneratingListener summaryListener = new SummaryGeneratingListener();

// Capture the class container result specifically (to assert @AfterAll behavior)
final AtomicReference<TestExecutionResult.Status> classStatus = new AtomicReference<>();

final TestExecutionListener captureClassStatus = new TestExecutionListener() {
@Override
public void executionFinished(final TestIdentifier id, final TestExecutionResult result) {
id.getSource()
.ifPresent(source -> {
if (id.isContainer() && source instanceof ClassSource) {
final ClassSource cs = (ClassSource) source;
if (cs.getClassName().equals(WaitForContextInAfterEachMethodTest.class.getName())) {
classStatus.set(result.getStatus());
}
}
});
}
};

final LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request()
.selectors(selector)
.filters(EngineFilter.includeEngines("junit-jupiter"))
// Make @Disabled inert for this run
.configurationParameter(
"junit.jupiter.conditions.deactivate", "org.junit.*DisabledCondition")
// Make execution deterministic
.configurationParameter("junit.jupiter.execution.parallel.enabled", "false")
.build();

final Launcher launcher = LauncherFactory.create();
launcher.registerTestExecutionListeners(summaryListener, captureClassStatus);

launcher.execute(request);

final TestExecutionSummary summary = summaryListener.getSummary();

// The single test method intentionally fails
assertEquals(1, summary.getTestsFoundCount(), "Expect exactly one test discovered");
assertEquals(1, summary.getTestsFailedCount(), "The test should fail via context.failNow()");
assertEquals(0, summary.getContainersFailedCount(), "The test class container should not fail");

// Critically: the class container (where @AfterAll runs) must be SUCCESSFUL
assertEquals(TestExecutionResult.Status.SUCCESSFUL,
classStatus.get(),
"@AfterAll must complete its VertxTestContext without failure");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package io.vertx.junit5.tests;

import io.vertx.core.Vertx;
import io.vertx.junit5.VertxExtension;
import io.vertx.junit5.VertxTestContext;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import java.util.concurrent.atomic.AtomicBoolean;

@Disabled("Executed only via programmatic launcher from RunAfterEachContextCheckTest")
@ExtendWith({VertxExtension.class})
public class WaitForContextInAfterEachMethodTest {
static final AtomicBoolean afterTestContextAwaited = new AtomicBoolean(false);

@Test
void test(final VertxTestContext context) {
context.failNow(new RuntimeException("Failing test through context"));
}

@AfterAll
static void afterAll(final VertxTestContext context) {
if (afterTestContextAwaited.get()) {
context.completeNow();
} else {
context.failNow(new RuntimeException("afterTest context was not awaited"));
}
}

@AfterEach
void afterTest(final Vertx vertx, final VertxTestContext context) {
vertx.setTimer(100, id -> {
afterTestContextAwaited.set(true);
context.completeNow();
});
}

}