diff --git a/documentation/antora.yml b/documentation/antora.yml index d809a8ee47a5..080d8247d149 100644 --- a/documentation/antora.yml +++ b/documentation/antora.yml @@ -192,6 +192,7 @@ asciidoc: BeforeAllCallback: '{javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/extension/BeforeAllCallback.html[BeforeAllCallback]' BeforeClassTemplateInvocationCallback: '{javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/extension/BeforeClassTemplateInvocationCallback.html[BeforeClassTemplateInvocationCallback]' BeforeEachCallback: '{javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/extension/BeforeEachCallback.html[BeforeEachCallback]' + AsyncReturnValueHandler: '{javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/extension/AsyncReturnValueHandler.html[AsyncReturnValueHandler]' BeforeTestExecutionCallback: '{javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/extension/BeforeTestExecutionCallback.html[BeforeTestExecutionCallback]' ClassTemplateInvocationContext: '{javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/extension/ClassTemplateInvocationContext.html[ClassTemplateInvocationContext]' ClassTemplateInvocationContextProvider: '{javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/extension/ClassTemplateInvocationContextProvider.html[ClassTemplateInvocationContextProvider]' diff --git a/documentation/modules/ROOT/pages/writing-tests/parallel-execution.adoc b/documentation/modules/ROOT/pages/writing-tests/parallel-execution.adoc index 81a5dff837d3..a70954810e50 100644 --- a/documentation/modules/ROOT/pages/writing-tests/parallel-execution.adoc +++ b/documentation/modules/ROOT/pages/writing-tests/parallel-execution.adoc @@ -180,6 +180,63 @@ WARNING: Using `worker_thread_pool` is currently an _experimental_ feature. You' to give it a try and provide feedback to the JUnit team so they can improve and eventually xref:api-evolution.adoc[promote] this feature. +[[config-reactive-execution]] +=== Reactive Execution + +The _reactive_ (cooperative) execution lane schedules nodes by composing `CompletionStage`s instead +of parking a thread for every in-flight node. This works together with the +xref:writing-tests/test-classes-and-methods.adoc#async-test-methods[asynchronous test methods] and the +`AsyncInvocationInterceptor` extension API so that an asynchronous test body can run without holding +a platform thread for its entire duration: the worker only _triggers_ the async body and is freed +while it is awaited. + +There are two ways to enable the reactive lane; they are distinct and the choice has implications +for how your tests run: cooperative execution of existing thread-based parallelism, or a standalone +cooperative lane. + +==== Cooperative execution of thread-based parallelism + +Enable the classic thread-based parallel-execution feature _and_ ask it to run cooperatively: + +[source,java] +---- +junit.jupiter.execution.parallel.enabled = true +junit.jupiter.execution.parallel.reactive.enabled = true +---- + +Implications: + +* The full parallel-execution contract applies: `@Execution`, `@ResourceLock`, concurrency permits, + and the strategy-based parallelism (from `junit.jupiter.execution.parallel.config.*`). +* Children are still gated by their resolved execution mode (`CONCURRENT` vs `SAME_THREAD`). +* The only difference from the normal thread-pool lane is that a thread is not parked while an + async test method's `CompletionStage` is pending. + +==== Standalone cooperative lane (no thread configuration) + +Enable the cooperative lane independently of the thread-based parallel feature: + +[source,java] +---- +junit.jupiter.execution.reactive.enabled = true +---- + +Implications: + +* No parallel-execution contract is involved and no thread configuration is required. Concurrency + comes from the async test methods' own returned contexts (for example the common ForkJoin pool + behind `CompletableFuture.supplyAsync`); a small internal trigger pool (at most 4 threads) only + starts each async body and is shut down when the run completes. +* Execution mode is resolved per test as follows: + **explicit** `@Execution` on the method or an ancestor always wins; **otherwise** a test method + that returns an asynchronous completion signal (`CompletionStage`, `CompletableFuture`, or + `Future`) is treated as `CONCURRENT`, while a synchronous or `void` test method is treated as + `SAME_THREAD` and keeps running sequentially in discovery order. +* `@ResourceLock` and concurrency-permit semantics of the thread-based lane do not apply here. + +Both modes are opt-in and currently _experimental_. `junit.jupiter.execution.parallel.reactive.enabled` +requires `parallel.enabled = true`; `junit.jupiter.execution.reactive.enabled` does not. + [[config-strategies]] === Strategies diff --git a/documentation/modules/ROOT/pages/writing-tests/test-classes-and-methods.adoc b/documentation/modules/ROOT/pages/writing-tests/test-classes-and-methods.adoc index a418ecf4f580..8f25be88d506 100644 --- a/documentation/modules/ROOT/pages/writing-tests/test-classes-and-methods.adoc +++ b/documentation/modules/ROOT/pages/writing-tests/test-classes-and-methods.adoc @@ -2,9 +2,9 @@ Test methods and lifecycle methods may be declared locally within the current test class, inherited from superclasses, or inherited from interfaces (see -xref:writing-tests/test-interfaces-and-default-methods.adoc[]). In addition, test methods and -lifecycle methods must not be `abstract` and must not return a value (except `@TestFactory` -methods which are required to return a value). +xref:writing-tests/test-interfaces-and-default-methods.adoc[]). In addition, they must not be +`abstract` and must not return a value (except xref:#async-test-methods[asynchronous return types] +and `@TestFactory` methods which are required to return a value). [NOTE] .Class and method visibility @@ -105,3 +105,126 @@ to support skipping delays during tests and gain control over virtual time and d ---- include::example$kotlin/example/KotlinCoroutinesRunTestDemo.kt[tags=user_guide] ---- + +[[async-test-methods]] +== Asynchronous Test Methods + +In Java, a `@Test` method may return a +https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/concurrent/CompletionStage.html[`CompletionStage`] +-- for example a +https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/concurrent/CompletableFuture.html[`CompletableFuture`] +or a +https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/concurrent/Future.html[`Future`]. + +The test is only considered finished once the returned asynchronously-completable signal completes: + +[source,java,indent=0] +.A test method that completes asynchronously +---- +import static java.util.concurrent.CompletableFuture.completedFuture; + +import java.util.concurrent.CompletionStage; + +@Test +CompletionStage myTest() { + // ... start asynchronous work ... + return completedFuture("done"); +} +---- + +The payload of the returned signal is ignored; only its completion is awaited. If the signal +completes exceptionally, the test is reported as failed with the corresponding throwable. + +NOTE: `@Timeout` -- and any other `InterceptingExecutableInvoker`-based interceptor -- applies to +the _entire_ asynchronous work, not merely the synchronous call that returns the +`CompletionStage`. See xref:writing-tests/timeouts.adoc[Testing Timeouts]. + +As an alternative to returning a `CompletionStage`, you may write a `suspend` function as shown in +xref:#kotlin-coroutines[Kotlin Coroutines], which JUnit internally executes via `runBlocking`. + +Lifecycle methods -- `@BeforeAll`, `@AfterAll`, `@BeforeEach`, and `@AfterEach` -- may likewise +return a `CompletionStage` (or `CompletableFuture`/`Future`). Their completion is awaited before the +next phase proceeds, so ordering is preserved: the test body only runs after the `@BeforeEach` +stage completes, and the test result is only reported after the `@AfterEach` stage completes. + +[source,java,indent=0] +.A lifecycle method that completes asynchronously +---- +import static java.util.concurrent.CompletableFuture.completedFuture; + +import java.util.concurrent.CompletionStage; + +@BeforeEach +CompletionStage setUp() { + // ... start asynchronous setup ... + return completedFuture(null); +} +---- + +As with test methods, `@Timeout` on a lifecycle method applies to the entire asynchronous work, not +merely the synchronous call that returns the `CompletionStage`. + +[#custom-async-return-types] +=== Custom Asynchronous Return Types + +By default only the JRE types `CompletionStage` and `Future` are recognized as asynchronously-completable +return types. If you use a custom promise-like type (for example a `MyPromise`), you can teach the +engine to recognize and await it by implementing the experimental {AsyncReturnValueHandler} `Extension` +SPI: + +[source,java,indent=0] +.A handler that maps a custom promise-like type to a CompletionStage +---- +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.concurrent.CompletionStage; + +import org.junit.jupiter.api.extension.AsyncReturnValueHandler; + +class MyAsyncReturnValueHandler implements AsyncReturnValueHandler { + + @Override + public boolean supports(Type genericReturnType, AnnotatedElement annotatedElement) { + if (genericReturnType instanceof ParameterizedType parameterizedType) { + return MyPromise.class.isAssignableFrom((Class) parameterizedType.getRawType()); + } + return MyPromise.class == genericReturnType; + } + + @Override + public CompletionStage toCompletionStage(Object returnedValue) { + return ((MyPromise) returnedValue).asCompletionStage(); + } +} +---- + +A handler can be registered in one of two ways: + +* _Globally_ via the `ServiceLoader` mechanism (that is, listed under + `META-INF/services/org.junit.jupiter.api.extension.Extension`), in which case it applies to all + tests and is loaded during discovery as an attribute:auto-detected[] extension. +* _Scoped_ to a test method or class via `@ExtendWith(MyAsyncReturnValueHandler.class)`, or through a + composed indirection annotation, for example: + +[source,java,indent=0] +.A composed indirection annotation for a custom return type +---- +@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith(MyAsyncReturnValueHandler.class) +@interface MapMyPromise { +} + +@Test +@MapMyPromise +MyPromise myTest() { + return MyPromise.completed("done"); +} +---- + +NOTE: A handler must be recognizable _during discovery_ in order for the engine to classify a method +with a custom return type as a test. This is the case for a globally (via the `ServiceLoader`) +registered handler and for a handler registered on the test method or class via `{ExtendWith}`. A +handler registered only through a `@RegisterExtension` field is not visible during discovery, so a +test method whose return type is recognized only that way is rejected and not executed. diff --git a/documentation/modules/ROOT/partials/release-notes/release-notes-6.2.0-M1.adoc b/documentation/modules/ROOT/partials/release-notes/release-notes-6.2.0-M1.adoc index 2ca0b5e52cde..f425df538d54 100644 --- a/documentation/modules/ROOT/partials/release-notes/release-notes-6.2.0-M1.adoc +++ b/documentation/modules/ROOT/partials/release-notes/release-notes-6.2.0-M1.adoc @@ -41,6 +41,12 @@ repository on GitHub. * `ConsoleLauncher` with `--details=verbose` now reports on each test atomically and indents its output correctly when tests are executed in parallel. Previously, the reported output from different tests could interleave. +* New `*Async` methods were added to the hierarchical execution SPI. The + `org.junit.platform.engine.support.hierarchical` package now offers + `AsyncTestExecution`, a non-blocking `ReactiveResourceGate` + (`ResourceLock#acquireAsync()`), and `submitAsync`/`invokeAllAsync` on + `HierarchicalTestExecutorService`, enabling reactive (e.g. `CompletionStage`-based) + test execution without parking threads. [[v6.2.0-M1-junit-jupiter]] === JUnit Jupiter @@ -71,6 +77,38 @@ repository on GitHub. and `@DisabledOnOs`. * Failures caused by `@Timeout` expirations now include a hint about enabling xref:writing-tests/timeouts.adoc#debugging-thread-dump[thread dumps]. +* A `@Test` method may now return type:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/concurrent/CompletionStage.html[`CompletionStage`] + (or a `CompletableFuture` or `Future`). The test is considered finished only once + the returned signal completes, and `@Timeout` applies to the entire asynchronous + work rather than only the synchronous method call. Lifecycle methods + (`@BeforeAll`, `@AfterAll`, `@BeforeEach`, `@AfterEach`) may also return such a signal, whose + completion is awaited before the next phase proceeds. See + xref:writing-tests/test-classes-and-methods.adoc#async-test-methods[Test Methods]. +* New `AsyncInvocationInterceptor` API that allows `Extension`s to intercept test, lifecycle, and + dynamic-test invocations without blocking a thread while the intercepted method performs + asynchronous work. The legacy `InvocationInterceptor` remains fully supported and is + automatically adapted to the asynchronous invocation pipeline.* New experimental `EarlyExtension` SPI and + `AsyncReturnValueHandler` extension API that let the engine recognize user-defined asynchronous + return types (for example a `MyPromise`) and await them by mapping the returned value to a + `CompletionStage`. A handler can be registered globally via the `ServiceLoader` (auto-detection) or + on a test method/class via `@ExtendWith`, including through a composed indirection annotation. + See xref:writing-tests/test-classes-and-methods.adoc#custom-async-return-types[Custom Asynchronous Return Types]. +* `@Timeout` now applies to the whole asynchronous duration of a test method that returns a + `CompletionStage`/`CompletableFuture`/`Future`: the awaited body is bounded via + `CompletableFuture.orTimeout`, and for `SEPARATE_THREAD` the completing thread is interrupted on + expiry where possible (falling back to a non-preemptive timeout otherwise, so a timed-out test is + never reported as passed). +* New experimental *reactive execution lane*: when `junit.jupiter.execution.parallel.reactive.enabled=true` + is set alongside `junit.jupiter.execution.parallel.enabled=true`, the thread-based parallel lane + schedules nodes cooperatively via `CompletionStage` composition, so waits on concurrency permits and + resource locks hand off the worker thread instead of parking it. +* New experimental *standalone cooperative lane*: `junit.jupiter.execution.reactive.enabled=true` + enables cooperative execution without requiring the thread-based parallel feature or any thread + configuration. An async-returning test method (`CompletionStage`/`CompletableFuture`/`Future`) is + implicitly `CONCURRENT` and overlaps with other async tests without a dedicated thread, while + synchronous/`void` test methods are implicitly `SAME_THREAD` and run sequentially in discovery + order. An explicit `@Execution` always wins. See + xref:writing-tests/parallel-execution.adoc#config-reactive-execution[Reactive Execution]. [[v6.2.0-M1-junit-vintage]] diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AfterAll.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AfterAll.java index e1586a4f68a4..00cd8eaedd31 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AfterAll.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AfterAll.java @@ -35,8 +35,10 @@ * *

Method Signatures

* - *

{@code @AfterAll} methods must have a {@code void} return type and must - * be {@code static} unless the test class is annotated with + *

{@code @AfterAll} methods must have a {@code void} return type, or return + * a {@link java.util.concurrent.CompletionStage}/{@link java.util.concurrent.CompletableFuture}/ + * {@link java.util.concurrent.Future} whose completion is awaited before the test class is + * reported as finished, and must be {@code static} unless the test class is annotated with * {@link TestInstance @TestInstance(Lifecycle.PER_CLASS)}. In addition, * {@code @AfterAll} methods may optionally declare parameters to be resolved by * {@link org.junit.jupiter.api.extension.ParameterResolver ParameterResolvers}. diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AfterEach.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AfterEach.java index d2da256acacc..3aa0026d4475 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AfterEach.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AfterEach.java @@ -28,8 +28,11 @@ * *

Method Signatures

* - *

{@code @AfterEach} methods must have a {@code void} return type and must - * not be {@code static}. In addition, {@code @AfterEach} methods may optionally + *

{@code @AfterEach} methods must have a {@code void} return type, or return + * a {@link java.util.concurrent.CompletionStage}/{@link java.util.concurrent.CompletableFuture}/ + * {@link java.util.concurrent.Future} whose completion is awaited before the associated test is + * reported as finished, and must not be {@code static}. In addition, {@code @AfterEach} methods may + * optionally * declare parameters to be resolved by * {@link org.junit.jupiter.api.extension.ParameterResolver ParameterResolvers}. * diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/BeforeAll.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/BeforeAll.java index 88fdb7d7ddb5..052275f3f4c6 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/BeforeAll.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/BeforeAll.java @@ -35,8 +35,10 @@ * *

Method Signatures

* - *

{@code @BeforeAll} methods must have a {@code void} return type and must - * be {@code static} unless the test class is annotated with + *

{@code @BeforeAll} methods must have a {@code void} return type, or return + * a {@link java.util.concurrent.CompletionStage}/{@link java.util.concurrent.CompletableFuture}/ + * {@link java.util.concurrent.Future} whose completion is awaited before the test class is + * executed, and must be {@code static} unless the test class is annotated with * {@link TestInstance @TestInstance(Lifecycle.PER_CLASS)}. In addition, * {@code @BeforeAll} methods may optionally declare parameters to be resolved by * {@link org.junit.jupiter.api.extension.ParameterResolver ParameterResolvers}. diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/BeforeEach.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/BeforeEach.java index 0863ca6954a5..1878613b7487 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/BeforeEach.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/BeforeEach.java @@ -28,8 +28,10 @@ * *

Method Signatures

* - *

{@code @BeforeEach} methods must have a {@code void} return type and must - * not be {@code static}. In addition, {@code @BeforeEach} methods may optionally + *

{@code @BeforeEach} methods must have a {@code void} return type, or return + * a {@link java.util.concurrent.CompletionStage}/{@link java.util.concurrent.CompletableFuture}/ + * {@link java.util.concurrent.Future} whose completion is awaited before the associated test is + * executed, and must not be {@code static}. In addition, {@code @BeforeEach} methods may optionally * declare parameters to be resolved by * {@link org.junit.jupiter.api.extension.ParameterResolver ParameterResolvers}. * diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/Constants.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/Constants.java index f1c38975ed51..9a8812f59c76 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/Constants.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/Constants.java @@ -191,6 +191,35 @@ public final class Constants { */ public static final String PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME = "junit.jupiter.execution.parallel.enabled"; + /** + * Property name used to enable the reactive (cooperative) execution lane: + * {@value} + * + *

When enabled alongside {@link #PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME}, + * the engine schedules nodes cooperatively via completion stages instead of + * one thread per in-flight node. By default this is disabled. + * + * @since 6.2 + */ + @API(status = EXPERIMENTAL, since = "6.2") + public static final String PARALLEL_EXECUTION_REACTIVE_PROPERTY_NAME = "junit.jupiter.execution.parallel.reactive.enabled"; + + /** + * Property name used to enable the standalone cooperative execution lane: + * {@value} + * + *

Unlike {@link #PARALLEL_EXECUTION_REACTIVE_PROPERTY_NAME}, this does not + * require {@link #PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME} to be enabled: it + * activates the cooperative (reactive) lane so that test methods returning an + * asynchronous completion signal overlap without a dedicated thread, while + * synchronous test methods keep running sequentially in discovery order. By + * default this is disabled. + * + * @since 6.2 + */ + @API(status = EXPERIMENTAL, since = "6.2") + public static final String JUPITER_EXECUTION_REACTIVE_PROPERTY_NAME = "junit.jupiter.execution.reactive.enabled"; + /** * Property name used to set the default test execution mode: {@value} * diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/Test.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/Test.java index 5e030f70d698..3a0b2d5b4306 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/Test.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/Test.java @@ -26,7 +26,9 @@ * method. * *

{@code @Test} methods must not be {@code private} or {@code static} and - * must not return a value. + * either return {@code void} or an async-completable + * {@link java.util.concurrent.CompletionStage}/{@link java.util.concurrent.CompletableFuture}/ + * {@link java.util.concurrent.Future}, whose completion is awaited to determine the test's outcome. * *

{@code @Test} methods may optionally declare parameters to be resolved by * {@link org.junit.jupiter.api.extension.ParameterResolver ParameterResolvers}. diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/extension/AsyncInvocationInterceptor.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/extension/AsyncInvocationInterceptor.java new file mode 100644 index 000000000000..5a7ada6b9ea5 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/extension/AsyncInvocationInterceptor.java @@ -0,0 +1,249 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.extension; + +import static org.apiguardian.api.API.Status.EXPERIMENTAL; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.concurrent.CompletionStage; + +import org.apiguardian.api.API; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.api.TestTemplate; + +/** + * {@code AsyncInvocationInterceptor} defines the API for {@link Extension + * Extensions} that wish to intercept calls to test code without blocking a + * thread while an asynchronously-completable test or lifecycle method runs. + * + *

This interface is the asynchronous counterpart of {@link InvocationInterceptor}. + * Each interception method receives an {@link AsyncInvocation} and returns a + * {@link CompletionStage} that completes when the interceptor has no more work + * to do. This allows the engine to suspend and resume the execution lane + * without parking a platform thread while awaiting a test or lifecycle method + * that returns e.g. a {@link CompletionStage}. + * + *

Invocation Contract

+ * + *

Each method in this class must return a {@link CompletionStage} that is + * completed when the {@link AsyncInvocation#proceedAsync() proceedAsync()} on + * the supplied invocation has been invoked exactly once. Otherwise, the + * enclosing test or container will be reported as failed. + * + *

The default implementation returns {@link AsyncInvocation#proceedAsync() + * proceedAsync()} on the supplied {@linkplain AsyncInvocation invocation}. + * + *

Constructor Requirements

+ * + *

Consult the documentation in {@link Extension} for details on + * constructor requirements. + * + * @since 6.2 + * @see InvocationInterceptor + * @see AsyncInvocation + * @see ReflectiveInvocationContext + * @see ExtensionContext + */ +@API(status = EXPERIMENTAL, since = "6.2") +public interface AsyncInvocationInterceptor extends TestInstantiationAwareExtension { + + /** + * Intercept the invocation of a test class constructor. + * + *

Note that the test class may not have been initialized + * (static initialization) when this method is invoked. + * + * @param invocation the invocation that is being intercepted; never + * {@code null} + * @param invocationContext the context of the invocation that is being + * intercepted; never {@code null} + * @param extensionContext the current extension context; never {@code null} + * @param the result type + * @return a completion stage signaling that the interceptor has finished; + * never {@code null} + */ + default CompletionStage interceptTestClassConstructorAsync(AsyncInvocation invocation, + ReflectiveInvocationContext> invocationContext, ExtensionContext extensionContext) { + return invocation.proceedAsync(); + } + + /** + * Intercept the invocation of a {@link BeforeAll @BeforeAll} method. + * + * @param invocation the invocation that is being intercepted; never + * {@code null} + * @param invocationContext the context of the invocation that is being + * intercepted; never {@code null} + * @param extensionContext the current extension context; never {@code null} + * @return a completion stage signaling that the interceptor has finished; + * never {@code null} + */ + default CompletionStage interceptBeforeAllMethodAsync(AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + return invocation.proceedAsync(); + } + + /** + * Intercept the invocation of a {@link BeforeEach @BeforeEach} method. + * + * @param invocation the invocation that is being intercepted; never + * {@code null} + * @param invocationContext the context of the invocation that is being + * intercepted; never {@code null} + * @param extensionContext the current extension context; never {@code null} + * @return a completion stage signaling that the interceptor has finished; + * never {@code null} + */ + default CompletionStage interceptBeforeEachMethodAsync(AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + return invocation.proceedAsync(); + } + + /** + * Intercept the invocation of a {@link Test @Test} method. + * + * @param invocation the invocation that is being intercepted; never + * {@code null} + * @param invocationContext the context of the invocation that is being + * intercepted; never {@code null} + * @param extensionContext the current extension context; never {@code null} + * @return a completion stage signaling that the interceptor has finished; + * never {@code null} + */ + default CompletionStage interceptTestMethodAsync(AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + return invocation.proceedAsync(); + } + + /** + * Intercept the invocation of a {@link TestFactory @TestFactory} method, + * such as a {@link org.junit.jupiter.api.RepeatedTest @RepeatedTest} or + * {@code @ParameterizedTest} method. + * + * @param invocation the invocation that is being intercepted; never + * {@code null} + * @param invocationContext the context of the invocation that is being + * intercepted; never {@code null} + * @param extensionContext the current extension context; never {@code null} + * @param the result type + * @return a completion stage providing the result of the invocation; + * never {@code null} + */ + default CompletionStage interceptTestFactoryMethodAsync( + AsyncInvocation invocation, ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext) { + return invocation.proceedAsync(); + } + + /** + * Intercept the invocation of a {@link TestTemplate @TestTemplate} method. + * + * @param invocation the invocation that is being intercepted; never + * {@code null} + * @param invocationContext the context of the invocation that is being + * intercepted; never {@code null} + * @param extensionContext the current extension context; never {@code null} + * @return a completion stage signaling that the interceptor has finished; + * never {@code null} + */ + default CompletionStage interceptTestTemplateMethodAsync(AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + return invocation.proceedAsync(); + } + + /** + * Intercept the invocation of a {@link DynamicTest}. + * + * @param invocation the invocation that is being intercepted; never + * {@code null} + * @param invocationContext the context of the invocation that is being + * intercepted; never {@code null} + * @param extensionContext the current extension context; never {@code null} + * @return a completion stage signaling that the interceptor has finished; + * never {@code null} + */ + default CompletionStage interceptDynamicTestAsync(AsyncInvocation invocation, + DynamicTestInvocationContext invocationContext, ExtensionContext extensionContext) { + return invocation.proceedAsync(); + } + + /** + * Intercept the invocation of an {@link AfterEach @AfterEach} method. + * + * @param invocation the invocation that is being intercepted; never + * {@code null} + * @param invocationContext the context of the invocation that is being + * intercepted; never {@code null} + * @param extensionContext the current extension context; never {@code null} + * @return a completion stage signaling that the interceptor has finished; + * never {@code null} + */ + default CompletionStage interceptAfterEachMethodAsync(AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + return invocation.proceedAsync(); + } + + /** + * Intercept the invocation of an {@link AfterAll @AfterAll} method. + * + * @param invocation the invocation that is being intercepted; never + * {@code null} + * @param invocationContext the context of the invocation that is being + * intercepted; never {@code null} + * @param extensionContext the current extension context; never {@code null} + * @return a completion stage signaling that the interceptor has finished; + * never {@code null} + */ + default CompletionStage interceptAfterAllMethodAsync(AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + return invocation.proceedAsync(); + } + + /** + * An invocation that returns a result, possibly after asynchronous work, + * and may throw a {@link Throwable}. + * + *

This interface is not intended to be implemented by clients. + * + * @param the result type + * @since 6.2 + */ + @API(status = EXPERIMENTAL, since = "6.2") + interface AsyncInvocation { + + /** + * Proceed with this invocation asynchronously. + * + * @return a completion stage providing the result of this invocation; + * potentially {@code null} + */ + CompletionStage proceedAsync(); + + /** + * Explicitly skip this invocation. + * + *

This allows to bypass the check that {@link #proceedAsync()} must + * be called at least once. The default implementation does nothing. + */ + default void skip() { + // do nothing + } + } + +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/extension/AsyncReturnValueHandler.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/extension/AsyncReturnValueHandler.java new file mode 100644 index 000000000000..d38150fdf0fc --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/extension/AsyncReturnValueHandler.java @@ -0,0 +1,104 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.extension; + +import static org.apiguardian.api.API.Status.EXPERIMENTAL; + +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Type; +import java.util.concurrent.CompletionStage; + +import org.apiguardian.api.API; +import org.jspecify.annotations.Nullable; + +/** + * {@code AsyncReturnValueHandler} enables support for custom asynchronous + * return types in test methods. + * + *

By default the engine only treats the JRE types {@link CompletionStage} + * and {@link java.util.concurrent.Future} as asynchronous return types. An + * {@code AsyncReturnValueHandler} allows a user-defined promise-like type + * (for example {@code MyPromise}) to be awaited by mapping the value + * returned from a test method to a {@link CompletionStage}. + * + *

Implementations can be registered automatically via the + * {@link java.util.ServiceLoader} mechanism (in which case they apply + * globally to all tests) or declaratively on a test method + * or class via {@link ExtendWith @ExtendWith}. When registered through + * {@code @ExtendWith}, an indirection annotation is also possible, for example: + * + * {@snippet : + * @Retention(RUNTIME) + * @Target({ METHOD, ANNOTATION_TYPE }) + * @ExtendWith(MyAsyncReturnValueHandler.class) + * public @interface MyPromise { + * } + * + * @Test + * @MyPromise + * MyPromise myTest() { ... } + * } + * + *

Because this interface extends {@link EarlyExtension}, implementations are + * loaded during discovery so that the engine can recognize the custom + * return type while deciding whether a method is a test. + * + *

Requirements

+ * + * + * + * @since 6.2 + */ +@API(status = EXPERIMENTAL, since = "6.2") +public interface AsyncReturnValueHandler extends EarlyExtension { + + /** + * Determine whether this handler supports the supplied generic return type + * of a test method. + * + *

This method is a pure query and must not have side effects. + * It may be called during discovery, before any test instance or + * {@link ExtensionContext} exists. + * + *

When the engine converts an already-returned value (rather than + * inspecting a method declaration) the {@code annotatedElement} may be + * {@code null}; implementations must not dereference it in that case. + * + * @param genericReturnType the generic return type of the test method, or + * the raw class of a returned value; never {@code null} + * @param annotatedElement the test method itself, or {@code null} when not + * available + * @return {@code true} if this handler can map a value of the supplied type + * to a {@link CompletionStage} + */ + boolean supports(Type genericReturnType, @Nullable AnnotatedElement annotatedElement); + + /** + * Map the value actually returned from a test method to a + * {@link CompletionStage} to be awaited. + * + *

This method is only invoked for values whose type was reported as + * supported by {@link #supports(Type, AnnotatedElement)}. + * + * @param returnedValue the value returned from the test method; never + * {@code null} + * @return the {@link CompletionStage} to await; never {@code null} + */ + CompletionStage toCompletionStage(Object returnedValue); + +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/extension/EarlyExtension.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/extension/EarlyExtension.java new file mode 100644 index 000000000000..6b41702b5feb --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/extension/EarlyExtension.java @@ -0,0 +1,50 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api.extension; + +import static org.apiguardian.api.API.Status.EXPERIMENTAL; + +import java.util.ServiceLoader; + +import org.apiguardian.api.API; + +/** + * An {@code EarlyExtension} is a special kind of {@link Extension} that is + * available before a test {@link ExtensionContext} exists. + * + *

Regular {@link Extension Extensions} are only discovered and instantiated + * once the engine starts executing the discovery result. An + * {@code EarlyExtension}, in contrast, is loaded during discovery so + * that the engine can consult it while it is still deciding what is and is not + * a test. + * + *

Implementations can be registered automatically via the + * {@link ServiceLoader} mechanism by listing them under the standard + * {@code META-INF/services/org.junit.jupiter.api.extension.Extension} file, + * in which case they are filtered by type and only loaded when the engine is + * asked to auto-detect extensions. Alternatively, when an implementation is + * only needed at runtime (for example to convert a returned value), it can be + * registered declaratively via {@link ExtendWith @ExtendWith} or + * {@link RegisterExtension @RegisterExtension} just like any other + * {@link Extension}. + * + *

Lifecycle

+ * + *

Implementations must be stateless with respect to test execution, + * and their discovery-relevant methods must return the same result regardless + * of when they are called. Implementations must have a {@code public} default + * constructor when loaded via the {@code ServiceLoader}. + * + * @since 6.2 + */ +@API(status = EXPERIMENTAL, since = "6.2") +public interface EarlyExtension extends Extension { +} diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/extension/InvocationInterceptor.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/extension/InvocationInterceptor.java index 8f55beecb0df..24082ae71d8b 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/extension/InvocationInterceptor.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/extension/InvocationInterceptor.java @@ -56,6 +56,7 @@ * for details. * * @since 5.5 + * @see AsyncInvocationInterceptor * @see Invocation * @see ReflectiveInvocationContext * @see ExtensionContext @@ -226,6 +227,7 @@ default void interceptAfterAllMethod(Invocation<@Nullable Void> invocation, * * @param the result type * @since 5.5 + * @see AsyncInvocationInterceptor.AsyncInvocation */ @API(status = STABLE, since = "5.10") interface Invocation { diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java index 85e7a48dcbd4..1da3fed688d2 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java @@ -23,6 +23,7 @@ import org.junit.jupiter.engine.discovery.DiscoverySelectorResolver; import org.junit.jupiter.engine.execution.JupiterEngineExecutionContext; import org.junit.jupiter.engine.execution.LauncherStoreFacade; +import org.junit.jupiter.engine.extension.EarlyExtensionRegistry; import org.junit.jupiter.engine.support.JupiterThrowableCollectorFactory; import org.junit.platform.engine.EngineDiscoveryRequest; import org.junit.platform.engine.ExecutionRequest; @@ -71,7 +72,9 @@ public TestDescriptor discover(EngineDiscoveryRequest discoveryRequest, UniqueId JupiterConfiguration configuration = new CachingJupiterConfiguration( new DefaultJupiterConfiguration(discoveryRequest.getConfigurationParameters(), discoveryRequest.getOutputDirectoryCreator(), issueReporter)); - JupiterEngineDescriptor engineDescriptor = new JupiterEngineDescriptor(uniqueId, configuration); + EarlyExtensionRegistry earlyExtensionRegistry = EarlyExtensionRegistry.create(configuration); + JupiterEngineDescriptor engineDescriptor = new JupiterEngineDescriptor(uniqueId, configuration, + earlyExtensionRegistry); DiscoverySelectorResolver.resolveSelectors(discoveryRequest, engineDescriptor, issueReporter); return engineDescriptor; } @@ -79,7 +82,35 @@ public TestDescriptor discover(EngineDiscoveryRequest discoveryRequest, UniqueId @Override protected HierarchicalTestExecutorService createExecutorService(ExecutionRequest request) { JupiterConfiguration configuration = getJupiterConfiguration(request); - if (configuration.isParallelExecutionEnabled()) { + boolean parallelEnabled = configuration.isParallelExecutionEnabled(); + boolean parallelReactive = request.getConfigurationParameters() // + .getBoolean(Constants.PARALLEL_EXECUTION_REACTIVE_PROPERTY_NAME) // + .orElse(false); + boolean standaloneReactive = request.getConfigurationParameters() // + .getBoolean(Constants.JUPITER_EXECUTION_REACTIVE_PROPERTY_NAME) // + .orElse(false); + + if (parallelEnabled && parallelReactive) { + // Path A: classic thread-based parallelism, run cooperatively. The + // full parallel-execution contract (@Execution, @ResourceLock, + // concurrency limits) is honored; the lane just avoids parking a + // thread while an asynchronous test body is awaited. + // Option A (a fully non-blocking lane from the top of the hierarchy + // down) would be preferable, but is intentionally deferred here to + // limit the impact of this first round. + var prefixedParameters = new PrefixedConfigurationParameters(request.getConfigurationParameters(), + Constants.PARALLEL_CONFIG_PREFIX); + return ParallelHierarchicalTestExecutorServiceFactory.createReactive(prefixedParameters); + } + if (standaloneReactive) { + // Path B: standalone cooperative execution lane, independent of the + // thread-based parallel feature. Async-returning test methods overlap + // cooperatively without a dedicated thread; synchronous methods keep + // running sequentially in discovery order. No thread configuration is + // required -- concurrency comes from the methods' returned contexts. + return ParallelHierarchicalTestExecutorServiceFactory.createReactive(); + } + if (parallelEnabled) { return ParallelHierarchicalTestExecutorServiceFactory.create(new PrefixedConfigurationParameters( request.getConfigurationParameters(), Constants.PARALLEL_CONFIG_PREFIX)); } diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassBasedTestDescriptor.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassBasedTestDescriptor.java index 0bc2f0e0bf7b..c77815f8b143 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassBasedTestDescriptor.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassBasedTestDescriptor.java @@ -41,6 +41,7 @@ import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.TestInstance.Lifecycle; import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.AsyncReturnValueHandler; import org.junit.jupiter.api.extension.BeforeAllCallback; import org.junit.jupiter.api.extension.Extension; import org.junit.jupiter.api.extension.ExtensionConfigurationException; @@ -92,6 +93,7 @@ public abstract class ClassBasedTestDescriptor extends JupiterTestDescriptor private static final InterceptingExecutableInvoker executableInvoker = new InterceptingExecutableInvoker(); protected final ClassInfo classInfo; + protected final List asyncReturnValueHandlers; private @Nullable LifecycleMethods lifecycleMethods; @@ -99,18 +101,30 @@ public abstract class ClassBasedTestDescriptor extends JupiterTestDescriptor ClassBasedTestDescriptor(UniqueId uniqueId, Class testClass, Supplier displayNameSupplier, JupiterConfiguration configuration) { + this(uniqueId, testClass, displayNameSupplier, configuration, List.of()); + } + + ClassBasedTestDescriptor(UniqueId uniqueId, Class testClass, Supplier displayNameSupplier, + JupiterConfiguration configuration, List asyncReturnValueHandlers) { super(uniqueId, testClass, displayNameSupplier, ClassSource.from(testClass), configuration); + this.asyncReturnValueHandlers = asyncReturnValueHandlers; this.classInfo = new ClassInfo(testClass, configuration); - this.lifecycleMethods = new LifecycleMethods(this.classInfo); + this.lifecycleMethods = new LifecycleMethods(this.classInfo, this.asyncReturnValueHandlers); } ClassBasedTestDescriptor(UniqueId uniqueId, Class testClass, String displayName, JupiterConfiguration configuration) { + this(uniqueId, testClass, displayName, configuration, List.of()); + } + + ClassBasedTestDescriptor(UniqueId uniqueId, Class testClass, String displayName, + JupiterConfiguration configuration, List asyncReturnValueHandlers) { super(uniqueId, displayName, ClassSource.from(testClass), configuration); + this.asyncReturnValueHandlers = asyncReturnValueHandlers; this.classInfo = new ClassInfo(testClass, configuration); - this.lifecycleMethods = new LifecycleMethods(this.classInfo); + this.lifecycleMethods = new LifecycleMethods(this.classInfo, this.asyncReturnValueHandlers); } // --- TestClassAware ------------------------------------------------------ @@ -446,7 +460,7 @@ private void invokeBeforeAllMethods(JupiterEngineExecutionContext context) { for (Method method : requireLifecycleMethods().beforeAll) { throwableCollector.execute(() -> { try { - executableInvoker.invokeVoid(method, testInstance, extensionContext, registry, + executableInvoker.invokeAndAwait(method, testInstance, extensionContext, registry, InvocationInterceptor::interceptBeforeAllMethod); } catch (Throwable throwable) { @@ -474,7 +488,7 @@ private void invokeAfterAllMethods(JupiterEngineExecutionContext context) { requireLifecycleMethods().afterAll.forEach(method -> throwableCollector.execute(() -> { try { - executableInvoker.invokeVoid(method, testInstance, extensionContext, registry, + executableInvoker.invokeAndAwait(method, testInstance, extensionContext, registry, InvocationInterceptor::interceptAfterAllMethod); } catch (Throwable throwable) { @@ -547,7 +561,7 @@ private void invokeMethodInExtensionContext(Method method, ExtensionContext cont Object target = testInstances.findInstance(getTestClass()).orElseThrow( () -> new JUnitException("Failed to find instance for method: " + method.toGenericString())); - executableInvoker.invokeVoid(method, target, context, registry, interceptorCall); + executableInvoker.invokeAndAwait(method, target, context, registry, interceptorCall); } private LifecycleMethods requireLifecycleMethods() { @@ -588,14 +602,14 @@ private static class LifecycleMethods { private final List beforeEach; private final List afterEach; - LifecycleMethods(ClassInfo classInfo) { + LifecycleMethods(ClassInfo classInfo, List asyncReturnValueHandlers) { Class testClass = classInfo.testClass; boolean requireStatic = classInfo.lifecycle == Lifecycle.PER_METHOD; DiscoveryIssueReporter issueReporter = DiscoveryIssueReporter.collecting(discoveryIssues); - this.beforeAll = findBeforeAllMethods(testClass, requireStatic, issueReporter); - this.afterAll = findAfterAllMethods(testClass, requireStatic, issueReporter); - this.beforeEach = findBeforeEachMethods(testClass, issueReporter); - this.afterEach = findAfterEachMethods(testClass, issueReporter); + this.beforeAll = findBeforeAllMethods(testClass, requireStatic, issueReporter, asyncReturnValueHandlers); + this.afterAll = findAfterAllMethods(testClass, requireStatic, issueReporter, asyncReturnValueHandlers); + this.beforeEach = findBeforeEachMethods(testClass, issueReporter, asyncReturnValueHandlers); + this.afterEach = findAfterEachMethods(testClass, issueReporter, asyncReturnValueHandlers); } } diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassTestDescriptor.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassTestDescriptor.java index 0c5032304342..3b5d825f1716 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassTestDescriptor.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassTestDescriptor.java @@ -21,6 +21,7 @@ import java.util.function.UnaryOperator; import org.apiguardian.api.API; +import org.junit.jupiter.api.extension.AsyncReturnValueHandler; import org.junit.jupiter.api.extension.TestInstances; import org.junit.jupiter.api.parallel.ResourceLocksProvider; import org.junit.jupiter.engine.config.JupiterConfiguration; @@ -48,7 +49,13 @@ public class ClassTestDescriptor extends ClassBasedTestDescriptor { public static final String SEGMENT_TYPE = "class"; public ClassTestDescriptor(UniqueId uniqueId, Class testClass, JupiterConfiguration configuration) { - super(uniqueId, testClass, createDisplayNameSupplierForClass(testClass, configuration), configuration); + this(uniqueId, testClass, configuration, List.of()); + } + + public ClassTestDescriptor(UniqueId uniqueId, Class testClass, JupiterConfiguration configuration, + List asyncReturnValueHandlers) { + super(uniqueId, testClass, createDisplayNameSupplierForClass(testClass, configuration), configuration, + asyncReturnValueHandlers); } private ClassTestDescriptor(UniqueId uniqueId, Class testClass, String displayName, diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/JupiterEngineDescriptor.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/JupiterEngineDescriptor.java index 6180e9bef2a0..56e1a5caa7a2 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/JupiterEngineDescriptor.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/JupiterEngineDescriptor.java @@ -17,6 +17,7 @@ import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.engine.config.JupiterConfiguration; import org.junit.jupiter.engine.execution.JupiterEngineExecutionContext; +import org.junit.jupiter.engine.extension.EarlyExtensionRegistry; import org.junit.jupiter.engine.extension.MutableExtensionRegistry; import org.junit.platform.engine.EngineExecutionListener; import org.junit.platform.engine.UniqueId; @@ -31,16 +32,32 @@ public class JupiterEngineDescriptor extends EngineDescriptor implements Node findBeforeAllMethods(Class testClass, boolean requireStatic, DiscoveryIssueReporter issueReporter) { + return findBeforeAllMethods(testClass, requireStatic, issueReporter, List.of()); + } + + static List findBeforeAllMethods(Class testClass, boolean requireStatic, + DiscoveryIssueReporter issueReporter, List asyncReturnValueHandlers) { return findMethodsAndCheckStatic(testClass, requireStatic, BeforeAll.class, HierarchyTraversalMode.TOP_DOWN, - issueReporter); + issueReporter, asyncReturnValueHandlers); } static List findAfterAllMethods(Class testClass, boolean requireStatic, DiscoveryIssueReporter issueReporter) { + return findAfterAllMethods(testClass, requireStatic, issueReporter, List.of()); + } + + static List findAfterAllMethods(Class testClass, boolean requireStatic, + DiscoveryIssueReporter issueReporter, List asyncReturnValueHandlers) { return findMethodsAndCheckStatic(testClass, requireStatic, AfterAll.class, HierarchyTraversalMode.BOTTOM_UP, - issueReporter); + issueReporter, asyncReturnValueHandlers); } static List findBeforeEachMethods(Class testClass, DiscoveryIssueReporter issueReporter) { - return findMethodsAndCheckNonStatic(testClass, BeforeEach.class, HierarchyTraversalMode.TOP_DOWN, - issueReporter); + return findBeforeEachMethods(testClass, issueReporter, List.of()); + } + + static List findBeforeEachMethods(Class testClass, DiscoveryIssueReporter issueReporter, + List asyncReturnValueHandlers) { + return findMethodsAndCheckNonStatic(testClass, BeforeEach.class, HierarchyTraversalMode.TOP_DOWN, issueReporter, + asyncReturnValueHandlers); } static List findAfterEachMethods(Class testClass, DiscoveryIssueReporter issueReporter) { - return findMethodsAndCheckNonStatic(testClass, AfterEach.class, HierarchyTraversalMode.BOTTOM_UP, - issueReporter); + return findAfterEachMethods(testClass, issueReporter, List.of()); + } + + static List findAfterEachMethods(Class testClass, DiscoveryIssueReporter issueReporter, + List asyncReturnValueHandlers) { + return findMethodsAndCheckNonStatic(testClass, AfterEach.class, HierarchyTraversalMode.BOTTOM_UP, issueReporter, + asyncReturnValueHandlers); } static void validateNoClassTemplateInvocationLifecycleMethodsAreDeclared(Class testClass, @@ -88,7 +110,7 @@ static void validateClassTemplateInvocationLifecycleMethodsAreDeclaredCorrectly( findAllClassTemplateInvocationLifecycleMethods(testClass) // .forEach(isNotPrivateError(issueReporter) // .and(returnsPrimitiveVoid(issueReporter, - LifecycleMethodUtils::classTemplateInvocationLifecycleMethodAnnotationName)) // + LifecycleMethodUtils::classTemplateInvocationLifecycleMethodAnnotationName, List.of())) // .and(requireStatic ? isStatic(issueReporter, LifecycleMethodUtils::classTemplateInvocationLifecycleMethodAnnotationName) @@ -108,31 +130,32 @@ private static Stream findAllClassTemplateInvocationLifecycleMethods(Cla private static List findMethodsAndCheckStatic(Class testClass, boolean requireStatic, Class annotationType, HierarchyTraversalMode traversalMode, - DiscoveryIssueReporter issueReporter) { + DiscoveryIssueReporter issueReporter, List asyncReturnValueHandlers) { Condition additionalCondition = requireStatic ? isStatic(issueReporter, __ -> annotationType.getSimpleName()) : alwaysSatisfied(); return findMethodsAndCheckVoidReturnType(testClass, annotationType, traversalMode, issueReporter, - additionalCondition); + additionalCondition, asyncReturnValueHandlers); } private static List findMethodsAndCheckNonStatic(Class testClass, Class annotationType, HierarchyTraversalMode traversalMode, - DiscoveryIssueReporter issueReporter) { + DiscoveryIssueReporter issueReporter, List asyncReturnValueHandlers) { return findMethodsAndCheckVoidReturnType(testClass, annotationType, traversalMode, issueReporter, - isNotStatic(issueReporter, __ -> annotationType.getSimpleName())); + isNotStatic(issueReporter, __ -> annotationType.getSimpleName()), asyncReturnValueHandlers); } private static List findMethodsAndCheckVoidReturnType(Class testClass, Class annotationType, HierarchyTraversalMode traversalMode, - DiscoveryIssueReporter issueReporter, Condition additionalCondition) { + DiscoveryIssueReporter issueReporter, Condition additionalCondition, + List asyncReturnValueHandlers) { return findAnnotatedMethods(testClass, annotationType, traversalMode).stream() // .peek(isNotPrivateWarning(issueReporter, annotationType::getSimpleName).toConsumer()) // - .filter(returnsPrimitiveVoid(issueReporter, __ -> annotationType.getSimpleName()).and( - additionalCondition).toPredicate()) // + .filter(returnsPrimitiveVoid(issueReporter, __ -> annotationType.getSimpleName(), + asyncReturnValueHandlers).and(additionalCondition).toPredicate()) // .toList(); } @@ -172,12 +195,22 @@ private static Condition isNotPrivateWarning(DiscoveryIssueReporter issu } private static Condition returnsPrimitiveVoid(DiscoveryIssueReporter issueReporter, - Function annotationNameProvider) { - return issueReporter.createReportingCondition(method -> getReturnType(method) == void.class, method -> { - String message = "@%s method '%s' must not return a value.".formatted(annotationNameProvider.apply(method), - method.toGenericString()); - return createIssue(Severity.ERROR, message, method); - }); + Function annotationNameProvider, List asyncReturnValueHandlers) { + return issueReporter.createReportingCondition( + method -> hasVoidOrAsyncReturnType(method, asyncReturnValueHandlers), method -> { + String message = ("@%s method '%s' must return void or an async-completable return type " + + "(CompletionStage, CompletableFuture, or Future).").formatted( + annotationNameProvider.apply(method), method.toGenericString()); + return createIssue(Severity.ERROR, message, method); + }); + } + + private static boolean hasVoidOrAsyncReturnType(Method method, + List asyncReturnValueHandlers) { + if (getReturnType(method) == void.class) { + return true; + } + return AsyncReturnTypeSupport.isSupported(method, asyncReturnValueHandlers); } private static String classTemplateInvocationLifecycleMethodAnnotationName(Method method) { diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/MethodBasedTestDescriptor.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/MethodBasedTestDescriptor.java index 9a6ff0c0e087..39fabf1d350e 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/MethodBasedTestDescriptor.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/MethodBasedTestDescriptor.java @@ -30,9 +30,11 @@ import org.apiguardian.api.API; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.TestWatcher; +import org.junit.jupiter.api.parallel.ExecutionMode; import org.junit.jupiter.api.parallel.ResourceLocksProvider; import org.junit.jupiter.engine.config.JupiterConfiguration; import org.junit.jupiter.engine.execution.JupiterEngineExecutionContext; +import org.junit.jupiter.engine.support.AsyncReturnTypeSupport; import org.junit.platform.commons.JUnitException; import org.junit.platform.commons.logging.Logger; import org.junit.platform.commons.logging.LoggerFactory; @@ -76,6 +78,21 @@ public final Method getTestMethod() { return this.methodInfo.testMethod; } + // --- Node ---------------------------------------------------------------- + + @Override + protected ExecutionMode getDefaultExecutionMode() { + if (AsyncReturnTypeSupport.isFullySupported(getTestMethod().getReturnType())) { + // If a test method returns an asynchronously-completable signal, it + // is implicitly eligible to run concurrently with other tests: the + // cooperative (reactive) execution lane can overlap it with other + // async tests without parking a dedicated thread. An explicit + // @Execution mode (on the method or an ancestor) always wins. + return ExecutionMode.CONCURRENT; + } + return super.getDefaultExecutionMode(); + } + // --- TestDescriptor ------------------------------------------------------ @Override diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/NestedClassTestDescriptor.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/NestedClassTestDescriptor.java index ac1fee96dd9a..f2e290f9c4f4 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/NestedClassTestDescriptor.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/NestedClassTestDescriptor.java @@ -24,6 +24,7 @@ import org.apiguardian.api.API; import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.extension.AsyncReturnValueHandler; import org.junit.jupiter.api.extension.TestInstances; import org.junit.jupiter.api.parallel.ResourceLocksProvider; import org.junit.jupiter.engine.config.JupiterConfiguration; @@ -51,13 +52,20 @@ public class NestedClassTestDescriptor extends ClassBasedTestDescriptor { public NestedClassTestDescriptor(UniqueId uniqueId, Class testClass, Supplier>> enclosingInstanceTypes, JupiterConfiguration configuration) { + this(uniqueId, testClass, enclosingInstanceTypes, configuration, List.of()); + } + + public NestedClassTestDescriptor(UniqueId uniqueId, Class testClass, + Supplier>> enclosingInstanceTypes, JupiterConfiguration configuration, + List asyncReturnValueHandlers) { super(uniqueId, testClass, - createDisplayNameSupplierForNestedClass(enclosingInstanceTypes, testClass, configuration), configuration); + createDisplayNameSupplierForNestedClass(enclosingInstanceTypes, testClass, configuration), configuration, + asyncReturnValueHandlers); } private NestedClassTestDescriptor(UniqueId uniqueId, Class testClass, String displayName, - JupiterConfiguration configuration) { - super(uniqueId, testClass, displayName, configuration); + JupiterConfiguration configuration, List asyncReturnValueHandlers) { + super(uniqueId, testClass, displayName, configuration, asyncReturnValueHandlers); } // --- JupiterTestDescriptor ----------------------------------------------- @@ -65,7 +73,7 @@ private NestedClassTestDescriptor(UniqueId uniqueId, Class testClass, String @Override protected NestedClassTestDescriptor withUniqueId(UnaryOperator uniqueIdTransformer) { return new NestedClassTestDescriptor(uniqueIdTransformer.apply(getUniqueId()), getTestClass(), getDisplayName(), - configuration); + configuration, this.asyncReturnValueHandlers); } // --- TestDescriptor ------------------------------------------------------ diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/TestMethodTestDescriptor.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/TestMethodTestDescriptor.java index 99aa0ca67c9a..88d4696ebcc9 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/TestMethodTestDescriptor.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/TestMethodTestDescriptor.java @@ -19,6 +19,10 @@ import java.lang.reflect.Method; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutionException; import java.util.function.Supplier; import java.util.function.UnaryOperator; @@ -26,6 +30,8 @@ import org.junit.jupiter.api.TestInstance.Lifecycle; import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.AfterTestExecutionCallback; +import org.junit.jupiter.api.extension.AsyncInvocationInterceptor; +import org.junit.jupiter.api.extension.AsyncReturnValueHandler; import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.BeforeTestExecutionCallback; import org.junit.jupiter.api.extension.ExtensionContext; @@ -37,12 +43,16 @@ import org.junit.jupiter.api.extension.TestWatcher; import org.junit.jupiter.engine.config.JupiterConfiguration; import org.junit.jupiter.engine.execution.AfterEachMethodAdapter; +import org.junit.jupiter.engine.execution.AsyncInterceptingExecutableInvoker; +import org.junit.jupiter.engine.execution.AsyncInterceptingExecutableInvoker.AsyncVoidMethodInterceptorCall; import org.junit.jupiter.engine.execution.BeforeEachMethodAdapter; import org.junit.jupiter.engine.execution.InterceptingExecutableInvoker; import org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.ReflectiveInterceptorCall.VoidMethodInterceptorCall; import org.junit.jupiter.engine.execution.JupiterEngineExecutionContext; import org.junit.jupiter.engine.extension.ExtensionRegistry; import org.junit.jupiter.engine.extension.MutableExtensionRegistry; +import org.junit.jupiter.engine.support.AsyncReturnTypeSupport; +import org.junit.platform.commons.util.ExceptionUtils; import org.junit.platform.commons.util.UnrecoverableExceptions; import org.junit.platform.engine.TestDescriptor; import org.junit.platform.engine.TestExecutionResult; @@ -72,13 +82,17 @@ public class TestMethodTestDescriptor extends MethodBasedTestDescriptor { public static final String SEGMENT_TYPE = "method"; private static final InterceptingExecutableInvoker executableInvoker = new InterceptingExecutableInvoker(); + private static final AsyncInterceptingExecutableInvoker asyncExecutableInvoker = new AsyncInterceptingExecutableInvoker(); private static final VoidMethodInterceptorCall defaultInterceptorCall = InvocationInterceptor::interceptTestMethod; + private static final AsyncVoidMethodInterceptorCall defaultAsyncInterceptorCall = AsyncInvocationInterceptor::interceptTestMethodAsync; private final VoidMethodInterceptorCall interceptorCall; + private final AsyncVoidMethodInterceptorCall asyncInterceptorCall; public TestMethodTestDescriptor(UniqueId uniqueId, Class testClass, Method testMethod, Supplier>> enclosingInstanceTypes, JupiterConfiguration configuration) { super(uniqueId, testClass, testMethod, enclosingInstanceTypes, configuration); this.interceptorCall = defaultInterceptorCall; + this.asyncInterceptorCall = defaultAsyncInterceptorCall; } TestMethodTestDescriptor(UniqueId uniqueId, String displayName, Class testClass, Method testMethod, @@ -88,8 +102,15 @@ public TestMethodTestDescriptor(UniqueId uniqueId, Class testClass, Method te TestMethodTestDescriptor(UniqueId uniqueId, String displayName, Class testClass, Method testMethod, JupiterConfiguration configuration, VoidMethodInterceptorCall interceptorCall) { + this(uniqueId, displayName, testClass, testMethod, configuration, interceptorCall, defaultAsyncInterceptorCall); + } + + TestMethodTestDescriptor(UniqueId uniqueId, String displayName, Class testClass, Method testMethod, + JupiterConfiguration configuration, VoidMethodInterceptorCall interceptorCall, + AsyncVoidMethodInterceptorCall asyncInterceptorCall) { super(uniqueId, displayName, testClass, testMethod, configuration); this.interceptorCall = interceptorCall; + this.asyncInterceptorCall = asyncInterceptorCall; } // --- JupiterTestDescriptor ----------------------------------------------- @@ -166,6 +187,88 @@ public JupiterEngineExecutionContext execute(JupiterEngineExecutionContext conte return context; } + @Override + public CompletionStage executeAsync(JupiterEngineExecutionContext context, + DynamicTestExecutor dynamicTestExecutor) { + if (!isSupportedAsyncReturnType(context.getExtensionRegistry())) { + // Preserve the exact blocking behavior for test methods that do not + // return an asynchronous completion signal. + return CompletableFuture.completedFuture(execute(context, dynamicTestExecutor)); + } + return executeAsyncTest(context); + } + + private boolean isSupportedAsyncReturnType(ExtensionRegistry extensionRegistry) { + return AsyncReturnTypeSupport.isSupported(getTestMethod(), + extensionRegistry.getExtensions(AsyncReturnValueHandler.class)); + } + + /** + * Execute a test method that returns an asynchronous completion signal + * without blocking a thread while the asynchronous work is pending. The + * {@code before} lifecycle and callback phases still run synchronously + * (collecting failures into the shared collector); the test method's async + * body and the {@code after} phases are composed into the returned stage. + */ + private CompletionStage executeAsyncTest(JupiterEngineExecutionContext context) { + ThrowableCollector throwableCollector = context.getThrowableCollector(); + + invokeBeforeEachCallbacks(context); + + CompletionStage testStage = CompletableFuture.completedFuture(null); + if (throwableCollector.isEmpty()) { + invokeBeforeEachMethods(context); + if (throwableCollector.isEmpty()) { + invokeBeforeTestExecutionCallbacks(context); + if (throwableCollector.isEmpty()) { + testStage = invokeTestMethodAsync(context); + } + } + } + + return testStage // + .handle((___, throwable) -> { + if (throwable != null && throwableCollector.isEmpty()) { + throwableCollector.execute(() -> ExceptionUtils.throwAsUncheckedException(unwrap(throwable))); + } + invokeAfterTestExecutionCallbacks(context); + invokeAfterEachMethods(context); + return null; + }) // + .thenApply(__ -> { + invokeAfterEachCallbacks(context); + return context; + }); + } + + private CompletionStage invokeTestMethodAsync(JupiterEngineExecutionContext context) { + ExtensionContext extensionContext = context.getExtensionContext(); + Object instance = extensionContext.getRequiredTestInstance(); + Method testMethod = getTestMethod(); + final ExtensionRegistry registry = context.getExtensionRegistry(); + + return asyncExecutableInvoker.interceptMethodAsync(testMethod, instance, extensionContext, registry, + this.asyncInterceptorCall) // + . handle((___, throwable) -> { + if (throwable == null) { + return null; + } + Throwable root = unwrap(throwable); + UnrecoverableExceptions.rethrowIfUnrecoverable(root); + invokeTestExecutionExceptionHandlers(registry, extensionContext, root); + return null; + }); + } + + private static Throwable unwrap(Throwable throwable) { + Throwable current = throwable; + while ((current instanceof CompletionException || current instanceof ExecutionException) + && current.getCause() != null) { + current = current.getCause(); + } + return current; + } + @Override public void cleanUp(JupiterEngineExecutionContext context) throws Exception { if (isPerMethodLifecycle(context) && context.getExtensionContext().getTestInstance().isPresent()) { @@ -216,8 +319,7 @@ protected void invokeTestMethod(JupiterEngineExecutionContext context, DynamicTe try { Method testMethod = getTestMethod(); Object instance = extensionContext.getRequiredTestInstance(); - executableInvoker.invokeVoid(testMethod, instance, extensionContext, context.getExtensionRegistry(), - interceptorCall); + invokeTestMethodInternally(context, extensionContext, testMethod, instance); } catch (Throwable throwable) { UnrecoverableExceptions.rethrowIfUnrecoverable(throwable); @@ -226,6 +328,17 @@ protected void invokeTestMethod(JupiterEngineExecutionContext context, DynamicTe }); } + private void invokeTestMethodInternally(JupiterEngineExecutionContext context, ExtensionContext extensionContext, + Method testMethod, Object instance) { + // Invoke the test method through the usual interceptor chain. If the + // method returns an asynchronous completion signal, its completion is + // awaited within the chain (so interceptors such as @Timeout cover the + // asynchronous work), and an exceptional completion is recorded as the + // test's failure. + executableInvoker.invokeAndAwait(testMethod, instance, extensionContext, context.getExtensionRegistry(), + interceptorCall); + } + private void invokeTestExecutionExceptionHandlers(ExtensionRegistry registry, ExtensionContext context, Throwable throwable) { diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/TestTemplateInvocationTestDescriptor.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/TestTemplateInvocationTestDescriptor.java index b751e4a1b4b4..b85fd4614ecc 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/TestTemplateInvocationTestDescriptor.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/TestTemplateInvocationTestDescriptor.java @@ -21,10 +21,12 @@ import org.apiguardian.api.API; import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.extension.AsyncInvocationInterceptor; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.InvocationInterceptor; import org.junit.jupiter.api.extension.TestTemplateInvocationContext; import org.junit.jupiter.engine.config.JupiterConfiguration; +import org.junit.jupiter.engine.execution.AsyncInterceptingExecutableInvoker.AsyncVoidMethodInterceptorCall; import org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.ReflectiveInterceptorCall.VoidMethodInterceptorCall; import org.junit.jupiter.engine.execution.JupiterEngineExecutionContext; import org.junit.jupiter.engine.extension.MutableExtensionRegistry; @@ -43,6 +45,7 @@ public class TestTemplateInvocationTestDescriptor extends TestMethodTestDescript public static final String SEGMENT_TYPE = "test-template-invocation"; private static final VoidMethodInterceptorCall interceptorCall = InvocationInterceptor::interceptTestTemplateMethod; + private static final AsyncVoidMethodInterceptorCall asyncInterceptorCall = AsyncInvocationInterceptor::interceptTestTemplateMethodAsync; private @Nullable TestTemplateInvocationContext invocationContext; @@ -51,7 +54,7 @@ public class TestTemplateInvocationTestDescriptor extends TestMethodTestDescript TestTemplateInvocationTestDescriptor(UniqueId uniqueId, Class testClass, Method templateMethod, TestTemplateInvocationContext invocationContext, int index, JupiterConfiguration configuration) { super(uniqueId, invocationContext.getDisplayName(index), testClass, templateMethod, configuration, - interceptorCall); + interceptorCall, asyncInterceptorCall); this.invocationContext = invocationContext; this.index = index; } diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/ClassSelectorResolver.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/ClassSelectorResolver.java index 5a27b2f9414a..5e12ee51b457 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/ClassSelectorResolver.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/ClassSelectorResolver.java @@ -36,6 +36,7 @@ import java.util.function.Supplier; import java.util.stream.Stream; +import org.junit.jupiter.api.extension.AsyncReturnValueHandler; import org.junit.jupiter.api.extension.ClassTemplateInvocationContext; import org.junit.jupiter.engine.config.JupiterConfiguration; import org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor; @@ -46,6 +47,7 @@ import org.junit.jupiter.engine.descriptor.NestedClassTestDescriptor; import org.junit.jupiter.engine.descriptor.TestClassAware; import org.junit.jupiter.engine.discovery.predicates.TestClassPredicates; +import org.junit.jupiter.engine.extension.EarlyExtensionRegistry; import org.junit.platform.commons.support.ReflectionSupport; import org.junit.platform.commons.util.ReflectionUtils; import org.junit.platform.commons.util.ReflectionUtils.CycleErrorHandling; @@ -72,13 +74,15 @@ class ClassSelectorResolver implements SelectorResolver { private final JupiterConfiguration configuration; private final TestClassPredicates predicates; private final DiscoveryIssueReporter issueReporter; + private final List asyncReturnValueHandlers; ClassSelectorResolver(Predicate classNameFilter, JupiterConfiguration configuration, - DiscoveryIssueReporter issueReporter) { + EarlyExtensionRegistry earlyExtensionRegistry, DiscoveryIssueReporter issueReporter) { this.classNameFilter = classNameFilter; this.configuration = configuration; - this.predicates = new TestClassPredicates(issueReporter); + this.predicates = new TestClassPredicates(issueReporter, earlyExtensionRegistry); this.issueReporter = issueReporter; + this.asyncReturnValueHandlers = earlyExtensionRegistry.getAsyncReturnValueHandlers(); } @Override @@ -239,7 +243,7 @@ private ClassTemplateTestDescriptor newClassTemplateTestDescriptor(TestDescripto private ClassTestDescriptor newClassTestDescriptor(TestDescriptor parent, Class testClass) { return new ClassTestDescriptor( parent.getUniqueId().append(ClassTestDescriptor.SEGMENT_TYPE, testClass.getName()), testClass, - configuration); + configuration, asyncReturnValueHandlers); } private ClassBasedTestDescriptor newMemberClassTestDescriptor(TestDescriptor parent, Class testClass) { @@ -257,7 +261,8 @@ private ClassTemplateTestDescriptor newNestedClassTemplateTestDescriptor(TestDes private NestedClassTestDescriptor newNestedClassTestDescriptor(TestDescriptor parent, Class testClass) { UniqueId uniqueId = parent.getUniqueId().append(NestedClassTestDescriptor.SEGMENT_TYPE, testClass.getSimpleName()); - return new NestedClassTestDescriptor(uniqueId, testClass, () -> getEnclosingTestClasses(parent), configuration); + return new NestedClassTestDescriptor(uniqueId, testClass, () -> getEnclosingTestClasses(parent), configuration, + asyncReturnValueHandlers); } private ClassTemplateTestDescriptor newClassTemplateTestDescriptor(TestDescriptor parent, String segmentType, diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/DiscoverySelectorResolver.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/DiscoverySelectorResolver.java index e779add4f5d7..6864e9c6e53f 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/DiscoverySelectorResolver.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/DiscoverySelectorResolver.java @@ -17,6 +17,7 @@ import org.junit.jupiter.engine.descriptor.JupiterEngineDescriptor; import org.junit.jupiter.engine.descriptor.Validatable; import org.junit.jupiter.engine.discovery.predicates.TestClassPredicates; +import org.junit.jupiter.engine.extension.EarlyExtensionRegistry; import org.junit.platform.engine.EngineDiscoveryRequest; import org.junit.platform.engine.TestDescriptor; import org.junit.platform.engine.support.discovery.DiscoveryIssueReporter; @@ -38,11 +39,12 @@ public class DiscoverySelectorResolver { private static final EngineDiscoveryRequestResolver resolver = EngineDiscoveryRequestResolver. builder() // - .addClassContainerSelectorResolverWithContext( - ctx -> new TestClassPredicates(ctx.getIssueReporter()).looksLikeNestedOrStandaloneTestClass) // + .addClassContainerSelectorResolverWithContext(ctx -> new TestClassPredicates(ctx.getIssueReporter(), + getEarlyExtensionRegistry(ctx)).looksLikeNestedOrStandaloneTestClass) // .addSelectorResolver(ctx -> new ClassSelectorResolver(ctx.getClassNameFilter(), getConfiguration(ctx), - ctx.getIssueReporter())) // - .addSelectorResolver(ctx -> new MethodSelectorResolver(getConfiguration(ctx), ctx.getIssueReporter())) // + getEarlyExtensionRegistry(ctx), ctx.getIssueReporter())) // + .addSelectorResolver(ctx -> new MethodSelectorResolver(getConfiguration(ctx), + getEarlyExtensionRegistry(ctx), ctx.getIssueReporter())) // .addTestDescriptorVisitor(ctx -> TestDescriptor.Visitor.composite( // new ClassOrderingVisitor(getConfiguration(ctx), ctx.getIssueReporter()), // new MethodOrderingVisitor(getConfiguration(ctx), ctx.getIssueReporter()), // @@ -57,6 +59,11 @@ private static JupiterConfiguration getConfiguration(InitializationContext context) { + return context.getEngineDescriptor().getEarlyExtensionRegistry(); + } + public static void resolveSelectors(EngineDiscoveryRequest request, JupiterEngineDescriptor engineDescriptor, DiscoveryIssueReporter issueReporter) { resolver.resolve(request, engineDescriptor, issueReporter); diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/MethodSelectorResolver.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/MethodSelectorResolver.java index 1d6f7bd8f604..64d145425b64 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/MethodSelectorResolver.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/MethodSelectorResolver.java @@ -28,6 +28,7 @@ import java.util.function.Supplier; import java.util.stream.Stream; +import org.junit.jupiter.api.extension.AsyncReturnValueHandler; import org.junit.jupiter.engine.config.JupiterConfiguration; import org.junit.jupiter.engine.descriptor.Filterable; import org.junit.jupiter.engine.descriptor.TestClassAware; @@ -39,6 +40,7 @@ import org.junit.jupiter.engine.discovery.predicates.IsTestMethod; import org.junit.jupiter.engine.discovery.predicates.IsTestTemplateMethod; import org.junit.jupiter.engine.discovery.predicates.TestClassPredicates; +import org.junit.jupiter.engine.extension.EarlyExtensionRegistry; import org.junit.platform.engine.DiscoveryIssue; import org.junit.platform.engine.DiscoveryIssue.Severity; import org.junit.platform.engine.DiscoverySelector; @@ -65,11 +67,14 @@ class MethodSelectorResolver implements SelectorResolver { private final DiscoveryIssueReporter issueReporter; private final List methodTypes; - MethodSelectorResolver(JupiterConfiguration configuration, DiscoveryIssueReporter issueReporter) { + MethodSelectorResolver(JupiterConfiguration configuration, EarlyExtensionRegistry earlyExtensionRegistry, + DiscoveryIssueReporter issueReporter) { this.configuration = configuration; this.issueReporter = issueReporter; - this.methodTypes = MethodType.allPossibilities(issueReporter); - this.testClassPredicate = new TestClassPredicates(issueReporter).looksLikeNestedOrStandaloneTestClass; + List asyncReturnValueHandlers = earlyExtensionRegistry.getAsyncReturnValueHandlers(); + this.methodTypes = MethodType.allPossibilities(issueReporter, asyncReturnValueHandlers); + this.testClassPredicate = new TestClassPredicates(issueReporter, + earlyExtensionRegistry).looksLikeNestedOrStandaloneTestClass; } @Override @@ -173,15 +178,17 @@ private Supplier> expansionCallback(TestDescrip private static class MethodType { - static List allPossibilities(DiscoveryIssueReporter issueReporter) { + static List allPossibilities(DiscoveryIssueReporter issueReporter, + List asyncReturnValueHandlers) { return Arrays.asList( // - new MethodType(new IsTestMethod(issueReporter), TestMethodTestDescriptor::new, + new MethodType(new IsTestMethod(issueReporter, asyncReturnValueHandlers), TestMethodTestDescriptor::new, TestMethodTestDescriptor.SEGMENT_TYPE), // new MethodType(new IsTestFactoryMethod(issueReporter), TestFactoryTestDescriptor::new, TestFactoryTestDescriptor.SEGMENT_TYPE, TestFactoryTestDescriptor.DYNAMIC_CONTAINER_SEGMENT_TYPE, TestFactoryTestDescriptor.DYNAMIC_TEST_SEGMENT_TYPE), // - new MethodType(new IsTestTemplateMethod(issueReporter), TestTemplateTestDescriptor::new, - TestTemplateTestDescriptor.SEGMENT_TYPE, TestTemplateInvocationTestDescriptor.SEGMENT_TYPE) // + new MethodType(new IsTestTemplateMethod(issueReporter, asyncReturnValueHandlers), + TestTemplateTestDescriptor::new, TestTemplateTestDescriptor.SEGMENT_TYPE, + TestTemplateInvocationTestDescriptor.SEGMENT_TYPE) // ); } diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/IsTestMethod.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/IsTestMethod.java index c077d1bc8108..79a2ba98370b 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/IsTestMethod.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/IsTestMethod.java @@ -12,8 +12,11 @@ import static org.apiguardian.api.API.Status.INTERNAL; +import java.util.List; + import org.apiguardian.api.API; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.AsyncReturnValueHandler; import org.junit.platform.engine.support.discovery.DiscoveryIssueReporter; /** @@ -25,7 +28,13 @@ public class IsTestMethod extends IsTestableMethod { public IsTestMethod(DiscoveryIssueReporter issueReporter) { - super(Test.class, IsTestableMethod::hasVoidReturnType, issueReporter); + this(issueReporter, List.of()); + } + + public IsTestMethod(DiscoveryIssueReporter issueReporter, List asyncReturnValueHandlers) { + super(Test.class, + (annotationType, reporter) -> hasVoidReturnType(annotationType, reporter, asyncReturnValueHandlers), + issueReporter); } } diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/IsTestTemplateMethod.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/IsTestTemplateMethod.java index 4011996de3d3..785a9400aec7 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/IsTestTemplateMethod.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/IsTestTemplateMethod.java @@ -12,8 +12,11 @@ import static org.apiguardian.api.API.Status.INTERNAL; +import java.util.List; + import org.apiguardian.api.API; import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.AsyncReturnValueHandler; import org.junit.platform.engine.support.discovery.DiscoveryIssueReporter; /** @@ -25,7 +28,14 @@ public class IsTestTemplateMethod extends IsTestableMethod { public IsTestTemplateMethod(DiscoveryIssueReporter issueReporter) { - super(TestTemplate.class, IsTestableMethod::hasVoidReturnType, issueReporter); + this(issueReporter, List.of()); + } + + public IsTestTemplateMethod(DiscoveryIssueReporter issueReporter, + List asyncReturnValueHandlers) { + super(TestTemplate.class, + (annotationType, reporter) -> hasVoidReturnType(annotationType, reporter, asyncReturnValueHandlers), + issueReporter); } } diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/IsTestableMethod.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/IsTestableMethod.java index 1c5057cb0a7b..180526de44c3 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/IsTestableMethod.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/IsTestableMethod.java @@ -16,9 +16,12 @@ import java.lang.annotation.Annotation; import java.lang.reflect.Method; +import java.util.List; import java.util.function.BiFunction; import java.util.function.Predicate; +import org.junit.jupiter.api.extension.AsyncReturnValueHandler; +import org.junit.jupiter.engine.support.AsyncReturnTypeSupport; import org.junit.platform.commons.support.ModifierSupport; import org.junit.platform.engine.DiscoveryIssue; import org.junit.platform.engine.DiscoveryIssue.Severity; @@ -64,11 +67,26 @@ private static Condition isNotPrivate(Class annota } protected static Condition hasVoidReturnType(Class annotationType, - DiscoveryIssueReporter issueReporter) { - return issueReporter.createReportingCondition(method -> getReturnType(method) == void.class, + DiscoveryIssueReporter issueReporter, List asyncReturnValueHandlers) { + return issueReporter.createReportingCondition( + method -> isVoidOrAsynchronousReturnType(method, asyncReturnValueHandlers), method -> createIssue(annotationType, method, "must not return a value")); } + /** + * A test method may return {@code void} or a fully supported asynchronous + * return type (for example a {@link CompletionStage} or {@link Future}), in + * which case its completion is awaited before the test is considered + * finished. + * + * @see AsyncReturnTypeSupport + */ + static boolean isVoidOrAsynchronousReturnType(Method method, + List asyncReturnValueHandlers) { + Class returnType = getReturnType(method); + return returnType == void.class || AsyncReturnTypeSupport.isSupported(method, asyncReturnValueHandlers); + } + protected static DiscoveryIssue createIssue(Class annotationType, Method method, String condition) { String message = "@%s method '%s' %s. It will not be executed.".formatted(annotationType.getSimpleName(), diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/TestClassPredicates.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/TestClassPredicates.java index a83bf625826a..1f50c4b2cecd 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/TestClassPredicates.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/TestClassPredicates.java @@ -23,6 +23,7 @@ import java.lang.annotation.Annotation; import java.lang.reflect.Method; import java.util.HashSet; +import java.util.List; import java.util.Set; import java.util.function.Predicate; @@ -30,6 +31,8 @@ import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.ClassTemplate; import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.extension.AsyncReturnValueHandler; +import org.junit.jupiter.engine.extension.EarlyExtensionRegistry; import org.junit.platform.commons.util.ReflectionUtils; import org.junit.platform.commons.util.ReflectionUtils.CycleErrorHandling; import org.junit.platform.engine.DiscoveryIssue; @@ -61,9 +64,14 @@ public class TestClassPredicates { private final Condition> isValidStandaloneTestClass; public TestClassPredicates(DiscoveryIssueReporter issueReporter) { - this.isTestOrTestFactoryOrTestTemplateMethod = new IsTestMethod(issueReporter) // + this(issueReporter, EarlyExtensionRegistry.empty()); + } + + public TestClassPredicates(DiscoveryIssueReporter issueReporter, EarlyExtensionRegistry earlyExtensionRegistry) { + List asyncReturnValueHandlers = earlyExtensionRegistry.getAsyncReturnValueHandlers(); + this.isTestOrTestFactoryOrTestTemplateMethod = new IsTestMethod(issueReporter, asyncReturnValueHandlers) // .or(new IsTestFactoryMethod(issueReporter)) // - .or(new IsTestTemplateMethod(issueReporter)); + .or(new IsTestTemplateMethod(issueReporter, asyncReturnValueHandlers)); this.isNotPrivateUnlessAbstractNestedClass = isNotPrivateUnlessAbstract("@Nested", issueReporter); this.isInnerNestedClass = isInner(issueReporter); this.isValidStandaloneTestClass = isNotPrivateUnlessAbstract("Test", issueReporter) // diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/AsyncInterceptingExecutableInvoker.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/AsyncInterceptingExecutableInvoker.java new file mode 100644 index 000000000000..5bd93e03d80b --- /dev/null +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/AsyncInterceptingExecutableInvoker.java @@ -0,0 +1,185 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.engine.execution; + +import static org.apiguardian.api.API.Status.INTERNAL; +import static org.junit.jupiter.engine.execution.ParameterResolutionUtils.resolveParameters; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import org.apiguardian.api.API; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.extension.AsyncInvocationInterceptor; +import org.junit.jupiter.api.extension.AsyncInvocationInterceptor.AsyncInvocation; +import org.junit.jupiter.api.extension.AsyncReturnValueHandler; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ReflectiveInvocationContext; +import org.junit.jupiter.engine.extension.ExtensionRegistry; +import org.junit.jupiter.engine.support.AsyncReturnTypeSupport; +import org.junit.jupiter.engine.support.MethodReflectionUtils; + +/** + * {@code AsyncInterceptingExecutableInvoker} is the asynchronous counterpart of + * {@link InterceptingExecutableInvoker}. It invokes a + * {@link java.lang.reflect.Executable} while allowing + * {@linkplain AsyncInvocationInterceptor async invocation interceptors} to + * intercept the invocation without blocking a thread while the intercepted + * executable performs asynchronous work. + * + *

Unlike the synchronous invoker, this invoker never parks the calling + * thread on a {@link CompletionStage} returned by the intercepted method: + * instead it composes the returned stage into the stage it produces. + * + * @since 6.2 + */ +@API(status = INTERNAL, since = "6.2") +public final class AsyncInterceptingExecutableInvoker { + + private static final AsyncInvocationInterceptorChain interceptorChain = new AsyncInvocationInterceptorChain(); + + /** + * Invoke the supplied {@code method}, returning a {@link CompletionStage} + * that completes once the invocation and (where applicable) the + * asynchronous work signaled by the method's return value have finished. + * + * @param method the method to invoke and resolve parameters for + * @param target the target on which the executable will be invoked; may be + * {@code null} for {@code static} methods + * @param extensionContext the current {@code ExtensionContext} + * @param extensionRegistry the {@code ExtensionRegistry} to retrieve + * {@code ParameterResolvers} from + * @param interceptorCall the {@link AsyncVoidMethodInterceptorCall} to + * dispatch the interceptors to + * @return a completion stage signaling termination of the invocation; never + * {@code null} + */ + public CompletionStage interceptMethodAsync(Method method, @Nullable Object target, + ExtensionContext extensionContext, ExtensionRegistry extensionRegistry, + AsyncVoidMethodInterceptorCall interceptorCall) { + @Nullable + Object[] arguments = resolveParameters(method, target, extensionContext, extensionRegistry); + MethodInvocation<@Nullable Void> methodInvocation = new MethodInvocation<>(method, target, arguments); + AsyncInvocation asyncInvocation = new AwaitingAsyncMethodInvocation(method, target, arguments, + extensionRegistry.getExtensions(AsyncReturnValueHandler.class)); + return interceptorChain.invoke(asyncInvocation, extensionRegistry, (interceptor, wrapped) -> { + // The AwaitingAsyncMethodInvocation, when reached, must ultimately + // resolve parameters; the MethodInvocation already holds the resolved + // arguments and acts as the ReflectiveInvocationContext for the call. + return interceptorCall.apply(interceptor, wrapped, methodInvocation, extensionContext); + }); + } + + /** + * Wraps a {@link MethodInvocation} so that interceptor methods operate on an + * {@link AsyncInvocation} and the produced stage completes once the + * asynchronous work signaled by the method's return value has finished. + */ + private static class AwaitingAsyncMethodInvocation implements AsyncInvocation { + + private final Method method; + private final @Nullable Object target; + private final @Nullable Object[] arguments; + private final List asyncReturnValueHandlers; + private @Nullable Object result; + + AwaitingAsyncMethodInvocation(Method method, @Nullable Object target, @Nullable Object[] arguments, + List asyncReturnValueHandlers) { + this.method = method; + this.target = target; + this.arguments = arguments; + this.asyncReturnValueHandlers = asyncReturnValueHandlers; + } + + @Override + public CompletionStage proceedAsync() { + @Nullable + Object value = getOrInvoke(); + if (value == null) { + return completedVoid(); + } + AsyncReturnValueHandler handler = AsyncReturnTypeSupport.findHandler(value, method, + asyncReturnValueHandlers); + if (handler != null) { + return await(handler.toCompletionStage(value)); + } + if (value instanceof CompletionStage stage) { + return await(stage); + } + if (value instanceof java.util.concurrent.Future future) { + return awaitFuture(future); + } + return completedVoid(); + } + + private @Nullable Object getOrInvoke() { + Object current = this.result; + if (current == null) { + current = MethodReflectionUtils.invoke(this.method, this.target, this.arguments); + this.result = current; + } + return current; + } + + private static CompletionStage await(CompletionStage stage) { + @SuppressWarnings("unchecked") + CompletionStage cast = (CompletionStage) (Object) stage; + return cast.thenCompose(__ -> completedVoid()); + } + + private static CompletionStage awaitFuture(java.util.concurrent.Future future) { + return CompletableFuture.completedFuture(null).thenCompose(__ -> { + try { + future.get(); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return CompletableFuture.failedFuture(e); + } + catch (java.util.concurrent.ExecutionException e) { + return CompletableFuture.failedFuture(e.getCause() != null ? e.getCause() : e); + } + return completedVoid(); + }); + } + + private static CompletionStage completedVoid() { + return CompletedStageSupport.completedVoid(); + } + } + + /** + * A functional interface for the call to be made to an + * {@link AsyncInvocationInterceptor}. + */ + @FunctionalInterface + public interface AsyncVoidMethodInterceptorCall { + + CompletionStage apply(AsyncInvocationInterceptor interceptor, AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) + throws Throwable; + } + + /** + * Small internal helper for producing a completed {@link Nullable Void} stage + * without tripping NullAway. + */ + private static final class CompletedStageSupport { + + @SuppressWarnings("NullAway") + private static CompletionStage completedVoid() { + return CompletableFuture.completedFuture(null); + } + } + +} diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/AsyncInvocationInterceptorChain.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/AsyncInvocationInterceptorChain.java new file mode 100644 index 000000000000..2baf58d7ba0f --- /dev/null +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/AsyncInvocationInterceptorChain.java @@ -0,0 +1,178 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.engine.execution; + +import static java.util.stream.Collectors.joining; +import static org.apiguardian.api.API.Status.INTERNAL; + +import java.util.List; +import java.util.ListIterator; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Stream; + +import org.apiguardian.api.API; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.extension.AsyncInvocationInterceptor; +import org.junit.jupiter.api.extension.AsyncInvocationInterceptor.AsyncInvocation; +import org.junit.jupiter.api.extension.Extension; +import org.junit.jupiter.api.extension.InvocationInterceptor; +import org.junit.jupiter.engine.extension.ExtensionRegistry; +import org.junit.platform.commons.JUnitException; +import org.junit.platform.commons.logging.Logger; +import org.junit.platform.commons.logging.LoggerFactory; +import org.junit.platform.commons.util.ExceptionUtils; + +/** + * {@code AsyncInvocationInterceptorChain} is the asynchronous counterpart of + * {@link InvocationInterceptorChain}: it keeps track of all + * {@linkplain AsyncInvocationInterceptor async invocation interceptors} which + * need to be applied to an invocation and folds them into a + * {@link CompletionStage}. + * + *

The chain does not block: each link returns a {@link CompletionStage}, so + * the execution lane can be suspended and resumed without parking a thread + * while awaiting a test or lifecycle method that returns an asynchronously + * completable signal. + * + * @since 6.2 + */ +@API(status = INTERNAL, since = "6.2") +public class AsyncInvocationInterceptorChain { + + private static final Logger logger = LoggerFactory.getLogger(AsyncInvocationInterceptorChain.class); + + private static Stream streamAsyncInvocationInterceptors( + ExtensionRegistry extensionRegistry) { + return extensionRegistry.stream(Extension.class) // + .map(extension -> { + if (extension instanceof AsyncInvocationInterceptor asyncInterceptor) { + return asyncInterceptor; + } + if (extension instanceof InvocationInterceptor syncInterceptor) { + return new SynchronousInvocationInterceptorAdapter(syncInterceptor); + } + return null; + }) // + .filter(it -> it != null); + } + + public CompletionStage invoke(AsyncInvocation invocation, + ExtensionRegistry extensionRegistry, InterceptorCall call) { + List interceptors = streamAsyncInvocationInterceptors(extensionRegistry).toList(); + if (interceptors.isEmpty()) { + return proceed(invocation); + } + return chainAndInvoke(invocation, call, interceptors); + } + + private CompletionStage chainAndInvoke(AsyncInvocation invocation, + InterceptorCall call, List interceptors) { + + ValidatingAsyncInvocation validatingInvocation = new ValidatingAsyncInvocation<>(invocation, interceptors); + AsyncInvocation chainedInvocation = chainInterceptors(validatingInvocation, call, interceptors); + return proceed(chainedInvocation).whenComplete((___, throwable) -> { + if (throwable == null) { + validatingInvocation.verifyInvokedAtLeastOnce(); + } + }); + } + + private AsyncInvocation chainInterceptors(AsyncInvocation invocation, + InterceptorCall call, List interceptors) { + AsyncInvocation result = invocation; + ListIterator iterator = interceptors.listIterator(interceptors.size()); + while (iterator.hasPrevious()) { + AsyncInvocationInterceptor interceptor = iterator.previous(); + result = new InterceptedAsyncInvocation<>(result, call, interceptor); + } + return result; + } + + private static CompletionStage proceed(AsyncInvocation invocation) { + return invocation.proceedAsync(); + } + + /** + * An asynchronous invocation that confirms that {@link #proceedAsync()} or + * {@link #skip()} has been invoked (at least once). + */ + private static class ValidatingAsyncInvocation implements AsyncInvocation { + + private final List interceptors; + private final AtomicBoolean invoked = new AtomicBoolean(); + private final AsyncInvocation delegate; + + ValidatingAsyncInvocation(AsyncInvocation delegate, List interceptors) { + this.delegate = delegate; + this.interceptors = interceptors; + } + + @Override + public CompletionStage proceedAsync() { + invoked.set(true); + return delegate.proceedAsync(); + } + + @Override + public void skip() { + invoked.set(true); + delegate.skip(); + } + + void verifyInvokedAtLeastOnce() { + if (!invoked.get()) { + String interceptorClasses = interceptors.stream() // + .map(Object::getClass) // + .map(Class::getName) // + .collect(joining(", ")); + throw new JUnitException( + "Invocation of interceptor chain not invoked at least once: " + interceptorClasses); + } + } + } + + /** + * An invocation with one additional interceptor applied. + */ + private record InterceptedAsyncInvocation(AsyncInvocation invocation, + InterceptorCall call, AsyncInvocationInterceptor interceptor) implements AsyncInvocation { + + @Override + public CompletionStage proceedAsync() { + try { + return call.apply(interceptor, invocation); + } + catch (Throwable t) { + logger.error(t, () -> "Internal error: " + t.getMessage()); + ExceptionUtils.throwAsUncheckedException(t); + return CompletableFuture.failedFuture(t); + } + } + + @Override + public void skip() { + invocation.skip(); + } + } + + /** + * A call to an interceptor. + */ + @FunctionalInterface + public interface InterceptorCall { + + CompletionStage apply(AsyncInvocationInterceptor interceptor, AsyncInvocation invocation) + throws Throwable; + } + +} diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/InterceptingExecutableInvoker.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/InterceptingExecutableInvoker.java index 67a3cd69a552..6fc4195a3352 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/InterceptingExecutableInvoker.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/InterceptingExecutableInvoker.java @@ -16,9 +16,12 @@ import java.lang.reflect.Constructor; import java.lang.reflect.Executable; import java.lang.reflect.Method; +import java.util.List; +import java.util.concurrent.CompletionException; import org.apiguardian.api.API; import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.extension.AsyncReturnValueHandler; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.InvocationInterceptor; import org.junit.jupiter.api.extension.InvocationInterceptor.Invocation; @@ -26,6 +29,9 @@ import org.junit.jupiter.api.extension.ReflectiveInvocationContext; import org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.ReflectiveInterceptorCall.VoidMethodInterceptorCall; import org.junit.jupiter.engine.extension.ExtensionRegistry; +import org.junit.jupiter.engine.support.AsyncReturnTypeSupport; +import org.junit.platform.commons.util.ExceptionUtils; +import org.junit.platform.commons.util.UnrecoverableExceptions; /** * {@code InterceptingExecutableInvoker} encapsulates the invocation of a @@ -70,6 +76,71 @@ public void invokeVoid(Method method, @Nullable Object target, ExtensionContext ReflectiveInterceptorCall.ofVoidMethod(interceptorCall)); } + /** + * Invoke the supplied method and, if it returns an asynchronous completion + * signal (e.g. a {@link java.util.concurrent.CompletionStage}), await its + * completion. + * + *

The completion is awaited within the invocation that the + * {@linkplain InvocationInterceptor interceptor} chain sees, so interceptors + * such as {@code @Timeout} guard the entire asynchronous work rather than + * only the synchronous method call that returns the stage. + * + * @param method the method to invoke and resolve parameters for + * @param target the target on which the executable will be invoked + * @param extensionContext the current {@code ExtensionContext} + * @param extensionRegistry the {@code ExtensionRegistry} to retrieve + * {@code ParameterResolvers} from + * @param interceptorCall the call for intercepting this method invocation + * via all registered {@linkplain InvocationInterceptor interceptors} + */ + @SuppressWarnings("NullAway") + public void invokeAndAwait(Method method, @Nullable Object target, ExtensionContext extensionContext, + ExtensionRegistry extensionRegistry, VoidMethodInterceptorCall interceptorCall) { + @Nullable + Object[] arguments = resolveParameters(method, target, extensionContext, extensionRegistry); + MethodInvocation capturing = new MethodInvocation<>(method, target, arguments) { + @Override + public Object proceed() { + Object value = super.proceed(); + awaitIfSupported(method, value, extensionRegistry); + return value; + } + }; + ReflectiveInterceptorCall call = (interceptor, invocation, invocationContext, context) -> { + interceptorCall.apply(interceptor, toVoidInvocation(invocation), invocationContext, context); + return null; + }; + invoke(capturing, capturing, extensionContext, extensionRegistry, call); + } + + private void awaitIfSupported(Method method, @Nullable Object value, ExtensionRegistry extensionRegistry) { + List asyncReturnValueHandlers = extensionRegistry.getExtensions( + AsyncReturnValueHandler.class); + if (value == null || !AsyncReturnTypeSupport.isSupported(method, asyncReturnValueHandlers)) { + return; + } + try { + AsyncReturnTypeSupport.toCompletableFuture(value, method, asyncReturnValueHandlers).get(); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new CompletionException(e); + } + catch (java.util.concurrent.ExecutionException e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + UnrecoverableExceptions.rethrowIfUnrecoverable(cause); + // Propagate the root cause (checked or not) through the void-typed + // interceptor chain unchanged. + ExceptionUtils.throwAsUncheckedException(cause); + } + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private static Invocation<@Nullable Void> toVoidInvocation(Invocation invocation) { + return (Invocation) invocation; + } + /** * Invoke the supplied method with dynamic parameter resolution. * diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/SynchronousInvocationInterceptorAdapter.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/SynchronousInvocationInterceptorAdapter.java new file mode 100644 index 000000000000..cb81302e2daa --- /dev/null +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/SynchronousInvocationInterceptorAdapter.java @@ -0,0 +1,184 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.engine.execution; + +import static org.apiguardian.api.API.Status.EXPERIMENTAL; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import org.apiguardian.api.API; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.extension.AsyncInvocationInterceptor; +import org.junit.jupiter.api.extension.AsyncInvocationInterceptor.AsyncInvocation; +import org.junit.jupiter.api.extension.DynamicTestInvocationContext; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.InvocationInterceptor; +import org.junit.jupiter.api.extension.InvocationInterceptor.Invocation; +import org.junit.jupiter.api.extension.ReflectiveInvocationContext; + +/** + * {@code SynchronousInvocationInterceptorAdapter} adapts a legacy + * {@link InvocationInterceptor} to the {@link AsyncInvocationInterceptor} + * contract by running the synchronous interceptor method and completing a stage + * immediately. The underlying invocation is executed synchronously (blocking, + * since a legacy interceptor cannot observe asynchronous completion) whenever + * the legacy interceptor calls {@link Invocation#proceed()}. + * + *

This enables existing (deprecated) synchronous interceptors to operate + * unchanged within the asynchronous invocation pipeline. + * + * @since 6.2 + */ +@API(status = EXPERIMENTAL, since = "6.2") +public class SynchronousInvocationInterceptorAdapter implements AsyncInvocationInterceptor { + + private final InvocationInterceptor delegate; + + public SynchronousInvocationInterceptorAdapter(InvocationInterceptor delegate) { + this.delegate = delegate; + } + + @Override + public CompletionStage interceptTestClassConstructorAsync(AsyncInvocation invocation, + ReflectiveInvocationContext> invocationContext, ExtensionContext extensionContext) { + return defer( + () -> delegate. interceptTestClassConstructor(toSync(invocation), invocationContext, extensionContext)); + } + + @Override + public CompletionStage interceptBeforeAllMethodAsync(AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + return deferVoid(() -> { + delegate.interceptBeforeAllMethod(toSyncNullable(invocation), invocationContext, extensionContext); + }); + } + + @Override + public CompletionStage interceptBeforeEachMethodAsync(AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + return deferVoid(() -> { + delegate.interceptBeforeEachMethod(toSyncNullable(invocation), invocationContext, extensionContext); + }); + } + + @Override + public CompletionStage interceptTestMethodAsync(AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + return deferVoid(() -> { + delegate.interceptTestMethod(toSyncNullable(invocation), invocationContext, extensionContext); + }); + } + + @Override + public CompletionStage interceptTestFactoryMethodAsync( + AsyncInvocation invocation, ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext) { + return defer( + () -> delegate. interceptTestFactoryMethod(toSync(invocation), invocationContext, extensionContext)); + } + + @Override + public CompletionStage interceptTestTemplateMethodAsync(AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + return deferVoid(() -> { + delegate.interceptTestTemplateMethod(toSyncNullable(invocation), invocationContext, extensionContext); + }); + } + + @Override + public CompletionStage interceptDynamicTestAsync(AsyncInvocation invocation, + DynamicTestInvocationContext invocationContext, ExtensionContext extensionContext) { + return deferVoid(() -> { + delegate.interceptDynamicTest(toSyncNullable(invocation), invocationContext, extensionContext); + }); + } + + @Override + public CompletionStage interceptAfterEachMethodAsync(AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + return deferVoid(() -> { + delegate.interceptAfterEachMethod(toSyncNullable(invocation), invocationContext, extensionContext); + }); + } + + @Override + public CompletionStage interceptAfterAllMethodAsync(AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + return deferVoid(() -> { + delegate.interceptAfterAllMethod(toSyncNullable(invocation), invocationContext, extensionContext); + }); + } + + private static Invocation toSync(AsyncInvocation invocation) { + return new Invocation() { + @Override + @SuppressWarnings("NullAway") + public T proceed() throws Throwable { + return invocation.proceedAsync().toCompletableFuture().join(); + } + }; + } + + private static Invocation<@Nullable T> toSyncNullable(AsyncInvocation invocation) { + return new Invocation<@Nullable T>() { + @Override + public @Nullable T proceed() throws Throwable { + return invocation.proceedAsync().toCompletableFuture().join(); + } + }; + } + + private static CompletionStage deferVoid(ThrowableRunnable runnable) { + try { + runnable.run(); + return completedStageOfVoid(); + } + catch (Throwable t) { + return CompletableFuture.failedFuture(t); + } + } + + @SuppressWarnings("NullAway") + private static CompletionStage completedStageOfVoid() { + return CompletableFuture.completedFuture(null); + } + + private static CompletionStage defer(ThrowableSupplier<@Nullable T> supplier) { + try { + return completedStageOf(supplier.get()); + } + catch (Throwable t) { + return CompletableFuture.failedFuture(t); + } + } + + @SuppressWarnings("NullAway") + private static CompletionStage completedStageOf(@Nullable T value) { + return CompletableFuture.completedFuture(value); + } + + @FunctionalInterface + private interface ThrowableSupplier { + + @Nullable + T get() throws Throwable; + } + + @FunctionalInterface + private interface ThrowableRunnable { + + void run() throws Throwable; + } + +} diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/EarlyExtensionRegistry.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/EarlyExtensionRegistry.java new file mode 100644 index 000000000000..8acfc31e9ed6 --- /dev/null +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/EarlyExtensionRegistry.java @@ -0,0 +1,101 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.engine.extension; + +import static java.util.Collections.emptyList; +import static org.apiguardian.api.API.Status.INTERNAL; + +import java.util.List; +import java.util.ServiceLoader; +import java.util.function.Predicate; + +import org.apiguardian.api.API; +import org.junit.jupiter.api.extension.AsyncReturnValueHandler; +import org.junit.jupiter.api.extension.EarlyExtension; +import org.junit.jupiter.api.extension.Extension; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.engine.config.JupiterConfiguration; +import org.junit.platform.commons.util.ClassLoaderUtils; +import org.junit.platform.commons.util.ServiceLoaderUtils; + +/** + * Registry of {@link EarlyExtension EarlyExtensions} that are made available + * during discovery, before any {@link ExtensionContext} exists. + * + *

Unlike the regular {@link ExtensionRegistry}, which is only populated when + * test execution starts, this registry is loaded eagerly so that the discovery + * phase can consult it. It is created once per engine discovery and cached on + * the engine descriptor for the duration of the corresponding discovery + + * execution session. + * + * @since 6.2 + */ +@API(status = INTERNAL, since = "6.2") +public final class EarlyExtensionRegistry { + + /** + * Creates a new registry, loading the {@link EarlyExtension EarlyExtensions} + * that apply globally via the {@link ServiceLoader}. + * + *

Automatic (the {@code ServiceLoader}) loading only happens when + * auto-detection is enabled in the supplied configuration, mirroring the + * behavior of {@link ExtensionRegistry}. Extensions that are registered + * declaratively via {@link org.junit.jupiter.api.extension.ExtendWith + * @ExtendWith} or {@link org.junit.jupiter.api.extension.RegisterExtension + * @RegisterExtension} are not part of this registry; they are + * resolved at runtime via the regular {@link ExtensionRegistry}. + * + * @param configuration the engine configuration; never {@code null} + * @return a new registry; never {@code null} + */ + public static EarlyExtensionRegistry create(JupiterConfiguration configuration) { + if (!configuration.isExtensionAutoDetectionEnabled()) { + return new EarlyExtensionRegistry(emptyList()); + } + + Predicate> filter = configuration.getFilterForAutoDetectedExtensions().and( + EarlyExtension.class::isAssignableFrom); + + ServiceLoader serviceLoader = ServiceLoader.load(Extension.class, + ClassLoaderUtils.getDefaultClassLoader()); + List handlers = ServiceLoaderUtils.filter(serviceLoader, filter) // + .filter(AsyncReturnValueHandler.class::isInstance) // + .map(AsyncReturnValueHandler.class::cast) // + .toList(); + return new EarlyExtensionRegistry(handlers); + } + + /** + * Creates an empty registry with no globally loaded early extensions. + * + * @return a new empty registry; never {@code null} + */ + public static EarlyExtensionRegistry empty() { + return new EarlyExtensionRegistry(emptyList()); + } + + private final List asyncReturnValueHandlers; + + private EarlyExtensionRegistry(List asyncReturnValueHandlers) { + this.asyncReturnValueHandlers = asyncReturnValueHandlers; + } + + /** + * Returns the globally loaded {@link AsyncReturnValueHandler + * AsyncReturnValueHandlers}, in {@code ServiceLoader} order. + * + * @return an immutable list; never {@code null} + */ + public List getAsyncReturnValueHandlers() { + return asyncReturnValueHandlers; + } + +} diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/MutableExtensionRegistry.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/MutableExtensionRegistry.java index d09ac5eee606..50404aa290be 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/MutableExtensionRegistry.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/MutableExtensionRegistry.java @@ -148,6 +148,15 @@ public static MutableExtensionRegistry createRegistryFrom(MutableExtensionRegist return registry; } + /** + * Create a new empty registry that does not contain any extensions. + * + * @return a new, empty {@code MutableExtensionRegistry}; never {@code null} + */ + public static MutableExtensionRegistry createEmptyRegistry() { + return new MutableExtensionRegistry(); + } + private final Set> registeredExtensionTypes; private final List registeredExtensions; private final Map, LateInitExtensions> lateInitExtensions; diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TimeoutExtension.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TimeoutExtension.java index 5b0789838fe1..ad39282bfd50 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TimeoutExtension.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TimeoutExtension.java @@ -16,11 +16,17 @@ import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Method; import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.function.Function; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Timeout; import org.junit.jupiter.api.Timeout.ThreadMode; +import org.junit.jupiter.api.extension.AsyncInvocationInterceptor; +import org.junit.jupiter.api.extension.AsyncInvocationInterceptor.AsyncInvocation; import org.junit.jupiter.api.extension.BeforeAllCallback; import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.ExtensionContext; @@ -33,7 +39,8 @@ /** * @since 5.5 */ -class TimeoutExtension implements BeforeAllCallback, BeforeEachCallback, InvocationInterceptor { +class TimeoutExtension + implements BeforeAllCallback, BeforeEachCallback, InvocationInterceptor, AsyncInvocationInterceptor { private static final ExtensionContext.Namespace NAMESPACE = ExtensionContext.Namespace.create(Timeout.class); private static final String TESTABLE_METHOD_TIMEOUT_KEY = "testable_method_timeout_from_annotation"; @@ -119,6 +126,139 @@ public void interceptAfterAllMethod(Invocation<@Nullable Void> invocation, TimeoutConfiguration::getDefaultAfterAllMethodTimeout); } + // --- Asynchronous interceptor methods --------------------------------- + + @Override + public CompletionStage interceptBeforeAllMethodAsync(AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + return interceptAsync(invocation, invocationContext, extensionContext, + TimeoutConfiguration::getDefaultBeforeAllMethodTimeout, + readTimeoutFromAnnotation(Optional.of(invocationContext.getExecutable()))); + } + + @Override + public CompletionStage interceptBeforeEachMethodAsync(AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + return interceptAsync(invocation, invocationContext, extensionContext, + TimeoutConfiguration::getDefaultBeforeEachMethodTimeout, + readTimeoutFromAnnotation(Optional.of(invocationContext.getExecutable()))); + } + + @Override + public CompletionStage interceptTestMethodAsync(AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + TimeoutDuration explicitTimeout = extensionContext.getStore(NAMESPACE).get(TESTABLE_METHOD_TIMEOUT_KEY, + TimeoutDuration.class); + return interceptAsync(invocation, invocationContext, extensionContext, + TimeoutConfiguration::getDefaultTestMethodTimeout, Optional.ofNullable(explicitTimeout)); + } + + @Override + public CompletionStage interceptTestTemplateMethodAsync(AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + TimeoutDuration explicitTimeout = extensionContext.getStore(NAMESPACE).get(TESTABLE_METHOD_TIMEOUT_KEY, + TimeoutDuration.class); + return interceptAsync(invocation, invocationContext, extensionContext, + TimeoutConfiguration::getDefaultTestTemplateMethodTimeout, Optional.ofNullable(explicitTimeout)); + } + + @Override + public CompletionStage interceptTestFactoryMethodAsync( + AsyncInvocation invocation, ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext) { + TimeoutDuration explicitTimeout = extensionContext.getStore(NAMESPACE).get(TESTABLE_METHOD_TIMEOUT_KEY, + TimeoutDuration.class); + return interceptAsyncResult(invocation, invocationContext, extensionContext, + TimeoutConfiguration::getDefaultTestFactoryMethodTimeout, Optional.ofNullable(explicitTimeout)); + } + + @Override + public CompletionStage interceptAfterEachMethodAsync(AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + return interceptAsync(invocation, invocationContext, extensionContext, + TimeoutConfiguration::getDefaultAfterEachMethodTimeout, + readTimeoutFromAnnotation(Optional.of(invocationContext.getExecutable()))); + } + + @Override + public CompletionStage interceptAfterAllMethodAsync(AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + return interceptAsync(invocation, invocationContext, extensionContext, + TimeoutConfiguration::getDefaultAfterAllMethodTimeout, + readTimeoutFromAnnotation(Optional.of(invocationContext.getExecutable()))); + } + + @SuppressWarnings("UnusedVariable") + private CompletionStage interceptAsync(AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext, + TimeoutProvider defaultTimeoutProvider, Optional explicitTimeout) { + return interceptAsync(invocation.proceedAsync(), extensionContext, defaultTimeoutProvider, explicitTimeout); + } + + private CompletionStage interceptAsync(CompletionStage base, + ExtensionContext extensionContext, TimeoutProvider defaultTimeoutProvider, + Optional explicitTimeout) { + TimeoutConfiguration timeoutConfiguration = getGlobalTimeoutConfiguration(extensionContext); + if (timeoutConfiguration.isTimeoutDisabled()) { + return base; + } + TimeoutDuration timeout = explicitTimeout.orElseGet( + () -> getDefaultTimeout(defaultTimeoutProvider, timeoutConfiguration)); + if (timeout == null) { + return base; + } + var threadMode = resolveTimeoutThreadMode(extensionContext, timeoutConfiguration); + return applyTimeout(base, timeout, threadMode); + } + + @SuppressWarnings("UnusedVariable") + private CompletionStage interceptAsyncResult(AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext, + TimeoutProvider defaultTimeoutProvider, Optional explicitTimeout) { + return interceptAsync(invocation.proceedAsync(), extensionContext, defaultTimeoutProvider, explicitTimeout); + } + + /** + * Apply {@code orTimeout} to the supplied stage. For {@link ThreadMode#SEPARATE_THREAD} the + * thread completing the original stage is interrupted when the timeout fires; if the async work + * is not interruptible (e.g. its stage is already complete), the timeout still fires and the + * timed-out invocation is reported as failed, never as successful. + */ + @SuppressWarnings({ "FutureReturnValueIgnored", "UnusedVariable" }) + private CompletionStage applyTimeout(CompletionStage base, + TimeoutDuration timeout, ThreadMode threadMode) { + long millis = timeout.toDuration().toMillis(); + CompletableFuture future = base.toCompletableFuture(); + if (threadMode == ThreadMode.SEPARATE_THREAD) { + var completingThread = new java.util.concurrent.atomic.AtomicReference(); + future.whenComplete((___, throwable) -> completingThread.set(Thread.currentThread())); + CompletableFuture raced = future.orTimeout(millis, TimeUnit.MILLISECONDS); + raced.whenComplete((value, throwable) -> { + if (containsTimeout(throwable)) { + // Best-effort interruption of the thread finishing the async + // work. If the body is not thread-bound (interruption lost), + // the timed-out invocation is still reported as failed; the + // late-arriving success never flips it to passed. + Thread thread = completingThread.get(); + if (thread != null && !thread.equals(Thread.currentThread())) { + thread.interrupt(); + } + } + }); + return raced; + } + return future.orTimeout(millis, TimeUnit.MILLISECONDS); + } + + private static boolean containsTimeout(Throwable throwable) { + for (Throwable current = throwable; current != null; current = current.getCause()) { + if (current instanceof TimeoutException) { + return true; + } + } + return false; + } + private void interceptLifecycleMethod(Invocation<@Nullable Void> invocation, ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext, TimeoutProvider defaultTimeoutProvider) throws Throwable { diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/support/AsyncReturnTypeSupport.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/support/AsyncReturnTypeSupport.java new file mode 100644 index 000000000000..f40d9ff62ed5 --- /dev/null +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/support/AsyncReturnTypeSupport.java @@ -0,0 +1,258 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.engine.support; + +import static java.util.Collections.synchronizedMap; +import static org.apiguardian.api.API.Status.INTERNAL; + +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Future; + +import org.apiguardian.api.API; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.extension.AsyncReturnValueHandler; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.Extension; +import org.junit.jupiter.engine.extension.ExtensionRegistry; +import org.junit.platform.commons.util.AnnotationUtils; +import org.junit.platform.commons.util.ExceptionUtils; +import org.junit.platform.commons.util.LruCache; +import org.junit.platform.commons.util.Preconditions; + +/** + * {@code AsyncReturnTypeSupport} provides support for test methods that return + * an asynchronous completion signal. + * + *

Returning a {@link CompletionStage}, {@link CompletableFuture}, or + * {@link Future} from a {@code @Test} method is interpreted as a promise that + * the test's asynchronous work has terminated; its payload (if any) is ignored. + * Only the termination itself is awaited, so the return type is treated as an + * opaque signal. + * + * @since 6.2 + */ +@API(status = INTERNAL, since = "6.2", consumers = "org.junit.jupiter.engine") +public final class AsyncReturnTypeSupport { + + private AsyncReturnTypeSupport() { + /* no-op */ + } + + /** + * Caches, per test {@link Method}, whether it has an asynchronous return + * type. This avoids invoking user-provided + * {@link AsyncReturnValueHandler#supports(Type, AnnotatedElement) + * handlers} and re-walking {@link ExtendWith @ExtendWith} annotations over + * and over again for the same method during discovery and execution. + */ + private static final Map isAsynchronousReturnTypeCache = synchronizedMap(new LruCache<>(512)); + + /** + * Determine whether the supplied return type is a fully supported + * asynchronous completion signal. + * + * @param type the (raw) return type; never {@code null} + * @return {@code true} if the type is a supported asynchronous signal + * @throws org.junit.platform.commons.PreconditionViolationException if + * {@code type} is {@code null} + */ + public static boolean isFullySupported(Class type) { + Preconditions.notNull(type, "type must not be null"); + return CompletionStage.class.isAssignableFrom(type) || Future.class.isAssignableFrom(type); + } + + /** + * Determine whether the return type of the supplied method is asynchronous, + * either because it is a built-in JRE type or because one of the supplied + * handlers {@link AsyncReturnValueHandler#supports(Type, AnnotatedElement) + * supports} it. + * + * @param method the method whose return type to inspect; never {@code null} + * @param asyncReturnValueHandlers the handlers to consult; never {@code null} + * @return {@code true} if the method is asynchronous + */ + public static boolean isSupported(Method method, List asyncReturnValueHandlers) { + return isAsynchronousReturnTypeCache.computeIfAbsent(method, + __ -> isSupportedUncached(method, asyncReturnValueHandlers)); + } + + private static boolean isSupportedUncached(Method method, List asyncReturnValueHandlers) { + if (isFullySupported(method.getReturnType())) { + return true; + } + Type genericReturnType = method.getGenericReturnType(); + for (AsyncReturnValueHandler handler : asyncReturnValueHandlers) { + if (handler.supports(genericReturnType, method)) { + return true; + } + } + for (AsyncReturnValueHandler handler : getExtendWithHandlers(method)) { + if (handler.supports(genericReturnType, method)) { + return true; + } + } + return false; + } + + /** + * Instantiate the {@link AsyncReturnValueHandler AsyncReturnValueHandlers} + * declared directly or transitively via {@link ExtendWith @ExtendWith} for + * the supplied test method and its declaring class hierarchy. + * + *

The method's own {@code @ExtendWith} annotations (including composed + * indirection annotations), the annotations of its declaring class and of + * each enclosing class (for {@code @Nested} tests), are consulted. For each + * level, {@link #collectFromExtendWith(AnnotatedElement, Set, List)} delegates + * to {@link AnnotationUtils#findRepeatableAnnotations(AnnotatedElement, + * Class)} so that {@code @Inherited} superclass and interface declarations, + * composed annotations, and {@code @Repeatable} containers are resolved + * exactly as they are for the runtime {@link ExtensionRegistry}. + * + *

This is needed during discovery to recognize a custom return + * type declared through a class- or method-level {@code @ExtendWith} before + * the regular {@link ExtensionRegistry} exists. The actual handler instances + * that convert a returned value into a signal to await are resolved from + * that runtime {@link ExtensionRegistry}. + * + * @param method the test method; never {@code null} + * @return the discovered handlers; never {@code null} + */ + public static List getExtendWithHandlers(Method method) { + Set> visitedExtensionClasses = new HashSet<>(); + Set> visitedClasses = new HashSet<>(); + List handlers = new ArrayList<>(); + + collectFromExtendWith(method, visitedExtensionClasses, handlers); + + Class testClass = method.getDeclaringClass(); + for (Class candidateClass = testClass; candidateClass != null + && visitedClasses.add(candidateClass); candidateClass = candidateClass.getEnclosingClass()) { + collectFromExtendWith(candidateClass, visitedExtensionClasses, handlers); + } + return handlers; + } + + private static void collectFromExtendWith(AnnotatedElement element, Set> visitedExtensionClasses, + List handlers) { + if (element == null) { + return; + } + for (ExtendWith extendWith : AnnotationUtils.findRepeatableAnnotations(element, ExtendWith.class)) { + for (Class extensionClass : extendWith.value()) { + if (visitedExtensionClasses.add(extensionClass) + && AsyncReturnValueHandler.class.isAssignableFrom(extensionClass)) { + handlers.add(instantiate(extensionClass)); + } + } + } + } + + @SuppressWarnings({ "unchecked", "deprecation" }) + private static AsyncReturnValueHandler instantiate(Class extensionClass) { + try { + Constructor constructor = extensionClass.getDeclaredConstructor(); + if (!constructor.canAccess(null)) { + constructor.setAccessible(true); + } + return (AsyncReturnValueHandler) constructor.newInstance(); + } + catch (ReflectiveOperationException ex) { + throw ExceptionUtils.throwAsUncheckedException(ex); + } + } + + /** + * Find the first {@link AsyncReturnValueHandler} that {@link + * AsyncReturnValueHandler#supports(Type, AnnotatedElement) supports} the + * supplied value, or {@code null} if none does. + * + *

The value's raw class is used as the return type to be matched. + * + * @param value the value to match; never {@code null} + * @param asyncReturnValueHandlers the handlers to consult; never {@code null} + * @return the matching handler, or {@code null} + */ + @Nullable + public static AsyncReturnValueHandler findHandler(Object value, @Nullable AnnotatedElement annotatedElement, + List asyncReturnValueHandlers) { + Class valueType = value.getClass(); + for (AsyncReturnValueHandler handler : asyncReturnValueHandlers) { + if (handler.supports(valueType, annotatedElement)) { + return handler; + } + } + return null; + } + + /** + * Convert the supplied asynchronously returned value into a signal to await. + * + * @param value the value returned by a test method; never {@code null} + * @return a {@link CompletableFuture} representing the value's termination; + * never {@code null} + * @throws org.junit.platform.commons.PreconditionViolationException if + * {@code value} is {@code null} + */ + public static CompletableFuture toCompletableFuture(Object value) { + return toCompletableFuture(value, null, List.of()); + } + + /** + * Convert the supplied asynchronously returned value into a signal to await, + * consulting the supplied {@link AsyncReturnValueHandler handlers} before + * falling back to the built-in JRE types. + * + * @param value the value returned by a test method; never {@code null} + * @param asyncReturnValueHandlers the handlers to consult; never {@code null} + * @return a {@link CompletableFuture} representing the value's termination; + * never {@code null} + */ + public static CompletableFuture toCompletableFuture(Object value, @Nullable AnnotatedElement annotatedElement, + List asyncReturnValueHandlers) { + Preconditions.notNull(value, "value must not be null"); + AsyncReturnValueHandler handler = findHandler(value, annotatedElement, asyncReturnValueHandlers); + if (handler != null) { + CompletionStage stage = handler.toCompletionStage(value); + Preconditions.notNull(stage, "toCompletionStage() must not return null"); + return stage.toCompletableFuture().thenApply(__ -> null); + } + if (value instanceof CompletionStage stage) { + return stage.toCompletableFuture().thenApply(__ -> null); + } + if (value instanceof Future future) { + return CompletableFuture.supplyAsync(() -> { + try { + future.get(); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new CompletionException(e); + } + catch (java.util.concurrent.ExecutionException e) { + throw new CompletionException(e.getCause()); + } + return null; + }); + } + return CompletableFuture.completedFuture(null); + } +} diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/UnrecoverableExceptions.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/UnrecoverableExceptions.java index 30533491790a..ab2b432ec454 100644 --- a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/UnrecoverableExceptions.java +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/UnrecoverableExceptions.java @@ -10,6 +10,7 @@ package org.junit.platform.commons.util; +import static java.util.Objects.requireNonNull; import static org.apiguardian.api.API.Status.INTERNAL; import org.apiguardian.api.API; @@ -41,6 +42,17 @@ private UnrecoverableExceptions() { /* no-op */ } + /** + * Determine whether the supplied {@link Throwable exception} is + * unrecoverable. + * + * @param exception the exception to check; may be {@code null} + * @return {@code true} if the supplied {@code exception} is unrecoverable + */ + public static boolean isUnrecoverable(@Nullable Throwable exception) { + return exception instanceof OutOfMemoryError; + } + /** * Rethrow the supplied {@link Throwable exception} if it is * unrecoverable. @@ -49,8 +61,10 @@ private UnrecoverableExceptions() { * method does nothing. */ public static void rethrowIfUnrecoverable(@Nullable Throwable exception) { - if (exception instanceof OutOfMemoryError) { - throw ExceptionUtils.throwAsUncheckedException(exception); + if (isUnrecoverable(exception)) { + // NullAway cannot refine the nullability of the parameter, so we + // use the implicit non-null argument of an unrecoverable exception. + throw ExceptionUtils.throwAsUncheckedException(requireNonNull(exception)); } } diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/AsyncResourcePermit.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/AsyncResourcePermit.java new file mode 100644 index 000000000000..e781ca0f35c6 --- /dev/null +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/AsyncResourcePermit.java @@ -0,0 +1,152 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.engine.support.hierarchical; + +import static org.apiguardian.api.API.Status.EXPERIMENTAL; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import org.apiguardian.api.API; +import org.junit.platform.commons.util.Preconditions; + +/** + * A reactive, bounded concurrency permit. + * + *

Replaces the blocking {@code Semaphore}-based worker lease for the + * non-blocking execution path. Acquiring a permit returns a + * {@link CompletionStage} that completes only once a permit becomes available; + * no thread is ever parked while waiting. Releasing a permit hands it to the + * longest-waiting acquirer in FIFO order. + * + *

The {@link CompletionStage} is used purely as a signal that a permit is + * held; its payload is intentionally ignored. + * + * @since 6.2 + */ +@API(status = EXPERIMENTAL, since = "6.2") +final class AsyncResourcePermit { + + private final int maxPermits; + private int availablePermits; + private final Deque> waitingPermits = new ArrayDeque<>(); + + /** + * A token held by the current owner of a permit. Must be released exactly + * once via {@link Permit#release()}. + */ + interface Permit { + + /** + * Release this permit, handing it to the longest-waiting acquirer if + * any. + */ + void release(); + } + + /** + * Create a permit gate allowing up to {@code maxPermits} concurrent holders. + * + * @param maxPermits the maximum number of permits to allow; must be positive + * @throws org.junit.platform.commons.PreconditionViolationException if + * {@code maxPermits} is not positive + */ + AsyncResourcePermit(int maxPermits) { + Preconditions.condition(maxPermits > 0, + "maxPermits must be a positive number of permits, but was " + maxPermits); + this.maxPermits = maxPermits; + this.availablePermits = maxPermits; + } + + int availablePermits() { + synchronized (this) { + return availablePermits; + } + } + + /** + * Return the maximum number of permits this gate allows. + */ + int maxPermits() { + return maxPermits; + } + + /** + * Acquire a permit asynchronously. + * + *

The returned stage completes (a) immediately if a permit is available + * or (b) when a previously held permit is released and this acquirer reaches + * the front of the FIFO queue. The {@link Permit} token is the completion + * value of the stage. + */ + CompletionStage acquire() { + Permit permit = tryAcquire(); + if (permit != null) { + return CompletableFuture.completedFuture(permit); + } + CompletableFuture pending = new CompletableFuture<>(); + synchronized (this) { + waitingPermits.addLast(pending); + } + return pending; + } + + /** + * Attempt to acquire a permit without queuing. + * + * @return a {@link Permit} if acquired, else {@code null} + */ + @org.jspecify.annotations.Nullable + Permit tryAcquire() { + synchronized (this) { + // Do not let a new acquirer jump ahead of already-queued waiters. + if (!waitingPermits.isEmpty() || availablePermits == 0) { + return null; + } + availablePermits--; + return new Lease(this); + } + } + + private static final class Lease implements Permit { + + private final AsyncResourcePermit gate; + private volatile boolean released; + + Lease(AsyncResourcePermit gate) { + this.gate = gate; + } + + @Override + public void release() { + if (!released) { + released = true; + gate.release(); + } + } + } + + private void release() { + CompletableFuture next; + synchronized (this) { + next = waitingPermits.pollFirst(); + if (next == null) { + availablePermits++; + } + } + if (next != null) { + // Hand the permit to the longest-waiting acquirer directly. + next.complete(new Lease(this)); + } + } +} diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/AsyncTestExecution.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/AsyncTestExecution.java new file mode 100644 index 000000000000..f7a915fc19aa --- /dev/null +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/AsyncTestExecution.java @@ -0,0 +1,181 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.engine.support.hierarchical; + +import static org.apiguardian.api.API.Status.EXPERIMENTAL; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ForkJoinPool; + +import org.apiguardian.api.API; +import org.jspecify.annotations.Nullable; +import org.junit.platform.commons.util.Preconditions; +import org.junit.platform.commons.util.UnrecoverableExceptions; + +/** + * Support for bridging blocking execution into the non-blocking + * {@link CompletionStage} world. + * + *

The returned stage is intentionally type-agnostic: it is used only as a + * promise of termination of the wrapped execution, not as a container of a + * value. Callers MUST NOT rely on the payload carried by the completed stage. + * + *

Any {@link Throwable} (including {@link Exception} and {@link Error}) + * thrown by the supplied execution results in a stage completed exceptionally + * with that throwable rather than a gracefully completed stage. + * + *

{@linkplain org.junit.platform.commons.util.UnrecoverableExceptions + * Unrecoverable} throwables are rethrown synchronously by + * {@link #synchronous(ThrowingRunnable)} (since it runs on the calling thread) + * and are carried by the failed stage of {@link #bridge(ThrowingRunnable)} + * (since it runs on a pool thread). + * + * @since 6.0 + */ +@API(status = EXPERIMENTAL, since = "6.0") +public final class AsyncTestExecution { + + private AsyncTestExecution() { + /* no-op */ + } + + /** + * A runnable that may throw any throwable. + */ + @FunctionalInterface + public interface ThrowingRunnable { + void run() throws Throwable; + } + + /** + * Run the supplied blocking {@code execution} on the common pool and return + * a stage that completes when the execution terminates. + * + *

If the execution throws, the returned stage completes exceptionally + * with that throwable. On normal return the stage completes successfully + * (its payload is irrelevant and must be ignored). + * + * @param execution the blocking execution to run + * @return a completion stage signaling termination of the execution + * @throws org.junit.platform.commons.PreconditionViolationException if + * {@code execution} is {@code null} + */ + public static CompletionStage bridge(ThrowingRunnable execution) { + Preconditions.notNull(execution, "execution must not be null"); + CompletableFuture result = new CompletableFuture<>(); + // execute() returns void, so the common pool is used without leaving an + // ignored Future behind. Any throwable (Exception or Error) produces a + // stage in a failed state so the throwable is never lost; a normal return + // produces a successful stage whose payload is a pure signal and MUST be + // ignored by callers. + ForkJoinPool.commonPool().execute(() -> { + try { + execution.run(); + result.complete(null); + } + catch (Throwable throwable) { + // The stage is completed exceptionally so the throwable is not + // lost, even for unrecoverable errors (the caller may rethrow + // them when awaiting the stage). + result.completeExceptionally(throwable); + } + }); + return result; + } + + /** + * Run the supplied {@code execution} synchronously on the calling thread and + * return an already-settled stage. + * + *

Unlike {@link #bridge(ThrowingRunnable)} this method does not offload + * the execution to a pool; it is intended for default {@code *Async} + * implementations that must preserve the calling thread's identity and + * ordering guarantees while still exposing a {@link CompletionStage} seam. + * + *

If the execution throws a recoverable throwable, the returned stage is + * already completed exceptionally with that throwable. An + * {@linkplain org.junit.platform.commons.util.UnrecoverableExceptions + * unrecoverable} throwable is rethrown synchronously so it propagates up the + * calling stack unchanged. On normal return the stage is already completed + * successfully (its payload is irrelevant and must be ignored). + * + * @param execution the execution to run synchronously + * @return an already-settled completion stage signaling termination + * @throws org.junit.platform.commons.PreconditionViolationException if + * {@code execution} is {@code null} + */ + @API(status = EXPERIMENTAL, since = "6.0") + public static CompletionStage synchronous(ThrowingRunnable execution) { + Preconditions.notNull(execution, "execution must not be null"); + CompletableFuture result = new CompletableFuture<>(); + try { + execution.run(); + result.complete(null); + } + catch (Throwable throwable) { + // Unrecoverable errors must not be trapped in a CompletableFuture's + // completion machinery; rethrow them synchronously so they propagate + // up the calling stack unchanged (matching the behavior of the + // blocking implementation). Recoverable throwables produce a stage in + // a failed state. + UnrecoverableExceptions.rethrowIfUnrecoverable(throwable); + result.completeExceptionally(throwable); + } + return result; + } + + /** + * Run the supplied {@code execution} synchronously on the calling thread + * and return a stage that is already completed with its result. + * + *

Unlike {@link #synchronous(ThrowingRunnable)}, the produced stage + * carries the {@code execution}'s result, which is useful for seeding the + * reactive world with a value (e.g. the context produced by a node). + * + * @param execution the execution to run synchronously + * @param the result type + * @return an already-settled completion stage carrying the result; never + * {@code null} + * @throws org.junit.platform.commons.PreconditionViolationException if + * {@code execution} is {@code null} + */ + @API(status = EXPERIMENTAL, since = "6.2") + public static CompletionStage synchronousResult(ThrowingSupplier execution) { + Preconditions.notNull(execution, "execution must not be null"); + CompletableFuture result = new CompletableFuture<>(); + try { + result.complete(execution.get()); + } + catch (Throwable throwable) { + // Unrecoverable errors must not be trapped in a CompletableFuture's + // completion machinery; rethrow them synchronously so they propagate + // up the calling stack unchanged. Recoverable throwables produce a + // stage in a failed state. + UnrecoverableExceptions.rethrowIfUnrecoverable(throwable); + result.completeExceptionally(throwable); + } + return result; + } + + /** + * A supplier that returns a result and may throw any throwable. + * + * @param the result type + */ + @FunctionalInterface + public interface ThrowingSupplier { + + @Nullable + T get() throws Throwable; + } + +} diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorService.java index 45dd45c35639..6b0484fd17f9 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorService.java @@ -14,10 +14,15 @@ import static org.apiguardian.api.API.Status.STABLE; import java.util.List; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import org.apiguardian.api.API; import org.jspecify.annotations.Nullable; +import org.junit.platform.commons.JUnitException; +import org.junit.platform.commons.util.Preconditions; +import org.junit.platform.commons.util.UnrecoverableExceptions; import org.junit.platform.engine.ExecutionRequest; import org.junit.platform.engine.TestDescriptor; import org.junit.platform.engine.support.hierarchical.Node.ExecutionMode; @@ -73,6 +78,57 @@ public interface HierarchicalTestExecutorService extends AutoCloseable { */ void invokeAll(List testTasks); + /** + * Asynchronous variant of {@link #submit(TestTask)}. + * + *

The returned {@link CompletionStage} is used purely as a promise that + * the task's execution has finished; its payload is intentionally ignored. + * + *

The default implementation bridges the (blocking) + * {@link #submit(TestTask)} method into the reactive world. Implementations + * that are fully non-blocking are encouraged to override this method. + * + * @param testTask the test task to be executed; never {@code null} + * @return a completion stage signaling termination of the task's execution; + * never {@code null} + * @throws org.junit.platform.commons.PreconditionViolationException if + * {@code testTask} is {@code null} + * @since 6.2 + */ + @API(status = EXPERIMENTAL, since = "6.2") + default CompletionStage submitAsync(TestTask testTask) { + Preconditions.notNull(testTask, "testTask must not be null"); + return AsyncTestExecution.synchronous(() -> { + var future = submit(testTask); + syncJoin(future); + }); + } + + /** + * Asynchronous variant of {@link #invokeAll(List)}. + * + *

The returned {@link CompletionStage} is used purely as a promise that + * all supplied tasks have finished; its payload is intentionally ignored. + * + *

The default implementation bridges the (blocking) + * {@link #invokeAll(List)} method into the reactive world. Implementations + * that are fully non-blocking are encouraged to override this method. + * + * @param testTasks the test tasks to be executed; never {@code null} + * @return a completion stage signaling termination of all supplied tasks; + * never {@code null} + * @throws org.junit.platform.commons.PreconditionViolationException if + * {@code testTasks} is {@code null} + * @since 6.2 + */ + @API(status = EXPERIMENTAL, since = "6.2") + default CompletionStage invokeAllAsync(List testTasks) { + Preconditions.notNull(testTasks, "testTasks must not be null"); + return AsyncTestExecution.synchronous(() -> { + invokeAll(testTasks); + }); + } + /** * Close this service and let it perform any required cleanup work. * @@ -82,6 +138,26 @@ public interface HierarchicalTestExecutorService extends AutoCloseable { @Override void close(); + /** + * Block until the supplied {@code future} completes, rethrowing a completed + * execution exception as a runtime exception. + * + * @param future the future to wait for + */ + private static void syncJoin(Future future) { + try { + future.get(); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new JUnitException("Interrupted while waiting for task to complete", e); + } + catch (ExecutionException e) { + UnrecoverableExceptions.rethrowIfUnrecoverable(e.getCause()); + throw new JUnitException("Task failed", e.getCause()); + } + } + /** * An executable task that represents a single test or container. */ @@ -93,16 +169,35 @@ interface TestTask { ExecutionMode getExecutionMode(); /** - * Get the {@linkplain ResourceLock resource lock} of this task. - */ + * Get the {@linkplain ResourceLock resource lock} of this task. + */ ResourceLock getResourceLock(); + /** + * Asynchronous variant of {@link #execute()}. + * + *

The returned {@link CompletionStage} is used purely as a promise that + * the task's execution has finished; its payload is intentionally ignored. + * + *

The default implementation bridges the blocking {@link #execute()} + * method into the reactive world. A fully asynchronous executor service may + * supply a reactive task via an alternative mechanism. + * + * @return a completion stage signaling termination of this task; never + * {@code null} + * @since 6.2 + */ + @API(status = EXPERIMENTAL, since = "6.2") + default CompletionStage executeAsync() { + return AsyncTestExecution.synchronous(this::execute); + } + /** * Get the {@linkplain TestDescriptor test descriptor} of this task. * * @throws UnsupportedOperationException if not supported for this TestTask implementation - * @since 6.0 - */ + * @since 6.0 + */ @API(status = EXPERIMENTAL, since = "6.0") default TestDescriptor getTestDescriptor() { throw new UnsupportedOperationException(); diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/Node.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/Node.java index 924fd9ce5efc..bef60baf25e2 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/Node.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/Node.java @@ -17,10 +17,12 @@ import java.util.Optional; import java.util.Set; +import java.util.concurrent.CompletionStage; import java.util.concurrent.Future; import org.apiguardian.api.API; import org.jspecify.annotations.Nullable; +import org.junit.platform.commons.JUnitException; import org.junit.platform.commons.util.ToStringBuilder; import org.junit.platform.engine.EngineExecutionListener; import org.junit.platform.engine.TestDescriptor; @@ -48,6 +50,26 @@ default C prepare(C context) throws Exception { return context; } + /** + * Asynchronous variant of {@link #prepare(EngineExecutionContext)}. + * + *

The result is used purely as a promise that the preparation has + * finished; the {@code CompletionStage} payload is intentionally ignored. + * + *

The default implementation delegates to the (blocking) + * {@link #prepare(EngineExecutionContext)} method, bridging it into the + * reactive world. + * + * @param context the context to prepare + * @return a completion stage signaling termination of the preparation; + * never {@code null} + * @since 6.2 + */ + @API(status = EXPERIMENTAL, since = "6.2") + default CompletionStage prepareAsync(C context) { + return AsyncTestExecution.synchronous(() -> prepare(context)); + } + /** * Clean up the supplied {@code context} after execution. * @@ -60,6 +82,26 @@ default C prepare(C context) throws Exception { default void cleanUp(C context) throws Exception { } + /** + * Asynchronous variant of {@link #cleanUp(EngineExecutionContext)}. + * + *

The result is used purely as a promise that the cleanup has finished; + * the {@code CompletionStage} payload is intentionally ignored. + * + *

The default implementation delegates to the (blocking) + * {@link #cleanUp(EngineExecutionContext)} method, bridging it into the + * reactive world. + * + * @param context the context to clean up + * @return a completion stage signaling termination of the cleanup; never + * {@code null} + * @since 6.2 + */ + @API(status = EXPERIMENTAL, since = "6.2") + default CompletionStage cleanUpAsync(C context) { + return AsyncTestExecution.synchronous(() -> cleanUp(context)); + } + /** * Determine if the execution of the supplied {@code context} should be * skipped. @@ -70,6 +112,28 @@ default SkipResult shouldBeSkipped(C context) throws Exception { return SkipResult.doNotSkip(); } + /** + * Asynchronous variant of {@link #shouldBeSkipped(EngineExecutionContext)}. + * + *

The result is used purely as a promise that the decision has been + * made; the {@code CompletionStage} payload is intentionally ignored (it is + * never a skip result). Use the (blocking) {@link #shouldBeSkipped} method + * to inspect the actual skip decision. + * + *

The default implementation delegates to the (blocking) + * {@link #shouldBeSkipped(EngineExecutionContext)} method, bridging it into + * the reactive world. + * + * @param context the context to inspect + * @return a completion stage signaling that a skip decision has been made; + * never {@code null} + * @since 6.2 + */ + @API(status = EXPERIMENTAL, since = "6.2") + default CompletionStage shouldBeSkippedAsync(C context) { + return AsyncTestExecution.synchronous(() -> shouldBeSkipped(context)); + } + /** * Execute the before behavior of this node. * @@ -88,6 +152,29 @@ default C before(C context) throws Exception { return context; } + /** + * Asynchronous variant of {@link #before(EngineExecutionContext)}. + * + *

The result is used purely as a promise that the before-behavior has + * finished; the {@code CompletionStage} payload is intentionally ignored. + * The produced context is carried by the (blocking) + * {@link #before(EngineExecutionContext)} method which the default + * implementation invokes. + * + *

The default implementation delegates to the (blocking) + * {@link #before(EngineExecutionContext)} method, bridging it into the + * reactive world. + * + * @param context the context to execute in + * @return a completion stage providing the context to use for children of + * this node; never {@code null} + * @since 6.2 + */ + @API(status = EXPERIMENTAL, since = "6.2") + default CompletionStage beforeAsync(C context) { + return AsyncTestExecution.synchronousResult(() -> before(context)); + } + /** * Execute the behavior of this node. * @@ -110,6 +197,29 @@ default C execute(C context, DynamicTestExecutor dynamicTestExecutor) throws Exc return context; } + /** + * Asynchronous variant of + * {@link #execute(EngineExecutionContext, DynamicTestExecutor)}. + * + *

The produced context is carried by the (blocking) + * {@link #execute(EngineExecutionContext, DynamicTestExecutor)} method which + * the default implementation invokes. + * + *

The default implementation delegates to the (blocking) + * {@link #execute(EngineExecutionContext, DynamicTestExecutor)} method, + * bridging it into the reactive world. + * + * @param context the context to execute in + * @param dynamicTestExecutor a mechanism to register additional test tasks + * @return a completion stage providing the context to use for the node's + * after-behavior; never {@code null} + * @since 6.2 + */ + @API(status = EXPERIMENTAL, since = "6.2") + default CompletionStage executeAsync(C context, DynamicTestExecutor dynamicTestExecutor) { + return AsyncTestExecution.synchronousResult(() -> execute(context, dynamicTestExecutor)); + } + /** * Execute the after behavior of this node. * @@ -125,6 +235,26 @@ default C execute(C context, DynamicTestExecutor dynamicTestExecutor) throws Exc default void after(C context) throws Exception { } + /** + * Asynchronous variant of {@link #after(EngineExecutionContext)}. + * + *

The result is used purely as a promise that the after-behavior has + * finished; the {@code CompletionStage} payload is intentionally ignored. + * + *

The default implementation delegates to the (blocking) + * {@link #after(EngineExecutionContext)} method, bridging it into the + * reactive world. + * + * @param context the context to execute in + * @return a completion stage signaling termination of the after-behavior; + * never {@code null} + * @since 6.2 + */ + @API(status = EXPERIMENTAL, since = "6.2") + default CompletionStage afterAsync(C context) { + return AsyncTestExecution.synchronous(() -> after(context)); + } + /** * Wraps around the invocation of {@link #before(EngineExecutionContext)}, * {@link #execute(EngineExecutionContext, DynamicTestExecutor)}, and @@ -139,6 +269,27 @@ default void around(C context, Invocation invocation) throws Exception { invocation.invoke(context); } + /** + * Asynchronous variant of {@link #around(EngineExecutionContext, Invocation)}. + * + *

The result is used purely as a promise that the around-behavior has + * finished; the {@code CompletionStage} payload is intentionally ignored. + * + *

The default implementation delegates to the (blocking) + * {@link #around(EngineExecutionContext, Invocation)} method, bridging it + * into the reactive world. + * + * @param context the context to execute in + * @param invocation the wrapped invocation (must be invoked exactly once) + * @return a completion stage signaling termination of the around-behavior; + * never {@code null} + * @since 6.2 + */ + @API(status = EXPERIMENTAL, since = "6.2") + default CompletionStage aroundAsync(C context, Invocation invocation) { + return AsyncTestExecution.synchronous(() -> around(context, invocation)); + } + /** * Callback invoked when the execution of this node has been skipped. * @@ -334,6 +485,33 @@ interface DynamicTestExecutor { * @throws InterruptedException if interrupted while waiting */ void awaitFinished() throws InterruptedException; + + /** + * Asynchronous variant of {@link #awaitFinished()}. + * + *

The returned {@link CompletionStage} is used purely as a promise + * that all submitted dynamic tests have finished; its payload is + * intentionally ignored. + * + *

The default implementation bridges the (blocking) + * {@link #awaitFinished()} method into the reactive world. + * + * @return a completion stage signaling that all dynamic tests have + * finished; never {@code null} + * @since 6.2 + */ + @API(status = EXPERIMENTAL, since = "6.2") + default CompletionStage awaitFinishedAsync() { + return AsyncTestExecution.synchronous(() -> { + try { + awaitFinished(); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new JUnitException("Interrupted while waiting for dynamic tests to finish", e); + } + }); + } } /** diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NodeTestTask.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NodeTestTask.java index 7dc0cfe3ba8b..6640c5123927 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NodeTestTask.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NodeTestTask.java @@ -21,6 +21,8 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; @@ -106,19 +108,8 @@ void setParentContext(@Nullable C parentContext) { public void execute() { try { throwableCollector = taskContext.throwableCollectorFactory().create(); - if (!taskContext.cancellationToken().isCancellationRequested()) { - prepare(); - } - if (throwableCollector.isEmpty()) { - throwableCollector.execute(() -> skipResult = checkWhetherSkipped()); - } - if (throwableCollector.isEmpty() && !requiredSkipResult().isSkipped()) { - executeRecursively(); - } - if (context != null) { - cleanUp(); - } - reportCompletion(); + Outcome outcome = awaitRecursively(executeAsync()); + UnrecoverableExceptions.rethrowIfUnrecoverable(outcome.unrecoverable()); } finally { // Ensure that the 'interrupted status' flag for the current thread @@ -140,6 +131,53 @@ public void execute() { context = null; } + /** + * Execute this task reactively. + * + *

This method drives the node's {@linkplain Node#prepareAsync prepare}, + * skip check, and {@link #executeRecursivelyAsync() recursive execution} + * behavior via {@link CompletionStage} composition so that a suspending node + * does not park a platform thread. + * + *

Note: it would be preferable to drive the whole hierarchy from the top + * down without any intermediate blocking (option "A"), but that is + * intentionally deferred here to limit the impact of this first round. + * + * @return a completion stage carrying this node's {@link Outcome}; never + * {@code null} + * @since 6.2 + */ + @Override + public CompletionStage> executeAsync() { + if (throwableCollector == null) { + throwableCollector = taskContext.throwableCollectorFactory().create(); + } + if (!taskContext.cancellationToken().isCancellationRequested()) { + prepare(); + } + if (requiredThrowableCollector().isEmpty()) { + requiredThrowableCollector().execute(() -> skipResult = checkWhetherSkipped()); + } + CompletionStage> flow; + if (requiredThrowableCollector().isEmpty() && !requiredSkipResult().isSkipped()) { + taskContext.listener().executionStarted(testDescriptor); + started = true; + flow = executeRecursivelyAsync(); + } + else { + flow = completedFuture(Outcome.continued(context)); + } + return flow.thenApply(outcome -> { + if (context != null && outcome.unrecoverable() == null) { + cleanUp(); + } + if (outcome.unrecoverable() == null) { + reportCompletion(); + } + return outcome; + }); + } + private void prepare() { requiredThrowableCollector().execute(() -> context = node.prepare(requireNonNull(parentContext))); @@ -154,38 +192,189 @@ private SkipResult checkWhetherSkipped() throws Exception { : node.shouldBeSkipped(requiredContext()); } - private void executeRecursively() { - taskContext.listener().executionStarted(testDescriptor); - started = true; + /** + * Execute the recursive part of this task reactively, producing an + * {@link Outcome} that bubbles unrecoverable errors to the caller. + * + * @return a completion stage carrying this subtree's {@link Outcome}; never + * {@code null} + */ + private CompletionStage> executeRecursivelyAsync() { + return runRecursivelyAsync(); + } - var throwableCollector = requiredThrowableCollector(); + /** + * Run the before/execute/children/dynamic-test/{@code after} phases + * reactively, producing an {@link Outcome} that bubbles unrecoverable + * errors to the caller. Recoverable failures are aggregated in the shared + * {@link ThrowableCollector}, honoring the collect-all semantics of the + * blocking implementation. + * + * @return a completion stage carrying this subtree's {@link Outcome}; never + * {@code null} + */ + private CompletionStage> runRecursivelyAsync() { + return node.beforeAsync(requiredContext()) // + .> handle((newContext, throwable) -> { + if (throwable != null) { + return toOutcome(throwable); + } + context = newContext; + return Outcome.continued(newContext); + }) // + .thenCompose(outcome -> { + if (outcome.unrecoverable() != null) { + return completedFuture(outcome); + } + if (outcome.failureOccurred()) { + return runAfterAsync(outcome); + } + return executeBodyThenChildrenThenAwaitAsync() // + .thenCompose(bodyOutcome -> { + if (bodyOutcome.unrecoverable() != null) { + return completedFuture(bodyOutcome); + } + return runAfterAsync(bodyOutcome); + }); + }); + } - throwableCollector.execute(() -> { - node.around(requiredContext(), ctx -> { - context = ctx; - throwableCollector.execute(() -> { - // @formatter:off - List> children = testDescriptor.getChildren().stream() - .map(descriptor -> new NodeTestTask(taskContext, descriptor)) + /** + * Execute the node's body, run its children, then await the dynamic-test + * executor, honoring the blocking implementation's control flow and + * aggregating recoverable failures into the collector. + */ + private CompletionStage> executeBodyThenChildrenThenAwaitAsync() { + final DefaultDynamicTestExecutor dynamicTestExecutor = new DefaultDynamicTestExecutor(); + + return node.executeAsync(requiredContext(), dynamicTestExecutor) // + .> handle((newContext, throwable) -> { + if (throwable != null) { + return toOutcome(throwable); + } + context = newContext; + return Outcome.continued(newContext); + }) // + .thenCompose(outcome -> { + if (outcome.unrecoverable() != null || outcome.failureOccurred()) { + return completedFuture(outcome); + } + List> children = testDescriptor.getChildren().stream() // + .map(descriptor -> new NodeTestTask(taskContext, descriptor)) // .collect(toCollection(ArrayList::new)); - // @formatter:on - - context = node.before(requiredContext()); - - final DynamicTestExecutor dynamicTestExecutor = new DefaultDynamicTestExecutor(); - context = node.execute(requiredContext(), dynamicTestExecutor); + if (children.isEmpty()) { + return completedFuture(outcome).thenCompose(o -> awaitDynamicFinished(dynamicTestExecutor, o)); + } + children.forEach(child -> child.setParentContext(outcome.context())); + return taskContext.executorService().invokeAllAsync(children) // + .> handle((___, throwable) -> { + if (throwable != null) { + return toOutcome(throwable); + } + return outcome; + }) // + .thenCompose(o -> { + if (o.unrecoverable() != null || o.failureOccurred()) { + return completedFuture(o); + } + return awaitDynamicFinished(dynamicTestExecutor, outcome); + }); + }); + } - if (!children.isEmpty()) { - children.forEach(child -> child.setParentContext(context)); - taskContext.executorService().invokeAll(children); + private CompletionStage> awaitDynamicFinished(DefaultDynamicTestExecutor dynamicTestExecutor, + Outcome outcome) { + return dynamicTestExecutor.awaitFinishedAsync() // + .> handle((___, throwable) -> { + if (throwable != null) { + return toOutcome(throwable); } + return outcome; + }); + } - throwableCollector.execute(dynamicTestExecutor::awaitFinished); + private CompletionStage> runAfterAsync(Outcome outcome) { + return node.afterAsync(requiredContext()) // + .> handle((___, throwable) -> { + if (throwable != null) { + return toOutcome(throwable); + } + return outcome; }); + } - throwableCollector.execute(() -> node.after(requiredContext())); - }); - }); + /** + * Convert a throwable obtained from an asynchronous phase into an + * {@link Outcome}: recoverable throwables are aggregated in the collector + * and reported as a failure that does not abort the subtree, while + * unrecoverable throwables (e.g. {@link OutOfMemoryError}) are bubbled to + * the caller. + */ + private Outcome toOutcome(Throwable throwable) { + Throwable root = unwrap(throwable); + if (UnrecoverableExceptions.isUnrecoverable(root)) { + return Outcome.errored(root); + } + requiredThrowableCollector().execute(() -> ExceptionUtils.throwAsUncheckedException(root)); + return Outcome.failedRecoverably(); + } + + /** + * Unwrap {@link CompletionException}s and {@link ExecutionException}s to + * their root cause. + */ + private static Throwable unwrap(Throwable throwable) { + Throwable current = throwable; + while ((current instanceof CompletionException || current instanceof ExecutionException) + && current.getCause() != null) { + current = current.getCause(); + } + return current; + } + + /** + * A value that carries the outcome of an asynchronous phase. Recoverable + * failures are aggregated in the {@link ThrowableCollector} and only signal + * {@link #failureOccurred()}; unrecoverable errors (e.g. + * {@link OutOfMemoryError}) are carried in {@link #unrecoverable()} so they + * can be rethrown by the root caller. + * + * @param context the context produced by the phase, if the phase continued + * @param unrecoverable the unrecoverable error to bubble, if any + * @param failureOccurred whether a recoverable failure was aggregated + * @param the context type + */ + record Outcome(@Nullable C context, @Nullable Throwable unrecoverable, boolean failureOccurred) { + + static Outcome continued(@Nullable C context) { + return new Outcome<>(context, null, false); + } + + static Outcome failedRecoverably() { + return new Outcome<>(null, null, true); + } + + static Outcome errored(Throwable unrecoverable) { + return new Outcome<>(null, unrecoverable, false); + } + } + + /** + * Block the calling thread until the supplied stage has completed, returning + * its {@link Outcome}. A stage that fails with an unrecoverable cause is + * rethrown synchronously as the same instance. + * + * @param stage the stage to await + * @return the completed stage's {@link Outcome} + */ + private Outcome awaitRecursively(CompletionStage> stage) { + try { + return stage.toCompletableFuture().join(); + } + catch (CompletionException ex) { + UnrecoverableExceptions.rethrowIfUnrecoverable(ex.getCause()); + throw ex; + } } private void cleanUp() { @@ -285,6 +474,20 @@ public void awaitFinished() throws InterruptedException { } } } + + /** + * Asynchronous variant of {@link #awaitFinished()}, used by the + * reactive driver. Dynamic-test execution itself is still scheduled on + * the executor thread pool, so this currently bridges the blocking + * wait; it does not block when no dynamic tests were registered. + * + * @return a completion stage signaling that all registered dynamic test + * tasks have finished; never {@code null} + */ + @Override + public CompletionStage awaitFinishedAsync() { + return AsyncTestExecution.synchronous(this::awaitFinished); + } } @FunctionalInterface diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ParallelHierarchicalTestExecutorServiceFactory.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ParallelHierarchicalTestExecutorServiceFactory.java index 195c7f8eeef2..85592b73c9a4 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ParallelHierarchicalTestExecutorServiceFactory.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ParallelHierarchicalTestExecutorServiceFactory.java @@ -90,6 +90,54 @@ public static HierarchicalTestExecutorService create(ParallelExecutorServiceType }; } + /** + * Create a reactive {@link HierarchicalTestExecutorService} with the given + * maximum parallelism. + * + *

The reactive service coordinates concurrency and resource-lock waits + * through completion stages rather than parked threads. Note: currently the + * service still parks a worker while the body of a node runs; a fully + * non-blocking execution lane from the top of the hierarchy down (option + * "A") would be preferable but is intentionally deferred to limit the + * impact of this first round. + * + * @param parallelism the maximum number of nodes executed concurrently + * @return a new reactive {@link HierarchicalTestExecutorService}; never + * {@code null} + */ + public static HierarchicalTestExecutorService createReactive(int parallelism) { + return new ReactiveHierarchicalTestExecutorService(parallelism); + } + + /** + * Create a reactive {@link HierarchicalTestExecutorService} for the + * standalone cooperative lane, without any thread configuration. + * + *

Concurrency comes from the asynchronous test methods' own returned + * contexts; a small trigger pool starts each async body. Container and + * synchronous/non-async nodes run in discovery order. + * + * @return a new reactive {@link HierarchicalTestExecutorService}; never + * {@code null} + */ + public static HierarchicalTestExecutorService createReactive() { + return new ReactiveHierarchicalTestExecutorService(); + } + + /** + * Create a reactive {@link HierarchicalTestExecutorService} with the + * parallelism derived from the supplied {@link ConfigurationParameters}. + * + * @param configurationParameters the configuration parameters to read the + * parallelism from + * @return a new reactive {@link HierarchicalTestExecutorService}; never + * {@code null} + */ + public static HierarchicalTestExecutorService createReactive(ConfigurationParameters configurationParameters) { + var configuration = DefaultParallelExecutionConfigurationStrategy.toConfiguration(configurationParameters); + return createReactive(configuration.getParallelism()); + } + private ParallelHierarchicalTestExecutorServiceFactory() { } diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ReactiveHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ReactiveHierarchicalTestExecutorService.java new file mode 100644 index 000000000000..04622ab2ce37 --- /dev/null +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ReactiveHierarchicalTestExecutorService.java @@ -0,0 +1,223 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.engine.support.hierarchical; + +import static java.lang.Math.max; +import static java.lang.Math.min; +import static org.apiguardian.api.API.Status.EXPERIMENTAL; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import org.apiguardian.api.API; +import org.jspecify.annotations.Nullable; +import org.junit.platform.commons.util.UnrecoverableExceptions; + +/** + * A {@link HierarchicalTestExecutorService} that schedules tasks reactively. + * + *

Concurrency is capped by an {@link AsyncResourcePermit} and resource locks + * are acquired through a {@link ReactiveResourceGate}; both hand off waiters via + * completed {@linkplain CompletionStage stages} instead of parking threads. + * Task bodies run on a bounded worker pool. + * + *

This is an opt-in, additive service. The default engine path keeps using + * the existing blocking services. + * + * @since 6.2 + */ +@API(status = EXPERIMENTAL, since = "6.2") +final class ReactiveHierarchicalTestExecutorService implements HierarchicalTestExecutorService { + + private final @Nullable AsyncResourcePermit permit; + private final ReactiveResourceGate resourceGate = new ReactiveResourceGate(); + private final ExecutorService workerPool; + + ReactiveHierarchicalTestExecutorService(int parallelism) { + this(parallelism, Executors.newFixedThreadPool(parallelism, runnable -> { + var thread = new Thread(runnable, "junit-reactive-worker"); + thread.setDaemon(true); + return thread; + })); + } + + /** + * Create a reactive executor for the standalone cooperative lane (no thread + * configuration). Concurrency is derived from the async test methods' own + * returned contexts; a small trigger pool starts the async bodies. + */ + ReactiveHierarchicalTestExecutorService() { + this(null, defaultTriggerPool()); + } + + ReactiveHierarchicalTestExecutorService(@Nullable Integer parallelism, ExecutorService workerPool) { + this.permit = parallelism != null ? new AsyncResourcePermit(parallelism) : null; + this.workerPool = workerPool; + } + + private static ExecutorService defaultTriggerPool() { + int size = min(4, max(1, Runtime.getRuntime().availableProcessors())); + return Executors.newFixedThreadPool(size, runnable -> { + var thread = new Thread(runnable, "junit-reactive-trigger"); + thread.setDaemon(true); + return thread; + }); + } + + @Override + public Future<@Nullable Void> submit(TestTask testTask) { + return executeTask(testTask).toCompletableFuture(); + } + + @Override + public CompletionStage submitAsync(TestTask testTask) { + return executeTask(testTask); + } + + @Override + public void invokeAll(List testTasks) { + invokeAllAsync(testTasks).toCompletableFuture().join(); + } + + @Override + public CompletionStage invokeAllAsync(List testTasks) { + if (testTasks.isEmpty()) { + return CompletableFuture.completedFuture(null); + } + // CONCURRENT children (including implicitly-concurrent async test methods) + // may overlap; SAME_THREAD children run sequentially in discovery order. + CompletionStage sequential = CompletableFuture.completedFuture(null); + List> concurrent = new ArrayList<>(); + for (TestTask testTask : testTasks) { + if (testTask.getExecutionMode() == Node.ExecutionMode.SAME_THREAD) { + sequential = sequential.thenCompose(__ -> executeTask(testTask).toCompletableFuture()); + } + else { + concurrent.add(executeTask(testTask).toCompletableFuture()); + } + } + if (concurrent.isEmpty()) { + return sequential; + } + if (testTasks.size() == concurrent.size()) { + return CompletableFuture.allOf(concurrent.toArray(new CompletableFuture[0])); + } + return sequential.thenCombine(CompletableFuture.allOf(concurrent.toArray(new CompletableFuture[0])), + (___, ____) -> null); + } + + /** + * Acquire a concurrency permit and the task's resource lock, execute the + * task, then release the lock and the permit. Every step is reactive; no + * thread is parked. + * + *

Container nodes do not consume a concurrency permit: they coordinate + * their descendant nodes, and each descendant leaf is what actually holds a + * permit. This avoids an effective deadlock where a container occupying the + * only permit waits for a child that needs the same permit. + * + *

In the standalone cooperative lane ({@code permit == null}) no permit is + * acquired or released at all: concurrency comes from the async methods' + * returned contexts. + */ + private CompletionStage<@Nullable Void> executeTask(TestTask testTask) { + if (isContainer(testTask) || this.permit == null) { + return acquireLockAndRun(testTask, null); + } + // @formatter:off + return this.permit.acquire() + .thenCompose(permitToken -> acquireLockAndRun(testTask, permitToken)); + // @formatter:on + } + + /** + * Determine whether the supplied task is a container node that coordinates + * its descendants (and therefore must not hold a concurrency permit while + * awaiting them). Generic tasks that are not {@link NodeTestTask}s are + * treated as leaves and always hold a permit. + */ + private static boolean isContainer(TestTask testTask) { + if (testTask instanceof NodeTestTask nodeTestTask) { + return !nodeTestTask.getTestDescriptor().isTest(); + } + return false; + } + + private CompletionStage<@Nullable Void> acquireLockAndRun(TestTask testTask, + AsyncResourcePermit.@Nullable Permit permit) { + // Acquire the resource lock reactively first; then run the task body on a + // worker thread, releasing the lock and the permit afterwards. + // @formatter:off + return resourceGate.acquire(testTask.getResourceLock()) + .thenCompose(lock -> runOnWorker(testTask, lock, permit)); + // @formatter:on + } + + /** + * Run a task's body on a worker thread, freeing the worker as soon as a + * genuinely asynchronous body returns a pending stage, and release the lock + * and permit once the body terminates. + */ + @SuppressWarnings("FutureReturnValueIgnored") + private CompletionStage<@Nullable Void> runOnWorker(TestTask testTask, ResourceLock lock, + AsyncResourcePermit.@Nullable Permit permit) { + CompletableFuture<@Nullable Void> running = new CompletableFuture<>(); + workerPool.execute(() -> { + // Run only the synchronous preamble of the task on this worker thread + // and chain the completion handler reactively instead of blocking on + // join(). For an asynchronously-completing task the worker thread is + // thereby freed immediately, so sibling CONCURRENT tasks can all be + // dispatched without being throttled by the worker-pool size. + final CompletableFuture task; + try { + task = testTask.executeAsync().toCompletableFuture(); + } + catch (Throwable throwable) { + UnrecoverableExceptions.rethrowIfUnrecoverable(throwable); + releaseResources(lock, permit); + running.completeExceptionally(throwable); + return; + } + task.whenComplete((___ignore, throwable) -> { + try { + if (throwable != null) { + UnrecoverableExceptions.rethrowIfUnrecoverable(throwable); + running.completeExceptionally(throwable); + } + else { + running.complete(null); + } + } + finally { + releaseResources(lock, permit); + } + }); + }); + return running; + } + + private void releaseResources(ResourceLock lock, AsyncResourcePermit.@Nullable Permit permit) { + resourceGate.release(lock); + if (permit != null) { + permit.release(); + } + } + + @Override + public void close() { + workerPool.shutdown(); + } +} diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ReactiveResourceGate.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ReactiveResourceGate.java new file mode 100644 index 000000000000..f61a1d8bc220 --- /dev/null +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ReactiveResourceGate.java @@ -0,0 +1,213 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.engine.support.hierarchical; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +/** + * A non-blocking FIFO gate for acquiring {@linkplain ResourceLock resource + * locks} without ever parking a thread. + * + *

Mutual exclusion is enforced per resource key: only one exclusive holder + * (or multiple shared holders) may own a given key at any point in time. + * Waiting acquirers register a {@link CompletableFuture} in the FIFO of the + * first busy key; when that key is released the longest-waiting acquirer is + * handed the lock directly ({@code FIFO}) without involving a thread pool. + * + *

Composite locks (multiple resource keys) are acquired all-or-nothing: + * if any constituent key is busy, the acquisition defers on that key and + * releases any keys already acquired, waiting for all to become free (no + * hold-and-wait, so no deadlock). + * + *

All bookkeeping is guarded by a single monitor, which makes the + * enqueue-then-recheck sequence atomic and therefore free of lost-wakeup + * races. Task execution happens outside the monitor. + * + *

The returned {@link CompletionStage} is used purely as a signal that the + * lock is held; it completes with the same {@link ResourceLock} instance, which + * MUST be released exactly once via {@link ResourceLock#release()}. + * + * @since 6.2 + */ +final class ReactiveResourceGate { + + private final Map states = new HashMap<>(); + + /** + * Acquire the supplied lock asynchronously, completing only once all of its + * resources are held. + * + * @param lock the resource lock to acquire; never {@code null} + * @return a completion stage that completes with {@code lock} once held + */ + synchronized CompletionStage acquire(ResourceLock lock) { + List resources = lock.getResources(); + if (resources.isEmpty()) { + return CompletableFuture.completedFuture(lock); + } + CompletableFuture result = new CompletableFuture<>(); + performAcquire(lock, resources, result); + return result; + } + + /** + * Release a previously acquired lock and hand each of its resources to the + * next waiter in FIFO order. + * + * @param lock the acquired resource lock to release; never {@code null} + */ + synchronized void release(ResourceLock lock) { + List resources = lock.getResources(); + if (resources.isEmpty()) { + return; + } + for (int i = resources.size() - 1; i >= 0; i--) { + KeyState state = stateFor(resources.get(i).getKey()); + state.release(); + state.advance(); + } + } + + /** + * Must be called while holding {@code this} monitor. Acquires all resources + * all-or-nothing; on conflict defers on the first busy key and re-checks + * once that key is released. + */ + private void performAcquire(ResourceLock lock, List resources, + CompletableFuture result) { + List acquired = new ArrayList<>(resources.size()); + for (ExclusiveResource resource : resources) { + KeyState state = stateFor(resource.getKey()); + if (!state.acquire(resource.getLockMode())) { + // Release what we already hold (reverse order) and defer on the + // first busy key. + releaseAcquired(acquired); + state.enqueue(this, lock, resources, result); + return; + } + acquired.add(state); + } + result.complete(lock); + } + + private void releaseAcquired(List acquired) { + for (int i = acquired.size() - 1; i >= 0; i--) { + acquired.get(i).release(); + } + } + + private KeyState stateFor(String key) { + return states.computeIfAbsent(key, __ -> new KeyState()); + } + + /** + * Per-key holder state plus the FIFO of acquirers waiting to overtake it. + */ + private static final class KeyState { + + private int sharedHolders; + private boolean heldExclusive; + private final Deque waiters = new ArrayDeque<>(); + + /** Must be called while holding the surrounding gate monitor. */ + boolean acquire(ExclusiveResource.LockMode mode) { + if (mode == ExclusiveResource.LockMode.READ) { + if (heldExclusive) { + return false; + } + sharedHolders++; + return true; + } + // WRITE or READ_WRITE: exclusive access required. + if (heldExclusive || sharedHolders > 0) { + return false; + } + heldExclusive = true; + return true; + } + + /** Must be called while holding the surrounding gate monitor. */ + void release() { + if (heldExclusive) { + heldExclusive = false; + } + else if (sharedHolders > 0) { + sharedHolders--; + } + else { + return; + } + } + + /** Must be called while holding the surrounding gate monitor. */ + void enqueue(ReactiveResourceGate gate, ResourceLock lock, List resources, + CompletableFuture result) { + waiters.addLast(new Acquisition(gate, lock, resources, result)); + } + + /** + * Advance the FIFO: after a holder releases, grant the key to the head + * waiter and let it attempt to acquire all of its resources. + * + * Must be called while holding the surrounding gate monitor. Returns + * {@code true} if the head waiter overtook this key. + */ + boolean advance() { + while (!waiters.isEmpty()) { + if (heldExclusive || sharedHolders > 0) { + return false; + } + Acquisition next = waiters.pollFirst(); + next.attempt(); + // Only one waiter may hold this key at a time; attempt() either + // acquired this key (stopping the loop) or deferred somewhere else. + if (heldExclusive || sharedHolders > 0) { + return true; + } + // If attempt() did not acquire this key (it deferred on another), + // the head has advanced; try the next waiter. + } + return false; + } + } + + /** + * A pending acquisition that must obtain every one of its resources before + * completing. + */ + private static final class Acquisition { + + private final ReactiveResourceGate gate; + private final ResourceLock lock; + private final List resources; + private final CompletableFuture result; + + Acquisition(ReactiveResourceGate gate, ResourceLock lock, List resources, + CompletableFuture result) { + this.gate = gate; + this.lock = lock; + this.resources = resources; + this.result = result; + } + + /** Must be called while holding the gate's monitor. */ + void attempt() { + gate.performAcquire(lock, resources, result); + } + } +} diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ResourceLock.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ResourceLock.java index b80f02fd5ad3..13c978fc9c10 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ResourceLock.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ResourceLock.java @@ -15,8 +15,10 @@ import java.util.List; import java.util.Optional; +import java.util.concurrent.CompletionStage; import org.apiguardian.api.API; +import org.junit.platform.commons.JUnitException; /** * A lock for a one or more resources. @@ -48,6 +50,36 @@ default boolean tryAcquire() { */ ResourceLock acquire() throws InterruptedException; + /** + * Asynchronous variant of {@link #acquire()}. + * + *

The returned {@link CompletionStage} is used purely as a promise that + * this lock has been acquired; its payload is intentionally ignored. Callers + * MUST {@linkplain #release() release} the lock exactly once once the stage + * completes. + * + *

The default implementation bridges the (blocking) {@link #acquire()} + * method into the reactive world. Implementations with a non-blocking + * acquisition strategy (e.g. a {@link ReactiveResourceGate}) are encouraged + * to override this method so that no thread is parked while waiting. + * + * @return a completion stage signaling that this lock has been acquired; + * never {@code null} + * @since 6.2 + */ + @API(status = EXPERIMENTAL, since = "6.2") + default CompletionStage acquireAsync() { + return AsyncTestExecution.bridge(() -> { + try { + acquire(); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new JUnitException("Interrupted while acquiring resource lock", e); + } + }); + } + /** * Release this resource lock. */ diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/api/ConstantTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/api/ConstantTests.java index d2fd4395d8c0..999dd9431afb 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/api/ConstantTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/api/ConstantTests.java @@ -20,6 +20,9 @@ public class ConstantTests { @Test void constantsAreConsistent() { + assertThat(Constants.PARALLEL_EXECUTION_REACTIVE_PROPERTY_NAME) // + .isEqualTo("junit.jupiter.execution.parallel.reactive.enabled"); + assertThat(Constants.PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME).isEqualTo(Constants.PARALLEL_CONFIG_PREFIX + ParallelHierarchicalTestExecutorServiceFactory.EXECUTOR_SERVICE_PROPERTY_NAME); diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/api/extension/KitchenSinkExtension.java b/jupiter-tests/src/test/java/org/junit/jupiter/api/extension/KitchenSinkExtension.java index d9d2fdbfc01d..3e91b112ba43 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/api/extension/KitchenSinkExtension.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/api/extension/KitchenSinkExtension.java @@ -10,9 +10,12 @@ package org.junit.jupiter.api.extension; +import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Constructor; import java.lang.reflect.Method; +import java.lang.reflect.Type; import java.util.Optional; +import java.util.concurrent.CompletionStage; import java.util.stream.Stream; import org.jspecify.annotations.Nullable; @@ -66,7 +69,11 @@ public class KitchenSinkExtension implements // Miscellaneous TestWatcher, InvocationInterceptor, - PreInterruptCallback + AsyncInvocationInterceptor, + PreInterruptCallback, + + // Custom asynchronous return types + AsyncReturnValueHandler // @formatter:on { @@ -280,6 +287,78 @@ public void interceptAfterAllMethod(Invocation<@Nullable Void> invocation, InvocationInterceptor.super.interceptAfterAllMethod(invocation, invocationContext, extensionContext); } + // --- Asynchronous invocation interception + + @Override + public CompletionStage interceptTestClassConstructorAsync( + AsyncInvocationInterceptor.AsyncInvocation invocation, + ReflectiveInvocationContext> invocationContext, ExtensionContext extensionContext) { + return AsyncInvocationInterceptor.super.interceptTestClassConstructorAsync(invocation, invocationContext, + extensionContext); + } + + @Override + public CompletionStage interceptBeforeAllMethodAsync( + AsyncInvocationInterceptor.AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + return AsyncInvocationInterceptor.super.interceptBeforeAllMethodAsync(invocation, invocationContext, + extensionContext); + } + + @Override + public CompletionStage interceptBeforeEachMethodAsync( + AsyncInvocationInterceptor.AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + return AsyncInvocationInterceptor.super.interceptBeforeEachMethodAsync(invocation, invocationContext, + extensionContext); + } + + @Override + public CompletionStage interceptTestMethodAsync(AsyncInvocationInterceptor.AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + return AsyncInvocationInterceptor.super.interceptTestMethodAsync(invocation, invocationContext, + extensionContext); + } + + @Override + public CompletionStage interceptTestFactoryMethodAsync( + AsyncInvocationInterceptor.AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + return AsyncInvocationInterceptor.super.interceptTestFactoryMethodAsync(invocation, invocationContext, + extensionContext); + } + + @Override + public CompletionStage interceptTestTemplateMethodAsync( + AsyncInvocationInterceptor.AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + return AsyncInvocationInterceptor.super.interceptTestTemplateMethodAsync(invocation, invocationContext, + extensionContext); + } + + @Override + public CompletionStage interceptDynamicTestAsync(AsyncInvocationInterceptor.AsyncInvocation invocation, + DynamicTestInvocationContext invocationContext, ExtensionContext extensionContext) { + return AsyncInvocationInterceptor.super.interceptDynamicTestAsync(invocation, invocationContext, + extensionContext); + } + + @Override + public CompletionStage interceptAfterEachMethodAsync( + AsyncInvocationInterceptor.AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + return AsyncInvocationInterceptor.super.interceptAfterEachMethodAsync(invocation, invocationContext, + extensionContext); + } + + @Override + public CompletionStage interceptAfterAllMethodAsync( + AsyncInvocationInterceptor.AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + return AsyncInvocationInterceptor.super.interceptAfterAllMethodAsync(invocation, invocationContext, + extensionContext); + } + // --- PreInterruptCallback ------------------------------------------------ @Override @@ -287,4 +366,16 @@ public void beforeThreadInterrupt(PreInterruptContext preInterruptContext, Exten throws Exception { } + + // --- AsyncReturnValueHandler --------------------------------------------- + + @Override + public boolean supports(Type genericReturnType, @Nullable AnnotatedElement annotatedElement) { + return false; + } + + @Override + public CompletionStage toCompletionStage(Object returnedValue) { + throw new UnsupportedOperationException(); + } } diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/DefaultExecutionModeTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/DefaultExecutionModeTests.java index 932529852225..13ce0005e20e 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/DefaultExecutionModeTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/DefaultExecutionModeTests.java @@ -157,6 +157,44 @@ static class ConcurrentTestCase extends TestCase { static class SameThreadTestCase extends TestCase { } + @Test + void asyncTestMethodsAreImplicitlyConcurrent() { + var engineDescriptor = discoverTestsWithDefaultExecutionMode(AsyncTestCase.class, null); + var classDescriptor = getOnlyElement(engineDescriptor.getChildren()); + // The test class itself has no explicit @Execution and its methods are + // async-returning -> the class-level default stays SAME_THREAD, but each + // async test method resolves implicitly to CONCURRENT. + assertThat(((Node) classDescriptor).getExecutionMode()).isEqualTo(SAME_THREAD); + classDescriptor.getChildren().forEach( + child -> assertThat(((Node) child).getExecutionMode()).isEqualTo(CONCURRENT)); + } + + @Test + void explicitExecutionModeOverridesAsyncImplicitConcurrent() { + var engineDescriptor = discoverTestsWithDefaultExecutionMode(ExplicitSameThreadAsyncTestCase.class, null); + var classDescriptor = getOnlyElement(engineDescriptor.getChildren()); + // Class-level @Execution(SAME_THREAD) wins over the async implicit default. + classDescriptor.getChildren().forEach( + child -> assertThat(((Node) child).getExecutionMode()).isEqualTo(SAME_THREAD)); + } + + static class AsyncTestCase { + + @Test + java.util.concurrent.CompletionStage async() { + return java.util.concurrent.CompletableFuture.completedFuture(null); + } + } + + @Execution(org.junit.jupiter.api.parallel.ExecutionMode.SAME_THREAD) + static class ExplicitSameThreadAsyncTestCase { + + @Test + java.util.concurrent.CompletionStage async() { + return java.util.concurrent.CompletableFuture.completedFuture(null); + } + } + static class OuterTestCase { @Nested class LevelOne { diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/ReactiveExecutionTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/ReactiveExecutionTests.java new file mode 100644 index 000000000000..78a0ff28235d --- /dev/null +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/ReactiveExecutionTests.java @@ -0,0 +1,214 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.engine; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; +import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; +import static org.junit.platform.testkit.engine.EventConditions.finishedSuccessfully; +import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.platform.engine.DiscoverySelector; +import org.junit.platform.testkit.engine.EngineExecutionResults; +import org.junit.platform.testkit.engine.EngineTestKit; + +/** + * Integration tests for the reactive (cooperative) execution lane. + * + * @since 6.2 + */ +class ReactiveExecutionTests { + + @Test + void runsTestsOnReactiveExecutionLane() { + var selector = selectClass(SampleTestCase.class); + + EngineExecutionResults results = executeOnReactiveLane(selector); + + results.testEvents().assertStatistics(stats -> stats.started(2).succeeded(2).aborted(0).failed(0)); + results.testEvents().assertThatEvents().haveExactly(2, finishedSuccessfully()); + } + + @Test + void reactiveLaneHandlesMultipleNodesSafely() { + var selector = selectClass(SampleTestCase2.class); + + EngineExecutionResults results = executeOnReactiveLane(selector); + + results.testEvents().assertStatistics(stats -> stats.started(1).succeeded(1).failed(0)); + } + + @Test + void reactiveLaneTimesOutOverrunningAsyncTestMethod() { + var selector = selectClass(OverrunningAsyncTimeoutTestCase.class); + + EngineExecutionResults results = executeOnReactiveLane(selector); + + results.testEvents().assertStatistics(stats -> stats.started(1).succeeded(0).failed(1)); + results.testEvents().assertThatEvents() // + .haveExactly(1, finishedWithFailure(instanceOf(TimeoutException.class))); + } + + @Test + void standaloneReactiveLaneOverlapsAsyncTestMethods() { + assumeTrue(Runtime.getRuntime().availableProcessors() >= 3, """ + this test requires at least 3 available processors so that the standalone reactive \ + lane can overlap the async test methods"""); + AsyncOverlapTestCase.peak = new java.util.concurrent.atomic.AtomicInteger(); + AsyncOverlapTestCase.inFlight = new java.util.concurrent.atomic.AtomicInteger(); + var selector = selectClass(AsyncOverlapTestCase.class); + + EngineExecutionResults results = executeOnStandaloneReactiveLane(selector); + + results.testEvents().assertStatistics(stats -> stats.started(3).succeeded(3).failed(0)); + assertTrue(AsyncOverlapTestCase.peak.get() >= 2, + "async test methods should overlap under the standalone cooperative lane; peak concurrency=" + + AsyncOverlapTestCase.peak.get()); + } + + @Test + void standaloneReactiveLaneKeepsSyncMethodsOrdered() { + var selector = selectClass(SyncMethodsTestCase.class); + + EngineExecutionResults results = executeOnStandaloneReactiveLane(selector); + + results.testEvents().assertStatistics(stats -> stats.started(2).succeeded(2).failed(0)); + } + + private static EngineExecutionResults executeOnReactiveLane(DiscoverySelector selector) { + return EngineTestKit // + .engine(new JupiterTestEngine()) // + .configurationParameter(org.junit.jupiter.api.Constants.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, + "true") // + .configurationParameter(org.junit.jupiter.api.Constants.PARALLEL_EXECUTION_REACTIVE_PROPERTY_NAME, + "true") // + .configurationParameter(org.junit.jupiter.api.Constants.PARALLEL_CONFIG_FIXED_PARALLELISM_PROPERTY_NAME, + "4") // + .selectors(selector) // + .execute(); + } + + private static EngineExecutionResults executeOnStandaloneReactiveLane(DiscoverySelector selector) { + return EngineTestKit // + .engine(new JupiterTestEngine()) // + .configurationParameter(org.junit.jupiter.api.Constants.JUPITER_EXECUTION_REACTIVE_PROPERTY_NAME, + "true") // + .selectors(selector) // + .execute(); + } + + static class SampleTestCase { + + @Test + void works() { + assertEquals(1, 1); + } + + @Test + CompletionStage worksAsynchronously() { + return CompletableFuture.supplyAsync(() -> "done"); + } + } + + @Timeout(5) + static class SampleTestCase2 { + + static final boolean ok = true; + + @Test + void alsoWorks() { + assertTrue(ok); + } + } + + static class OverrunningAsyncTimeoutTestCase { + + @Test + @Timeout(value = 200, unit = TimeUnit.MILLISECONDS) + CompletionStage overruns() { + return new CompletableFuture<>(); + } + } + + static class AsyncOverlapTestCase { + + private static ExecutorService executor; + + static java.util.concurrent.atomic.AtomicInteger inFlight = new java.util.concurrent.atomic.AtomicInteger(); + static java.util.concurrent.atomic.AtomicInteger peak = new java.util.concurrent.atomic.AtomicInteger(); + + @BeforeAll + static void startExecutor() { + executor = Executors.newFixedThreadPool(3); + } + + @AfterAll + static void stopExecutor() { + executor.shutdownNow(); + } + + @Test + CompletionStage a() { + return asyncBody(); + } + + @Test + CompletionStage b() { + return asyncBody(); + } + + @Test + CompletionStage c() { + return asyncBody(); + } + + private static CompletionStage asyncBody() { + return CompletableFuture.runAsync(() -> { + int current = inFlight.incrementAndGet(); + peak.updateAndGet(seen -> Math.max(seen, current)); + try { + Thread.sleep(100); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + finally { + inFlight.decrementAndGet(); + } + }, executor); + } + } + + static class SyncMethodsTestCase { + + @Test + void one() { + } + + @Test + void two() { + } + } + +} diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/descriptor/CompletionStageLifecycleMethodsTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/descriptor/CompletionStageLifecycleMethodsTests.java new file mode 100644 index 000000000000..f38a7a5ee8fe --- /dev/null +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/descriptor/CompletionStageLifecycleMethodsTests.java @@ -0,0 +1,205 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.engine.descriptor; + +import static java.util.concurrent.CompletableFuture.completedFuture; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.message; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.engine.AbstractJupiterTestEngineTests; + +/** + * Integration tests for lifecycle methods ({@code @BeforeAll}, {@code @AfterAll}, + * {@code @BeforeEach}, and {@code @AfterEach}) that return an asynchronous completion + * signal such as a {@link CompletionStage}. + * + * @since 6.2 + */ +class CompletionStageLifecycleMethodsTests extends AbstractJupiterTestEngineTests { + + @Test + void asyncBeforeEachAndAfterEachCompleteSuccessfully() { + AsyncLifecycleTestCase.beforeEachBodyFlag.set(false); + AsyncLifecycleTestCase.afterEachBodyFlag.set(false); + + var results = executeTestsForClass(AsyncLifecycleTestCase.class); + + results.testEvents().assertStatistics(stats -> stats.started(1).succeeded(1).failed(0)); + assertTrue(AsyncLifecycleTestCase.beforeEachBodyFlag.get(), + "test body should only run after the @BeforeEach stage completes"); + assertTrue(AsyncLifecycleTestCase.afterEachBodyFlag.get(), + "test result should only be reported after the @AfterEach stage completes"); + } + + @Test + void asyncBeforeAllAndAfterAllCompleteSuccessfully() { + AsyncClassLifecycleTestCase.beforeAllBodyFlag.set(false); + AsyncClassLifecycleTestCase.afterAllBodyFlag.set(false); + + var results = executeTestsForClass(AsyncClassLifecycleTestCase.class); + + results.testEvents().assertStatistics(stats -> stats.started(1).succeeded(1).failed(0)); + assertTrue(AsyncClassLifecycleTestCase.beforeAllBodyFlag.get(), + "test body should only run after the @BeforeAll stage completes"); + assertTrue(AsyncClassLifecycleTestCase.afterAllBodyFlag.get(), + "@AfterAll stage should complete before the result is reported"); + } + + @Test + void asyncBeforeEachThatFailsAsynchronouslyIsReportedAsFailed() { + var results = executeTestsForClass(FailingAsyncBeforeEachTestCase.class); + + results.testEvents().assertStatistics(stats -> stats.started(1).succeeded(0).failed(1)); + results.testEvents().assertThatEvents() // + .haveExactly(1, + finishedWithFailure(instanceOf(IllegalStateException.class), message("async before boom"))); + } + + @Test + void asyncAfterEachThatFailsAsynchronouslyIsReportedAsFailed() { + var results = executeTestsForClass(FailingAsyncAfterEachTestCase.class); + + results.testEvents().assertStatistics(stats -> stats.started(1).succeeded(0).failed(1)); + results.testEvents().assertThatEvents() // + .haveExactly(1, + finishedWithFailure(instanceOf(IllegalStateException.class), message("async after boom"))); + } + + @Test + void timeoutAppliesToAwaitedAsyncBeforeEachStage() { + var results = executeTestsForClass(TimedOutAsyncBeforeEachTestCase.class); + + results.testEvents().assertStatistics(stats -> stats.started(1).succeeded(0).failed(1)); + results.testEvents().assertThatEvents() // + .haveExactly(1, finishedWithFailure(instanceOf(TimeoutException.class))); + } + + @Test + void voidLifecycleMethodsStillSupported() { + var results = executeTestsForClass(VoidLifecycleTestCase.class); + + results.testEvents().assertStatistics(stats -> stats.started(1).succeeded(1).failed(0)); + } + + // ---------------------------------------------------------------------- + + static class AsyncLifecycleTestCase { + + static final AtomicBoolean beforeEachBodyFlag = new AtomicBoolean(); + static final AtomicBoolean afterEachBodyFlag = new AtomicBoolean(); + + @BeforeEach + CompletionStage setUpOnAnotherThread() { + return CompletableFuture.runAsync(() -> beforeEachBodyFlag.set(true)); + } + + @AfterEach + CompletionStage tearDownOnAnotherThread() { + return CompletableFuture.runAsync(() -> afterEachBodyFlag.set(true)); + } + + @Test + CompletionStage stageReturningTest() { + return completedFuture(""); + } + } + + static class AsyncClassLifecycleTestCase { + + static final AtomicBoolean beforeAllBodyFlag = new AtomicBoolean(); + static final AtomicBoolean afterAllBodyFlag = new AtomicBoolean(); + + @BeforeAll + static CompletionStage setUpClassOnAnotherThread() { + return CompletableFuture.runAsync(() -> beforeAllBodyFlag.set(true)); + } + + @AfterAll + static CompletionStage tearDownClassOnAnotherThread() { + return CompletableFuture.runAsync(() -> afterAllBodyFlag.set(true)); + } + + @Test + CompletionStage stageReturningTest() { + return completedFuture(""); + } + } + + static class FailingAsyncBeforeEachTestCase { + + @BeforeEach + CompletionStage failingSetUp() { + return CompletableFuture.failedFuture(new IllegalStateException("async before boom")); + } + + @Test + CompletionStage neverRuns() { + return completedFuture(""); + } + } + + static class FailingAsyncAfterEachTestCase { + + @AfterEach + CompletionStage failingTearDown() { + return CompletableFuture.failedFuture(new IllegalStateException("async after boom")); + } + + @Test + CompletionStage runsFine() { + return CompletableFuture.runAsync(() -> { + }); + } + } + + static class TimedOutAsyncBeforeEachTestCase { + + @BeforeEach + @Timeout(value = 200, unit = TimeUnit.MILLISECONDS, threadMode = Timeout.ThreadMode.SEPARATE_THREAD) + CompletableFuture neverCompletes() { + return new CompletableFuture<>(); + } + + @Test + CompletionStage neverRuns() { + return completedFuture(""); + } + } + + static class VoidLifecycleTestCase { + + @BeforeEach + void setUpVoid() { + } + + @AfterEach + void tearDownVoid() { + } + + @Test + void plainTest() { + } + } +} diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/descriptor/CompletionStageTestMethodsTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/descriptor/CompletionStageTestMethodsTests.java new file mode 100644 index 000000000000..ec94ac5ec71b --- /dev/null +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/descriptor/CompletionStageTestMethodsTests.java @@ -0,0 +1,153 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.engine.descriptor; + +import static java.util.concurrent.CompletableFuture.completedFuture; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.message; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.engine.AbstractJupiterTestEngineTests; + +/** + * Integration tests for {@code @Test} methods that return an asynchronous + * completion signal such as a {@link CompletionStage}. + * + * @since 6.0 + */ +class CompletionStageTestMethodsTests extends AbstractJupiterTestEngineTests { + + @Test + void stageReturningTestMethodCompletesSuccessfully() { + var results = executeTestsForClass(StageReturningTestCase.class); + results.testEvents().assertStatistics(stats -> stats.started(1).succeeded(1).failed(0)); + } + + @Test + void futureReturningTestMethodCompletesSuccessfully() { + var results = executeTestsForClass(FutureReturningTestCase.class); + results.testEvents().assertStatistics(stats -> stats.started(1).succeeded(1).failed(0)); + } + + @Test + void stageReturningTestMethodThatFailsAsynchronouslyIsReportedAsFailed() { + var results = executeTestsForClass(FailingStageTestCase.class); + results.testEvents().assertStatistics(stats -> stats.started(1).succeeded(0).failed(1)); + results.testEvents().assertThatEvents() // + .haveExactly(1, finishedWithFailure(instanceOf(IllegalStateException.class), message("async boom"))); + } + + @Test + void stageReturningTestMethodAppliesLifecycleCallbacks() { + LifecycleCallbackTestCase.beforeEachCount.set(0); + LifecycleCallbackTestCase.afterEachCount.set(0); + + var results = executeTestsForClass(LifecycleCallbackTestCase.class); + + results.testEvents().assertStatistics(stats -> stats.started(2).succeeded(2).failed(0)); + assertEquals(2, LifecycleCallbackTestCase.beforeEachCount.get(), "each test method should run @BeforeEach"); + assertEquals(2, LifecycleCallbackTestCase.afterEachCount.get(), "each test method should run @AfterEach"); + } + + @Test + void voidReturningTestMethodStillSupported() { + var results = executeTestsForClass(VoidTestCase.class); + results.testEvents().assertStatistics(stats -> stats.started(1).succeeded(1).failed(0)); + } + + @Test + void timeoutAppliesToAwaitedStageReturningBody() { + var results = executeTestsForClass(TimedOutStageTestCase.class); + results.testEvents().assertStatistics(stats -> stats.started(1).succeeded(0).failed(1)); + results.testEvents().assertThatEvents() // + .haveExactly(1, finishedWithFailure(instanceOf(TimeoutException.class))); + } + + // ---------------------------------------------------------------------- + + static class StageReturningTestCase { + + @Test + CompletionStage onStage() { + return completedFuture("done"); + } + } + + static class FutureReturningTestCase { + + @Test + CompletableFuture onFuture() { + return completedFuture(""); + } + } + + static class FailingStageTestCase { + + @Test + CompletionStage onStage() { + return CompletableFuture.failedFuture(new IllegalStateException("async boom")); + } + } + + static class LifecycleCallbackTestCase { + + static final AtomicInteger beforeEachCount = new AtomicInteger(); + static final AtomicInteger afterEachCount = new AtomicInteger(); + + @BeforeEach + void setUp() { + beforeEachCount.incrementAndGet(); + } + + @AfterEach + void tearDown() { + afterEachCount.incrementAndGet(); + } + + @Test + CompletionStage first() { + return completedFuture(""); + } + + @Test + CompletionStage second() { + return completedFuture(""); + } + } + + static class VoidTestCase { + + @Test + void doesNothing() { + assertEquals(1, 1); + } + } + + static class TimedOutStageTestCase { + + @Test + @Timeout(value = 200, unit = TimeUnit.MILLISECONDS, threadMode = Timeout.ThreadMode.SEPARATE_THREAD) + CompletableFuture neverCompletes() { + return new CompletableFuture<>(); + } + } +} diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtilsTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtilsTests.java index 555e5c345623..526ff0428683 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtilsTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtilsTests.java @@ -21,6 +21,8 @@ import java.lang.reflect.Method; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import org.jspecify.annotations.NullUnmarked; import org.junit.jupiter.api.AfterAll; @@ -52,7 +54,7 @@ void findNonVoidBeforeAllMethodsWithStandardLifecycle() throws Exception { var methodSource = MethodSource.from(TestCaseWithInvalidLifecycleMethods.class.getDeclaredMethod("cc")); var notVoidIssue = DiscoveryIssue.builder(Severity.ERROR, - "@BeforeAll method 'private java.lang.Double org.junit.jupiter.engine.descriptor.TestCaseWithInvalidLifecycleMethods.cc()' must not return a value.") // + "@BeforeAll method 'private java.lang.Double org.junit.jupiter.engine.descriptor.TestCaseWithInvalidLifecycleMethods.cc()' must return void or an async-completable return type (CompletionStage, CompletableFuture, or Future).") // .source(methodSource) // .build(); var notStaticIssue = DiscoveryIssue.builder(Severity.ERROR, @@ -73,7 +75,7 @@ void findNonVoidAfterAllMethodsWithStandardLifecycle() throws Exception { var methodSource = MethodSource.from(TestCaseWithInvalidLifecycleMethods.class.getDeclaredMethod("dd")); var notVoidIssue = DiscoveryIssue.builder(Severity.ERROR, - "@AfterAll method 'private java.lang.String org.junit.jupiter.engine.descriptor.TestCaseWithInvalidLifecycleMethods.dd()' must not return a value.") // + "@AfterAll method 'private java.lang.String org.junit.jupiter.engine.descriptor.TestCaseWithInvalidLifecycleMethods.dd()' must return void or an async-completable return type (CompletionStage, CompletableFuture, or Future).") // .source(methodSource) // .build(); var notStaticIssue = DiscoveryIssue.builder(Severity.ERROR, @@ -94,7 +96,7 @@ void findNonVoidBeforeEachMethodsWithStandardLifecycle() throws Exception { var methodSource = MethodSource.from(TestCaseWithInvalidLifecycleMethods.class.getDeclaredMethod("aa")); var notVoidIssue = DiscoveryIssue.builder(Severity.ERROR, - "@BeforeEach method 'private java.lang.String org.junit.jupiter.engine.descriptor.TestCaseWithInvalidLifecycleMethods.aa()' must not return a value.") // + "@BeforeEach method 'private java.lang.String org.junit.jupiter.engine.descriptor.TestCaseWithInvalidLifecycleMethods.aa()' must return void or an async-completable return type (CompletionStage, CompletableFuture, or Future).") // .source(methodSource) // .build(); var privateIssue = DiscoveryIssue.builder(Severity.WARNING, @@ -111,7 +113,7 @@ void findNonVoidAfterEachMethodsWithStandardLifecycle() throws Exception { var methodSource = MethodSource.from(TestCaseWithInvalidLifecycleMethods.class.getDeclaredMethod("bb")); var notVoidIssue = DiscoveryIssue.builder(Severity.ERROR, - "@AfterEach method 'private int org.junit.jupiter.engine.descriptor.TestCaseWithInvalidLifecycleMethods.bb()' must not return a value.") // + "@AfterEach method 'private int org.junit.jupiter.engine.descriptor.TestCaseWithInvalidLifecycleMethods.bb()' must return void or an async-completable return type (CompletionStage, CompletableFuture, or Future).") // .source(methodSource) // .build(); var privateIssue = DiscoveryIssue.builder(Severity.WARNING, @@ -189,6 +191,26 @@ void findAfterAllMethodsWithLifeCyclePerClassAndRequiringStatic() { assertThat(namesOf(methods)).containsExactlyInAnyOrder("seven", "eight"); } + @Test + void findBeforeEachAndAfterEachMethodsWithAsyncReturnTypes() { + List methods = new ArrayList<>(); + methods.addAll(findBeforeEachMethods(TestCaseWithAsyncLifecycleMethods.class, issueReporter)); + methods.addAll(findAfterEachMethods(TestCaseWithAsyncLifecycleMethods.class, issueReporter)); + + assertThat(namesOf(methods)).containsExactlyInAnyOrder("asyncBeforeEach", "asyncAfterEach"); + assertThat(discoveryIssues).isEmpty(); + } + + @Test + void findBeforeAllAndAfterAllMethodsWithAsyncReturnTypes() { + List methods = new ArrayList<>(); + methods.addAll(findBeforeAllMethods(TestCaseWithAsyncLifecycleMethods.class, true, issueReporter)); + methods.addAll(findAfterAllMethods(TestCaseWithAsyncLifecycleMethods.class, true, issueReporter)); + + assertThat(namesOf(methods)).containsExactlyInAnyOrder("asyncBeforeAll", "asyncAfterAll"); + assertThat(discoveryIssues).isEmpty(); + } + private static List namesOf(List methods) { return methods.stream().map(Method::getName).toList(); } @@ -276,3 +298,27 @@ private String dd() { } } + +class TestCaseWithAsyncLifecycleMethods { + + @BeforeAll + static CompletionStage asyncBeforeAll() { + return CompletableFuture.completedFuture(null); + } + + @AfterAll + static CompletionStage asyncAfterAll() { + return CompletableFuture.completedFuture(null); + } + + @BeforeEach + CompletionStage asyncBeforeEach() { + return CompletableFuture.completedFuture(null); + } + + @AfterEach + CompletionStage asyncAfterEach() { + return CompletableFuture.completedFuture(null); + } + +} diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/discovery/predicates/IsTestMethodTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/discovery/predicates/IsTestMethodTests.java index 29f7734d82ff..00fe6ab212c4 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/discovery/predicates/IsTestMethodTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/discovery/predicates/IsTestMethodTests.java @@ -18,6 +18,9 @@ import java.lang.reflect.Method; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Future; import java.util.function.Predicate; import org.junit.jupiter.api.Test; @@ -57,6 +60,18 @@ void publicTestMethodWithArgument() { assertThat(isTestMethod).accepts(method); } + @Test + void publicAsyncTestMethod() { + Method method = method("publicAsyncTestMethod"); + assertThat(isTestMethod).accepts(method); + } + + @Test + void publicFutureTestMethod() { + Method method = method("publicFutureTestMethod"); + assertThat(isTestMethod).accepts(method); + } + @Test void protectedTestMethod() { assertThat(isTestMethod).accepts(method("protectedTestMethod")); @@ -199,6 +214,16 @@ int bogusTestMethodReturningPrimitive() { public void publicTestMethod() { } + @Test + public CompletionStage publicAsyncTestMethod() { + return CompletableFuture.completedFuture("done"); + } + + @Test + public Future publicFutureTestMethod() { + return CompletableFuture.completedFuture("done"); + } + @Test public void publicTestMethodWithArgument(TestInfo info) { } diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/execution/AsyncInterceptingExecutableInvokerTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/execution/AsyncInterceptingExecutableInvokerTests.java new file mode 100644 index 000000000000..9491dcde5b54 --- /dev/null +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/execution/AsyncInterceptingExecutableInvokerTests.java @@ -0,0 +1,157 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.engine.execution; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import java.lang.reflect.Method; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.AsyncInvocationInterceptor; +import org.junit.jupiter.api.extension.AsyncInvocationInterceptor.AsyncInvocation; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.InvocationInterceptor; +import org.junit.jupiter.api.extension.ReflectiveInvocationContext; +import org.junit.jupiter.engine.extension.MutableExtensionRegistry; + +/** + * Tests for {@link AsyncInterceptingExecutableInvoker} and the asynchronous + * invocation pipeline. + * + * @since 6.2 + */ +class AsyncInterceptingExecutableInvokerTests { + + private final ExtensionContext extensionContext = mock(); + + private final MutableExtensionRegistry extensionRegistry = MutableExtensionRegistry.createEmptyRegistry(); + + @Test + void interceptsVoidMethodAndCompletes() throws Exception { + var method = getMethod(TestCase.class, "voidMethod"); + extensionRegistry.registerExtension(new IncrementingAsyncInterceptor(), this); + + CompletionStage stage = new AsyncInterceptingExecutableInvoker().interceptMethodAsync(method, + new TestCase(), extensionContext, extensionRegistry, interceptTestMethodAsync()); + + stage.toCompletableFuture().get(); + assertEquals(2, TestCase.count); + } + + @Test + void waitsForAsyncReturnValueWithoutBlocking() { + var method = getMethod(TestCase.class, "completionStageMethod"); + var gate = new CompletableFuture(); + + CompletableFuture invocation = new AsyncInterceptingExecutableInvoker().interceptMethodAsync(method, + new TestCase(gate), extensionContext, extensionRegistry, interceptTestMethodAsync()).toCompletableFuture(); + + // Non-blocking guarantee: the produced stage is returned immediately and + // only completes once the intercepted method's own stage completes. + assertFalse(invocation.isDone()); + + gate.complete(null); + assertTrue(invocation.isDone()); + var result = invocation.join(); + } + + @Test + void propagatesAsyncFailure() { + var method = getMethod(TestCase.class, "completionStageMethod"); + var gate = new CompletableFuture(); + gate.completeExceptionally(new IllegalStateException("async boom")); + + CompletableFuture invocation = new AsyncInterceptingExecutableInvoker().interceptMethodAsync(method, + new TestCase(gate), extensionContext, extensionRegistry, interceptTestMethodAsync()).toCompletableFuture(); + + var executionException = assertThrows(ExecutionException.class, invocation::get); + assertTrue(executionException.getCause() instanceof IllegalStateException); + } + + @Test + void adaptsLegacyInvocationInterceptor() throws Exception { + var method = getMethod(TestCase.class, "voidMethod"); + extensionRegistry.registerExtension(new LegacyLoggingInterceptor(), this); + + CompletionStage stage = new AsyncInterceptingExecutableInvoker().interceptMethodAsync(method, + new TestCase(), extensionContext, extensionRegistry, interceptTestMethodAsync()); + + stage.toCompletableFuture().get(); + assertTrue(TestCase.legacySeen.get()); + } + + private static Method getMethod(Class clazz, String name) { + try { + return clazz.getDeclaredMethod(name); + } + catch (NoSuchMethodException e) { + throw new IllegalArgumentException(e); + } + } + + private static AsyncInterceptingExecutableInvoker.AsyncVoidMethodInterceptorCall interceptTestMethodAsync() { + return AsyncInvocationInterceptor::interceptTestMethodAsync; + } + + static class TestCase { + + static volatile int count; + static final AtomicBoolean legacySeen = new AtomicBoolean(); + + private final CompletableFuture gate; + + TestCase() { + this(CompletableFuture.completedFuture(null)); + } + + TestCase(CompletableFuture gate) { + this.gate = gate; + } + + void voidMethod() { + count++; + } + + CompletionStage completionStageMethod() { + return gate; + } + } + + static class IncrementingAsyncInterceptor implements AsyncInvocationInterceptor { + + @Override + public CompletionStage interceptTestMethodAsync(AsyncInvocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) { + TestCase.count++; + return invocation.proceedAsync(); + } + } + + static class LegacyLoggingInterceptor implements InvocationInterceptor { + + @Override + public void interceptTestMethod(Invocation<@org.jspecify.annotations.Nullable Void> invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) + throws Throwable { + TestCase.legacySeen.set(true); + invocation.proceed(); + } + } + +} diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/AsyncReturnValueHandlerCacheTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/AsyncReturnValueHandlerCacheTests.java new file mode 100644 index 000000000000..41f896018f15 --- /dev/null +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/AsyncReturnValueHandlerCacheTests.java @@ -0,0 +1,105 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.engine.extension; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; +import static org.junit.platform.testkit.engine.EventConditions.finishedSuccessfully; + +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicInteger; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.AsyncReturnValueHandler; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.platform.testkit.engine.EngineTestKit; + +/** + * Verifies that {@link AsyncReturnValueHandler#supports(Type, AnnotatedElement)} + * is queried only a bounded number of times per method thanks to caching. + * + * @since 6.2 + */ +class AsyncReturnValueHandlerCacheTests { + + private final CountingAsyncReturnValueHandler handler = new CountingAsyncReturnValueHandler(); + private final AtomicInteger supportCallsSinceReset = new AtomicInteger(); + + @Test + void supportsIsNotInvokedRepeatedlyForTheSameMethod() { + handler.resetSupportCount(); + + EngineTestKit.engine(new org.junit.jupiter.engine.JupiterTestEngine()) // + .selectors(selectClass(TestCase.class)) // + .execute() // + .testEvents() // + .assertStatistics(stats -> stats.started(1).succeeded(1).failed(0)) // + .assertThatEvents().haveExactly(1, finishedSuccessfully()); + + int supportsCallCount = handler.getSupportCount(); + // The result for this method is cached after the first evaluation, so + // `supports` must not be invoked once per descriptor pass. + assertTrue(supportsCallCount <= 2, + "supports() should be called only once during this execution, but was called " + supportsCallCount + + " times"); + } + + // ------------------------------------------------------------------------- + + record MyPromise(CompletionStage delegate) { + + static MyPromise completed(T value) { + return new MyPromise<>(CompletableFuture.completedFuture(value)); + } + } + + static class CountingAsyncReturnValueHandler implements AsyncReturnValueHandler { + + private final AtomicInteger supportCalls = new AtomicInteger(); + + @Override + public boolean supports(Type genericReturnType, @Nullable AnnotatedElement annotatedElement) { + supportCalls.incrementAndGet(); + if (genericReturnType instanceof ParameterizedType parameterizedType) { + return MyPromise.class.isAssignableFrom((Class) parameterizedType.getRawType()); + } + return MyPromise.class == genericReturnType; + } + + @Override + public CompletionStage toCompletionStage(Object returnedValue) { + return ((MyPromise) returnedValue).delegate(); + } + + int getSupportCount() { + return supportCalls.get(); + } + + void resetSupportCount() { + supportCalls.set(0); + } + } + + @ExtendWith(CountingAsyncReturnValueHandler.class) + static class TestCase { + + @Test + MyPromise test() { + return MyPromise.completed("done"); + } + } + +} diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/AsyncReturnValueHandlerTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/AsyncReturnValueHandlerTests.java new file mode 100644 index 000000000000..d9241b06c268 --- /dev/null +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/AsyncReturnValueHandlerTests.java @@ -0,0 +1,285 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.engine.extension; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; +import static org.junit.platform.testkit.engine.EventConditions.finishedSuccessfully; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.AsyncReturnValueHandler; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.platform.engine.DiscoverySelector; +import org.junit.platform.testkit.engine.EngineExecutionResults; +import org.junit.platform.testkit.engine.EngineTestKit; + +/** + * Integration tests for custom asynchronous return types handled via + * {@link AsyncReturnValueHandler}. + * + * @since 6.2 + */ +class AsyncReturnValueHandlerTests { + + @Test + void testMethodReturningCustomPromiseIsAwaitedViaExtendWith() { + executeForClass(ExtendWithTestCase.class).testEvents() // + .assertStatistics(stats -> stats.started(1).succeeded(1).failed(0)) // + .assertThatEvents().haveExactly(1, finishedSuccessfully()); + } + + @Test + void testMethodReturningCustomPromiseIsAwaitedViaUmbrellaAnnotation() { + executeForClass(UmbrellaAnnotationTestCase.class).testEvents() // + .assertStatistics(stats -> stats.started(1).succeeded(1).failed(0)) // + .assertThatEvents().haveExactly(1, finishedSuccessfully()); + } + + @Test + void classLevelExtendWithIsRecognizedWhenReturningCustomPromise() { + executeForClass(ClassLevelExtendWithTestCase.class).testEvents() // + .assertStatistics(stats -> stats.started(1).succeeded(1).failed(0)) // + .assertThatEvents().haveExactly(1, finishedSuccessfully()); + } + + @Test + void classLevelUmbrellaAnnotationIsRecognizedWhenReturningCustomPromise() { + executeForClass(ClassLevelUmbrellaTestCase.class).testEvents() // + .assertStatistics(stats -> stats.started(1).succeeded(1).failed(0)) // + .assertThatEvents().haveExactly(1, finishedSuccessfully()); + } + + @Test + void inheritedClassLevelExtendWithIsRecognizedWhenReturningCustomPromise() { + executeForClass(InheritedClassLevelTestCase.class).testEvents() // + .assertStatistics(stats -> stats.started(1).succeeded(1).failed(0)) // + .assertThatEvents().haveExactly(1, finishedSuccessfully()); + } + + @Test + void enclosingClassLevelExtendWithIsRecognizedForNestedTestWhenReturningCustomPromise() { + executeForClass(NestedTestCase.class).testEvents() // + .assertStatistics(stats -> stats.started(1).succeeded(1).failed(0)) // + .assertThatEvents().haveExactly(1, finishedSuccessfully()); + } + + @Test + void lifecycleMethodsReturningCustomPromiseAreAwaited() { + executeForClass(LifecycleMethodsTestCase.class).testEvents() // + .assertStatistics(stats -> stats.started(1).succeeded(1).failed(0)) // + .assertThatEvents().haveExactly(1, finishedSuccessfully()); + } + + @Test + void nestedClassLifecycleMethodsReturningCustomPromiseAreAwaited() { + executeForClass(NestedLifecycleTestCase.class).testEvents() // + .assertStatistics(stats -> stats.started(1).succeeded(1).failed(0)) // + .assertThatEvents().haveExactly(1, finishedSuccessfully()); + } + + private EngineExecutionResults executeForClass(Class testClass) { + return execute(selectClass(testClass)); + } + + private EngineExecutionResults execute(DiscoverySelector selector) { + return EngineTestKit // + .engine(new org.junit.jupiter.engine.JupiterTestEngine()) // + .selectors(selector) // + .execute(); + } + + // ------------------------------------------------------------------------- + + record MyPromise(CompletionStage delegate) { + + static MyPromise completed(T value) { + return new MyPromise<>(CompletableFuture.completedFuture(value)); + } + + static MyPromise ofAsync(Runnable asyncBody) { + CompletableFuture stage = new CompletableFuture<>(); + asyncBody.run(); + stage.complete(null); + return new MyPromise<>(stage); + } + } + + /** + * Converts a {@link MyPromise} returned from a test method into a + * {@link CompletionStage} to be awaited. + */ + static class MyAsyncReturnValueHandler implements AsyncReturnValueHandler { + + @Override + public boolean supports(Type genericReturnType, @Nullable AnnotatedElement annotatedElement) { + Class rawType = (genericReturnType instanceof ParameterizedType parameterizedType) + ? (Class) parameterizedType.getRawType() + : (Class) genericReturnType; + return MyPromise.class.isAssignableFrom(rawType); + } + + @Override + public CompletionStage toCompletionStage(Object returnedValue) { + return ((MyPromise) returnedValue).delegate(); + } + } + + @Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) + @Retention(RetentionPolicy.RUNTIME) + @ExtendWith(MyAsyncReturnValueHandler.class) + @interface MarkPromise { + } + + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + @MarkPromise + @interface MapMyPromise { + } + + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.RUNTIME) + @ExtendWith(MyAsyncReturnValueHandler.class) + @interface ClassLevelMarkPromise { + } + + static class ExtendWithTestCase { + + @Test + @ExtendWith(MyAsyncReturnValueHandler.class) + MyPromise test() { + return MyPromise.completed("done"); + } + } + + static class UmbrellaAnnotationTestCase { + + @Test + @MapMyPromise + MyPromise test() { + return MyPromise.completed("done"); + } + } + + @ClassLevelMarkPromise + static class ClassLevelExtendWithTestCase { + + @Test + MyPromise test() { + return MyPromise.completed("done"); + } + } + + @ClassLevelMarkPromise + static class ClassLevelUmbrellaTestCase { + + @Test + MyPromise test() { + return MyPromise.completed("done"); + } + } + + @ClassLevelMarkPromise + static class AbstractClassLevelBase { + + @Test + MyPromise test() { + return MyPromise.completed("done"); + } + } + + static class InheritedClassLevelTestCase extends AbstractClassLevelBase { + } + + @ClassLevelMarkPromise + static class NestedTestCase { + + @Nested + class NestedInner { + + @Test + MyPromise test() { + return MyPromise.completed("done"); + } + } + } + + @ClassLevelMarkPromise + static class LifecycleMethodsTestCase { + + static volatile boolean beforeAllCompleted; + static volatile boolean beforeAllAfterEachSeen; + + @BeforeAll + static MyPromise beforeAll() { + return MyPromise.ofAsync(() -> beforeAllCompleted = true); + } + + @BeforeEach + MyPromise beforeEach() { + // Must only run after @BeforeAll's async work completed. + return MyPromise.ofAsync(() -> beforeAllAfterEachSeen = beforeAllCompleted); + } + + @AfterEach + MyPromise afterEach() { + return MyPromise.ofAsync(() -> { + }); + } + + @AfterAll + static MyPromise afterAll() { + return MyPromise.ofAsync(() -> { + }); + } + + @Test + void test() { + // @BeforeAll completed asynchronously before this test ran. + assertTrue(beforeAllAfterEachSeen); + } + } + + @ClassLevelMarkPromise + static class NestedLifecycleTestCase { + + @Nested + class NestedInner { + + @BeforeEach + MyPromise beforeEach() { + return MyPromise.ofAsync(() -> { + }); + } + + @Test + MyPromise test() { + return MyPromise.completed("done"); + } + } + } + +} diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/AsyncTimeoutTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/AsyncTimeoutTests.java new file mode 100644 index 000000000000..d2b52e6a9f7b --- /dev/null +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/AsyncTimeoutTests.java @@ -0,0 +1,116 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.engine.extension; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.junit.jupiter.api.Timeout.ThreadMode.SAME_THREAD; +import static org.junit.jupiter.api.Timeout.ThreadMode.SEPARATE_THREAD; +import static org.junit.platform.testkit.engine.EventConditions.finishedSuccessfully; +import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure; +import static org.junit.platform.testkit.engine.TestExecutionResultConditions.instanceOf; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeoutException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.engine.AbstractJupiterTestEngineTests; + +/** + * Integration tests for {@link TimeoutExtension} applied to test methods that + * return an asynchronous completion signal. + * + * @since 6.2 + */ +class AsyncTimeoutTests extends AbstractJupiterTestEngineTests { + + @Test + void asyncTestMethodThatOverrunsTimesOutOnSameThread() { + var results = executeTestsForClass(OverrunningAsyncTestCase.class); + + results.testEvents().assertStatistics(stats -> stats.started(1).succeeded(0).failed(1)); + results.testEvents().assertThatEvents() // + .haveExactly(1, finishedWithFailure(instanceOf(TimeoutException.class))); + } + + @Test + void asyncTestMethodThatOverrunsTimesOutOnSeparateThread() { + var results = executeTestsForClass(OverrunningSeparateThreadAsyncTestCase.class); + + results.testEvents().assertStatistics(stats -> stats.started(1).succeeded(0).failed(1)); + results.testEvents().assertThatEvents() // + .haveExactly(1, finishedWithFailure(instanceOf(TimeoutException.class))); + } + + @Test + void asyncTestMethodCompletingWithinTimeoutSucceeds() { + var results = executeTestsForClass(FastAsyncTestCase.class); + + results.testEvents().assertStatistics(stats -> stats.started(1).succeeded(1).failed(0)); + results.testEvents().assertThatEvents().haveExactly(1, finishedSuccessfully()); + } + + @Test + void timedOutAsyncTestIsNeverReportedSuccessful() { + // A never-completing async body with a timeout: the test must fail with + // TimeoutException and must NOT be reported successful. + var results = executeTestsForClass(NeverCompletingAsyncTestCase.class); + + results.testEvents().assertStatistics(stats -> stats.started(1).succeeded(0).failed(1)); + results.testEvents().assertThatEvents().haveExactly(1, finishedWithFailure(instanceOf(TimeoutException.class))); + } + + static class OverrunningAsyncTestCase { + + @Test + @Timeout(value = 200, unit = MILLISECONDS, threadMode = SAME_THREAD) + CompletionStage overruns() { + return new CompletableFuture<>(); + } + } + + static class OverrunningSeparateThreadAsyncTestCase { + + @Test + @Timeout(value = 200, unit = MILLISECONDS, threadMode = SEPARATE_THREAD) + CompletionStage overruns() { + return new CompletableFuture<>(); + } + } + + static class FastAsyncTestCase { + + @Test + @Timeout(value = 5, unit = MILLISECONDS, threadMode = SAME_THREAD) + CompletionStage fast() { + return CompletableFuture.supplyAsync(() -> { + try { + Thread.sleep(1); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return "done"; + }); + } + } + + static class NeverCompletingAsyncTestCase { + + @Test + @Timeout(value = 200, unit = MILLISECONDS, threadMode = SAME_THREAD) + CompletionStage never() { + return new CompletableFuture<>(); + } + } + +} diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/EarlyExtensionRegistryTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/EarlyExtensionRegistryTests.java new file mode 100644 index 000000000000..13b0cb8edfa9 --- /dev/null +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/EarlyExtensionRegistryTests.java @@ -0,0 +1,114 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.engine.extension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Type; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.ServiceLoader; +import java.util.concurrent.CompletionStage; +import java.util.function.Predicate; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.AsyncReturnValueHandler; +import org.junit.jupiter.api.extension.EarlyExtension; +import org.junit.jupiter.api.extension.Extension; +import org.junit.jupiter.engine.config.JupiterConfiguration; +import org.junit.platform.commons.util.ClassLoaderUtils; +import org.junit.platform.commons.util.ServiceLoaderUtils; + +/** + * Unit tests for {@link EarlyExtensionRegistry}. + * + * @since 6.2 + */ +class EarlyExtensionRegistryTests { + + @Test + void doesNotLoadEarlyExtensionsWhenAutoDetectionIsDisabled() { + JupiterConfiguration configuration = configurationWithAutoDetection(false); + + EarlyExtensionRegistry registry = EarlyExtensionRegistry.create(configuration); + + assertTrue(registry.getAsyncReturnValueHandlers().isEmpty()); + } + + @Test + void loadsServiceLoaderProvidedAsyncReturnValueHandlers() throws IOException { + Path testDir = Files.createTempDirectory("early-extension-registry-test"); + Path servicesDir = testDir.resolve("META-INF/services"); + Files.createDirectories(servicesDir); + Files.writeString(servicesDir.resolve(Extension.class.getName()), EarlyReturnValueHandler.class.getName()); + + try (URLClassLoader classLoader = new URLClassLoader(new URL[] { testDir.toUri().toURL() }, + ClassLoaderUtils.getDefaultClassLoader())) { + JupiterConfiguration configuration = configurationWithAutoDetection(true); + + // Use the temporary classloader the same way the engine does, by + // loading via ServiceLoader.filter(..., EarlyExtension::isAssignableFrom). + var serviceLoader = ServiceLoader.load(Extension.class, classLoader); + List handlers = ServiceLoaderUtils // + .filter(serviceLoader, clazz -> EarlyExtension.class.isAssignableFrom(clazz)) // + .filter(AsyncReturnValueHandler.class::isInstance) // + .map(AsyncReturnValueHandler.class::cast) // + .toList(); + + assertEquals(1, handlers.size()); + assertTrue(handlers.get(0) instanceof EarlyReturnValueHandler); + } + } + + @Test + void getAsyncReturnValueHandlersIsEmptyByDefaultWhenAutoDetectionDisabled() { + EarlyExtensionRegistry registry = EarlyExtensionRegistry.create(configurationWithAutoDetection(false)); + + assertSame(Collections.emptyList(), registry.getAsyncReturnValueHandlers(), "an empty list should be returned"); + } + + private static JupiterConfiguration configurationWithAutoDetection(boolean enabled) { + JupiterConfiguration configuration = mock(JupiterConfiguration.class); + when(configuration.isExtensionAutoDetectionEnabled()).thenReturn(enabled); + when(configuration.getFilterForAutoDetectedExtensions()).thenReturn((Predicate>) // + clazz -> true); + return configuration; + } + + /** + * Minimal, globally registered {@link AsyncReturnValueHandler} for verifying + * ServiceLoader-based discovery. + */ + public static class EarlyReturnValueHandler implements AsyncReturnValueHandler { + + @Override + public boolean supports(Type genericReturnType, @Nullable AnnotatedElement element) { + return false; + } + + @Override + public CompletionStage toCompletionStage(Object returnedValue) { + throw new UnsupportedOperationException("not used in this test"); + } + } + +} diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedClassIntegrationTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedClassIntegrationTests.java index baf10bc058f4..9eaa634546f2 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedClassIntegrationTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedClassIntegrationTests.java @@ -682,7 +682,7 @@ void lifecycleMethodsMustNotDeclareReturnType() { .isEqualTo(Severity.ERROR); assertThat(issue.message()) // .isEqualTo( - "@BeforeParameterizedClassInvocation method 'static int %s.beforeParameterizedClassInvocation()' must not return a value.", + "@BeforeParameterizedClassInvocation method 'static int %s.beforeParameterizedClassInvocation()' must return void or an async-completable return type (CompletionStage, CompletableFuture, or Future).", NonVoidLifecycleMethodTestCase.class.getName()); assertThat(issue.source()) // .containsInstanceOf(org.junit.platform.engine.support.descriptor.MethodSource.class); diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/AsyncResourcePermitTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/AsyncResourcePermitTests.java new file mode 100644 index 000000000000..e08c3214c0af --- /dev/null +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/AsyncResourcePermitTests.java @@ -0,0 +1,115 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.engine.support.hierarchical; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.Test; +import org.junit.platform.commons.PreconditionViolationException; +import org.junit.platform.engine.support.hierarchical.AsyncResourcePermit.Permit; + +/** + * Tests for {@link AsyncResourcePermit}. + * + * @since 6.2 + */ +class AsyncResourcePermitTests { + + @Test + void acquiresUpToMaxPermitsImmediately() { + var permit = new AsyncResourcePermit(2); + + var first = permit.acquire().toCompletableFuture(); + var second = permit.acquire().toCompletableFuture(); + + assertThat(first).isDone(); + assertThat(second).isDone(); + assertThat(permit.availablePermits()).isZero(); + } + + @Test + void queuesAcquirerWhenExhaustedUntilRelease() { + var permit = new AsyncResourcePermit(1); + + var first = permit.acquire().toCompletableFuture(); + var thirdBeyond = permit.acquire().toCompletableFuture(); + + assertThat(first).isDone(); + assertThat(thirdBeyond).isNotDone(); + + var held = first.join(); + assertThat(held).isNotNull(); + held.release(); + + assertThat(thirdBeyond).isDone(); + } + + @Test + void handsPermitsToWaitersNotBackToPool() { + var permit = new AsyncResourcePermit(1); + + Permit first = permit.acquire().toCompletableFuture().join(); + var second = permit.acquire().toCompletableFuture(); + + first.release(); + + // The released permit goes to the waiting acquirer, so a new acquirer + // must still wait until the second acquirer releases. + var third = permit.acquire().toCompletableFuture(); + assertThat(second).isDone(); + assertThat(third).isNotDone(); + + second.join().release(); + assertThat(third).isDone(); + } + + @Test + void throwsForNonPositiveMaxPermits() { + assertThatThrownBy(() -> new AsyncResourcePermit(0)).isInstanceOf(PreconditionViolationException.class); + } + + @Test + void permitReleasedAtMostOnce() { + var permit = new AsyncResourcePermit(1); + + Permit held = permit.acquire().toCompletableFuture().join(); + held.release(); + held.release(); + + assertThat(permit.availablePermits()).isOne(); + } + + @Test + void isFifoFairUnderContention() { + var permit = new AsyncResourcePermit(1); + Permit first = permit.acquire().toCompletableFuture().join(); + + List> order = new ArrayList<>(); + var a = permit.acquire().toCompletableFuture(); + var b = permit.acquire().toCompletableFuture(); + a.whenComplete((p, t) -> order.add(a)); + b.whenComplete((p, t) -> order.add(b)); + + first.release(); + // The first released permit goes to the head of the FIFO (a). + assertThat(a).isDone(); + assertThat(b).isNotDone(); + + a.join().release(); + assertThat(b).isDone(); + assertThat(order).containsExactly(a, b); + } +} diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/AsyncTestExecutionTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/AsyncTestExecutionTests.java new file mode 100644 index 000000000000..97df0595fac1 --- /dev/null +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/AsyncTestExecutionTests.java @@ -0,0 +1,96 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.engine.support.hierarchical; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link AsyncTestExecution}. + * + * @since 6.0 + */ +class AsyncTestExecutionTests { + + @Test + void bridgeCompletesSuccessfullyForBlockingExecution() { + AtomicBoolean executed = new AtomicBoolean(); + var stage = AsyncTestExecution.bridge(() -> executed.set(true)); + + assertThat(completedNormally(stage)).isTrue(); + assertThat(executed).isTrue(); + } + + @Test + void bridgeCompletesExceptionallyForBlockingFailure() { + RuntimeException expected = new RuntimeException("boom"); + var stage = AsyncTestExecution.bridge(() -> { + throw expected; + }); + + assertThatThrownBy(stage.toCompletableFuture()::join).hasRootCause(expected); + } + + @Test + void bridgeCompletesExceptionallyForUnrecoverableError() { + OutOfMemoryError expected = new OutOfMemoryError("boom"); + var stage = AsyncTestExecution.bridge(() -> { + throw expected; + }); + + assertThatThrownBy(stage.toCompletableFuture()::join).hasRootCause(expected); + } + + @Test + void synchronousRunsOnCallingThread() { + Thread caller = Thread.currentThread(); + AtomicReference executionThread = new AtomicReference<>(); + var stage = AsyncTestExecution.synchronous(() -> executionThread.set(Thread.currentThread())); + + assertThat(completedNormally(stage)).isTrue(); + assertThat(executionThread).hasValue(caller); + } + + @Test + void synchronousCompletesExceptionallyForFailure() { + IllegalStateException expected = new IllegalStateException("boom"); + var stage = AsyncTestExecution.synchronous(() -> { + throw expected; + }); + + assertThatThrownBy(stage.toCompletableFuture()::join).hasRootCause(expected); + } + + @Test + void synchronousRethrowsUnrecoverableErrorSynchronously() { + OutOfMemoryError expected = new OutOfMemoryError("boom"); + + assertThatThrownBy(() -> AsyncTestExecution.synchronous(() -> { + throw expected; + })).isSameAs(expected); + } + + private boolean completedNormally(CompletionStage stage) { + try { + stage.toCompletableFuture().join(); + return true; + } + catch (Throwable throwable) { + return false; + } + } +} diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ReactiveHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ReactiveHierarchicalTestExecutorServiceTests.java new file mode 100644 index 000000000000..9af38fb087b8 --- /dev/null +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ReactiveHierarchicalTestExecutorServiceTests.java @@ -0,0 +1,163 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.engine.support.hierarchical; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BooleanSupplier; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.function.Executable; +import org.junit.platform.engine.support.hierarchical.ExclusiveResource.LockMode; +import org.junit.platform.engine.support.hierarchical.Node.ExecutionMode; + +/** + * Tests for {@link ReactiveHierarchicalTestExecutorService}. + * + * @since 6.2 + */ +@Timeout(10) +class ReactiveHierarchicalTestExecutorServiceTests { + + private final LockManager lockManager = new LockManager(); + + @Test + void runsUnrelatedTasksConcurrentlyWithoutBlocking() throws Exception { + var nop = NopLock.INSTANCE; + List events = new CopyOnWriteArrayList<>(); + CountDownLatch release = new CountDownLatch(1); + + try (var service = new ReactiveHierarchicalTestExecutorService(2)) { + var a = service.submit(task("a", nop, () -> { + events.add("a-start"); + awaitLatch(release); + events.add("a-end"); + })); + var b = service.submit(task("b", nop, () -> events.add("b-start"))); + + // "b" shares no lock with "a", so it may run concurrently even while "a" is blocked. + awaitUpTo(() -> events.contains("a-start") && events.contains("b-start")); + assertThat(events).contains("a-start"); + + release.countDown(); + a.get(); + b.get(); + } + } + + @Test + void enforcesExclusiveAccessForExclusiveResource() throws Exception { + var lock = lockManager.getLockForResource(new ExclusiveResource("shared", LockMode.READ_WRITE)); + AtomicInteger maxActive = new AtomicInteger(); + AtomicInteger active = new AtomicInteger(); + + try (var service = new ReactiveHierarchicalTestExecutorService(2)) { + List> futures = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + futures.add(service.submit(task("task-" + i, lock, () -> { + int now = active.incrementAndGet(); + maxActive.accumulateAndGet(now, Math::max); + sleepQuietly(1); + active.decrementAndGet(); + }))); + } + for (var future : futures) { + future.get(); + } + } + assertThat(maxActive.get()).isOne(); + } + + @Test + void invokeAllExecutesAllSuppliedTasks() { + var nop = NopLock.INSTANCE; + var executed = new AtomicInteger(); + // @formatter:off + List tasks = List.of( + new TestTaskStub(nop, executed::incrementAndGet), + new TestTaskStub(nop, executed::incrementAndGet) + ); + // @formatter:on + + try (var service = new ReactiveHierarchicalTestExecutorService(2)) { + service.invokeAll(tasks); + } + assertEquals(2, executed.get()); + } + + private static TestTaskStub task(String name, ResourceLock lock, Executable action) { + return new TestTaskStub(lock, action); + } + + private static void awaitLatch(CountDownLatch latch) { + try { + latch.await(); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private static void sleepQuietly(long millis) { + try { + Thread.sleep(millis); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private static void awaitUpTo(BooleanSupplier condition) { + long deadline = System.currentTimeMillis() + 2_000; + while (!condition.getAsBoolean()) { + assertThat(System.currentTimeMillis()).as("condition not met within timeout").isLessThan(deadline); + sleepQuietly(5); + } + } + + private static final class TestTaskStub implements HierarchicalTestExecutorService.TestTask { + + private final ResourceLock lock; + private final Executable action; + + TestTaskStub(ResourceLock lock, Executable action) { + this.lock = lock; + this.action = action; + } + + @Override + public ExecutionMode getExecutionMode() { + return ExecutionMode.CONCURRENT; + } + + @Override + public ResourceLock getResourceLock() { + return lock; + } + + @Override + public void execute() { + try { + action.execute(); + } + catch (Throwable e) { + throw new AssertionError("task threw unexpectedly", e); + } + } + } +} diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ReactiveResourceGateTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ReactiveResourceGateTests.java new file mode 100644 index 000000000000..e427b3637d9c --- /dev/null +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ReactiveResourceGateTests.java @@ -0,0 +1,132 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.engine.support.hierarchical; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.locks.ReentrantLock; + +import org.junit.jupiter.api.Test; +import org.junit.platform.engine.support.hierarchical.ExclusiveResource.LockMode; + +/** + * Tests for {@link ReactiveResourceGate}. + * + * @since 6.2 + */ +class ReactiveResourceGateTests { + + private final ReactiveResourceGate gate = new ReactiveResourceGate(); + + @Test + void acquiresUncontendedExclusiveLock() { + var lock = lockOf("key", LockMode.READ_WRITE); + + var acquired = gate.acquire(lock).toCompletableFuture(); + + assertThat(acquired.join()).isSameAs(lock); + gate.release(lock); + } + + @Test + void holdsExclusiveLockUntilReleased() { + var lock = lockOf("key", LockMode.READ_WRITE); + + var first = gate.acquire(lock).toCompletableFuture(); + var second = gate.acquire(lockOf("key", LockMode.READ_WRITE)).toCompletableFuture(); + + // The second acquisition must not complete while the first is held. + assertThat(first).isDone(); + assertThat(second).isNotDone(); + + gate.release(lock); + + // Releasing hands the lock to the waiting (FIFO) acquirer. + assertThat(second).isDone(); + gate.release(lockOf("key", LockMode.READ_WRITE)); + } + + @Test + void servicesIndependentResourcesConcurrently() { + var a = lockOf("a", LockMode.READ_WRITE); + var b = lockOf("b", LockMode.READ_WRITE); + + var acquiredA = gate.acquire(a).toCompletableFuture(); + var acquiredB = gate.acquire(b).toCompletableFuture(); + + assertThat(acquiredA).isDone(); + assertThat(acquiredB).isDone(); + + gate.release(a); + gate.release(b); + } + + @Test + void allowsConcurrentReadHolders() { + var readA = gate.acquire(lockOf("key", LockMode.READ)).toCompletableFuture(); + var readB = gate.acquire(lockOf("key", LockMode.READ)).toCompletableFuture(); + + assertThat(readA).isDone(); + assertThat(readB).isDone(); + } + + @Test + void writerDoesNotProceedWhileReadersHold() { + var reader = lockOf("key", LockMode.READ); + gate.acquire(reader); + gate.acquire(lockOf("key", LockMode.READ)); + + var writer = gate.acquire(lockOf("key", LockMode.READ_WRITE)).toCompletableFuture(); + + assertThat(writer).isNotDone(); + } + + @Test + void fifoHandoffUnderContention() { + // Acquire the key exclusively, then enqueue two waiters. + var holder = lockOf("key", LockMode.READ_WRITE); + gate.acquire(holder); + + var first = gate.acquire(lockOf("key", LockMode.READ_WRITE)).toCompletableFuture(); + var second = gate.acquire(lockOf("key", LockMode.READ_WRITE)).toCompletableFuture(); + + List> completionOrder = new ArrayList<>(); + first.whenComplete((a, b) -> completionOrder.add(first)); + second.whenComplete((a, b) -> completionOrder.add(second)); + + gate.release(holder); + // After the first release, the first waiter takes the key. + assertThat(first).isDone(); + assertThat(second).isNotDone(); + + gate.release(lockOf("key", LockMode.READ_WRITE)); + assertThat(second).isDone(); + assertThat(completionOrder).containsExactly(first, second); + } + + @Test + void completesNopLockImmediately() { + var nop = NopLock.INSTANCE; + + var acquired = gate.acquire(nop).toCompletableFuture(); + + assertThat(acquired).isDone(); + assertThat(acquired.join()).isSameAs(nop); + } + + private static ResourceLock lockOf(String key, LockMode mode) { + var lock = new ReentrantLock(); + return new SingleLock(new ExclusiveResource(key, mode), lock); + } +} diff --git a/platform-tooling-support-tests/projects/graalvm-starter/build.gradle.kts b/platform-tooling-support-tests/projects/graalvm-starter/build.gradle.kts index d8c137b32556..01589be32c00 100644 --- a/platform-tooling-support-tests/projects/graalvm-starter/build.gradle.kts +++ b/platform-tooling-support-tests/projects/graalvm-starter/build.gradle.kts @@ -33,8 +33,23 @@ tasks.test { } val initializeAtBuildTime = mapOf>( - // These need to be added to native-build-tools - "6.2" to listOf(), + // Workaround for GraalVM/JDK 21 only; see the `if (jdkVersion <= 21)` guard below. + // + // These JUnit 6.2 async classes initialize eagerly during GraalVM's build-time analysis + // (TestMethodTestDescriptor. -> AsyncInterceptingExecutableInvoker. -> + // AsyncInvocationInterceptorChain.), which conflicts with GraalVM's default + // runtime class-initialization and fails with + // "Classes that should be initialized at run time got initialized during image building". + // + // The permanent fix is to add them to native-build-tools' initialize-at-buildtime list + // (which already contains the sync equivalents such as InterceptingExecutableInvoker and + // InvocationInterceptorChain): + // https://github.com/graalvm/native-build-tools/blob/master/common/junit-platform-native/src/main/resources/initialize-at-buildtime + // Once they are registered there, remove this entry again. + "6.2" to listOf( + "org.junit.jupiter.engine.execution.AsyncInvocationInterceptorChain", + "org.junit.jupiter.engine.execution.AsyncInterceptingExecutableInvoker", + ), ) graalvmNative {