Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce EnableTestScopedConstructorContext annotation for extensions #4032

Merged
merged 4 commits into from
Oct 8, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions documentation/src/docs/asciidoc/link-attributes.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ endif::[]
:BeforeAllCallback: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/extension/BeforeAllCallback.html[BeforeAllCallback]
:BeforeEachCallback: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/extension/BeforeEachCallback.html[BeforeEachCallback]
:BeforeTestExecutionCallback: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/extension/BeforeTestExecutionCallback.html[BeforeTestExecutionCallback]
:EnableTestScopedConstructorContext: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/extension/EnableTestScopedConstructorContext.html[@EnableTestScopedConstructorContext]
:ExecutableInvoker: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/extension/ExecutableInvoker.html[ExecutableInvoker]
:ExecutionCondition: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/extension/ExecutionCondition.html[ExecutionCondition]
:ExtendWith: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/extension/ExtendWith.html[@ExtendWith]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ JUnit repository on GitHub.
[[release-notes-5.12.0-M1-junit-jupiter-deprecations-and-breaking-changes]]
==== Deprecations and Breaking Changes

* ❓
* `ParameterResolver` extensions receive a different `ExtensionContext` for constructor
parameters of the test instance. Since the `ExtensionContext` is now consistent with
parameters of test methods, extensions are unlikely to break, but the behavior may
change in certain scenarios.

[[release-notes-5.12.0-M1-junit-jupiter-new-features-and-improvements]]
==== New Features and Improvements
Expand All @@ -54,6 +57,12 @@ JUnit repository on GitHub.
`@ConvertWith`), and `ArgumentsAggregator` (declared via `@AggregateWith`)
implementations can now use constructor injection from registered `ParameterResolver`
extensions.
* Implementations of `ParameterResolver` now receive a test-specific `ExtensionContext`
for constructor parameters of the test class.
* `@EnableTestScopedConstructorContext` has been added to enable the use of a test-scoped
`ExtensionContext` in `TestInstancePreConstructCallback`, `TestInstancePostProcessor`
and `TestInstanceFactory`. The behavior enabled by the annotation is expected to
eventually become the default in future versions of JUnit Jupiter.


[[release-notes-5.12.0-M1-junit-vintage]]
Expand Down
18 changes: 18 additions & 0 deletions documentation/src/docs/asciidoc/user-guide/extensions.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,12 @@ This extension provides a symmetric call to `{TestInstancePreDestroyCallback}` a
in combination with other extensions to prepare constructor parameters or keeping track of test
instances and their lifecycle.

[NOTE]
====
You may annotate your extension with `{EnableTestScopedConstructorContext}` for revised
handling of `CloseableResource` and to make test-specific data available to your implementation.
====

[[extensions-test-instance-factories]]
=== Test Instance Factories

Expand All @@ -407,6 +413,12 @@ the user's responsibility to ensure that only a single `TestInstanceFactory` is
registered for any specific test class.
====

[NOTE]
====
You may annotate your extension with `{EnableTestScopedConstructorContext}` for revised
handling of `CloseableResource` and to make test-specific data available to your implementation.
====

[[extensions-test-instance-post-processing]]
=== Test Instance Post-processing

Expand All @@ -419,6 +431,12 @@ initialization methods on the test instance, etc.
For a concrete example, consult the source code for the `{MockitoExtension}` and the
`{SpringExtension}`.

[NOTE]
====
You may annotate your extension with `{EnableTestScopedConstructorContext}` for revised
handling of `CloseableResource` and to make test-specific data available to your implementation.
====

[[extensions-test-instance-pre-destroy-callback]]
=== Test Instance Pre-destroy Callback

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* Copyright 2015-2024 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.MAINTAINED;

import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import org.apiguardian.api.API;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.extension.ExtensionContext.Store;
import org.junit.jupiter.api.extension.ExtensionContext.Store.CloseableResource;

/**
* {@code @EnableTestScopedConstructorContext} allows
* {@link Extension Extensions} to use a test-scoped {@link ExtensionContext}
* during creation of test instances.
*
* <p>The annotation should be used on extension classes.
* JUnit will call the following extension callbacks of annotated extensions
* with a test-scoped {@link ExtensionContext}, unless the test class is
* annotated with {@link TestInstance @TestInstance(Lifecycle.PER_CLASS)}.
*
* <ul>
* <li>{@link TestInstancePreConstructCallback}</li>
* <li>{@link TestInstancePostProcessor}</li>
* <li>{@link TestInstanceFactory}</li>
* </ul>
*
* <p>Implementations of these extension callbacks can observe the following
* differences if they are using {@code @EnableTestScopedConstructorContext}.
*
* <ul>
* <li>{@link ExtensionContext#getElement() getElement()} may refer to the test
* method and {@link ExtensionContext#getTestClass() getTestClass()} may refer
* to a nested test class. Use {@link TestInstanceFactoryContext#getTestClass()}
* to get the class under construction.</li>
* <li>{@link ExtensionContext#getTestMethod() getTestMethod()} is no-longer
* empty, unless the test class is annotated with
* {@link TestInstance @TestInstance(Lifecycle.PER_CLASS)}.</li>
* <li>If the callback adds a new {@link CloseableResource CloseableResource} to
* the {@link Store Store}, the resource is closed just after the instance is
* destroyed.</li>
* <li>The callbacks can now access data previously stored by
* {@link TestTemplateInvocationContext}, unless the test class is annotated
* with {@link TestInstance @TestInstance(Lifecycle.PER_CLASS)}.</li>
* </ul>
*
* <p><strong>Note</strong>: The behavior which is enabled by this annotation is
* expected to become the default in future versions of JUnit Jupiter. To ensure
* future compatibility, extension vendors are therefore advised to annotate
* their extensions, even if they don't need the new functionality.
*
* @since 5.12
* @see TestInstancePreConstructCallback
* @see TestInstancePostProcessor
* @see TestInstanceFactory
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@API(status = MAINTAINED, since = "5.12")
marcphilipp marked this conversation as resolved.
Show resolved Hide resolved
public @interface EnableTestScopedConstructorContext {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Feel free to suggest other names. This is the best name that came to mind. I think the name is good enough, at least I like it much more than my other ideas (e.g. NewTestInstanceConstructionContext).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I just realized that you have already suggested @MethodLevelExtensionContextAware. I don't have a strong preference. Your suggestion is a bit shorter.

Copy link
Member

Choose a reason for hiding this comment

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

Let's stick with your name for now. I'll brainstorm with the team in our next team call.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I just wanted to note, that after the idea of a common method has lingered in my head over the last day, I have started to prefer it. The idea would be to add a default method to the empty Extension interface. At the end, I don't think any of my reasons is that significant, and I am totally happy with the current solution as well. Anyway, here are the reasons why I started to like the idea of a default method, although it is more clunky to use.

  • Improved discoverability as the method is shown by the IDE when you look for methods to override (e.g. Ctrl+I in IntelliJ). However, the Javadoc also makes it rather easy to find already.
  • Extensions can use arbitrary logic for the flag. For example, a wrapper or proxy which delegates all method calls to another extension would be able to preserve the flag from the delegate. However, it is probably a very special use case and not that relevant.
  • Less likely to impact the performance without having to introduce any cache.
  • Maybe more consistent, as I think there are currently no other annotations used in the extension logic.
  • A default method could technically be removed again without breaking binary compatibility (ABI). However, I assume removing it from the API again is not really an option anyway, so it is not really relevant.

Feel free to pick whatever you prefer. I just wanted to communicate my thoughts.

Copy link
Member

Choose a reason for hiding this comment

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

Those are good points! To get a better feel for it, I've given it a try in #4062. I may be subject to the IKEA effect now, but it like the solution better for the reasons you've given above.

}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import static org.apiguardian.api.API.Status.STABLE;

import org.apiguardian.api.API;
import org.junit.jupiter.api.extension.ExtensionContext.Store.CloseableResource;

/**
* {@code TestInstanceFactory} defines the API for {@link Extension
Expand Down Expand Up @@ -56,6 +57,11 @@ public interface TestInstanceFactory extends Extension {
/**
* Callback for creating a test instance for the supplied context.
*
* <p>You may annotate your extension with
* {@link EnableTestScopedConstructorContext @EnableTestScopedConstructorContext}
* for revised handling of {@link CloseableResource CloseableResource} and
* to make test-specific data available to your implementation.
*
* <p><strong>Note</strong>: the {@code ExtensionContext} supplied to a
* {@code TestInstanceFactory} will always return an empty
* {@link java.util.Optional} value from
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import static org.apiguardian.api.API.Status.STABLE;

import org.apiguardian.api.API;
import org.junit.jupiter.api.extension.ExtensionContext.Store.CloseableResource;

/**
* {@code TestInstancePostProcessor} defines the API for {@link Extension
Expand Down Expand Up @@ -45,6 +46,11 @@ public interface TestInstancePostProcessor extends Extension {
/**
* Callback for post-processing the supplied test instance.
*
* <p>You may annotate your extension with
* {@link EnableTestScopedConstructorContext @EnableTestScopedConstructorContext}
* for revised handling of {@link CloseableResource CloseableResource} and
* to make test-specific data available to your implementation.
*
* <p><strong>Note</strong>: the {@code ExtensionContext} supplied to a
* {@code TestInstancePostProcessor} will always return an empty
* {@link java.util.Optional} value from {@link ExtensionContext#getTestInstance()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import org.apiguardian.api.API;
import org.junit.jupiter.api.TestInstance.Lifecycle;
import org.junit.jupiter.api.extension.ExtensionContext.Store.CloseableResource;

/**
* {@code TestInstancePreConstructCallback} defines the API for {@link Extension
Expand Down Expand Up @@ -49,6 +50,11 @@ public interface TestInstancePreConstructCallback extends Extension {
/**
* Callback invoked prior to test instances being constructed.
*
* <p>You may annotate your extension with
* {@link EnableTestScopedConstructorContext @EnableTestScopedConstructorContext}
* for revised handling of {@link CloseableResource CloseableResource} and
* to make test-specific data available to your implementation.
*
* @param factoryContext the context for the test instance about to be instantiated;
* never {@code null}
* @param context the current extension context; never {@code null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import org.junit.jupiter.api.TestInstance.Lifecycle;
import org.junit.jupiter.api.extension.AfterAllCallback;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.EnableTestScopedConstructorContext;
import org.junit.jupiter.api.extension.Extension;
import org.junit.jupiter.api.extension.ExtensionConfigurationException;
import org.junit.jupiter.api.extension.ExtensionContext;
Expand Down Expand Up @@ -66,6 +67,7 @@
import org.junit.jupiter.engine.extension.ExtensionRegistry;
import org.junit.jupiter.engine.extension.MutableExtensionRegistry;
import org.junit.platform.commons.JUnitException;
import org.junit.platform.commons.util.AnnotationUtils;
import org.junit.platform.commons.util.ExceptionUtils;
import org.junit.platform.commons.util.ReflectionUtils;
import org.junit.platform.commons.util.StringUtils;
Expand Down Expand Up @@ -277,15 +279,18 @@ private TestInstancesProvider testInstancesProvider(JupiterEngineExecutionContex
// For Lifecycle.PER_CLASS, ourExtensionContext.getTestInstances() is used to store the instance.
// Otherwise, extensionContext.getTestInstances() is always empty and we always create a new instance.
return (registry, context) -> ourExtensionContext.getTestInstances().orElseGet(
() -> instantiateAndPostProcessTestInstance(parentExecutionContext, registry, context));
() -> instantiateAndPostProcessTestInstance(parentExecutionContext, ourExtensionContext, registry,
context));
}

private TestInstances instantiateAndPostProcessTestInstance(JupiterEngineExecutionContext parentExecutionContext,
ExtensionRegistry registry, JupiterEngineExecutionContext context) {
ClassExtensionContext ourExtensionContext, ExtensionRegistry registry,
JupiterEngineExecutionContext context) {

TestInstances instances = instantiateTestClass(parentExecutionContext, registry, context);
TestInstances instances = instantiateTestClass(parentExecutionContext, ourExtensionContext, registry, context);
context.getThrowableCollector().execute(() -> {
invokeTestInstancePostProcessors(instances.getInnermostInstance(), registry, context.getExtensionContext());
invokeTestInstancePostProcessors(instances.getInnermostInstance(), registry, context.getExtensionContext(),
ourExtensionContext);
// In addition, we initialize extension registered programmatically from instance fields here
// since the best time to do that is immediately following test class instantiation
// and post-processing.
Expand All @@ -295,27 +300,35 @@ private TestInstances instantiateAndPostProcessTestInstance(JupiterEngineExecuti
}

protected abstract TestInstances instantiateTestClass(JupiterEngineExecutionContext parentExecutionContext,
ExtensionRegistry registry, JupiterEngineExecutionContext context);
ExtensionContext ourExtensionContext, ExtensionRegistry registry, JupiterEngineExecutionContext context);

protected TestInstances instantiateTestClass(Optional<TestInstances> outerInstances, ExtensionRegistry registry,
ExtensionContext extensionContext) {
ExtensionContext extensionContext, ExtensionContext ourExtensionContext) {

Optional<Object> outerInstance = outerInstances.map(TestInstances::getInnermostInstance);
invokeTestInstancePreConstructCallbacks(new DefaultTestInstanceFactoryContext(this.testClass, outerInstance),
registry, extensionContext);
registry, extensionContext, ourExtensionContext);
Object instance = this.testInstanceFactory != null //
? invokeTestInstanceFactory(outerInstance, extensionContext) //
: invokeTestClassConstructor(outerInstance, registry, extensionContext);
? invokeTestInstanceFactory(outerInstance, extensionContext, ourExtensionContext) //
: invokeTestClassConstructor(outerInstance, registry, extensionContext, ourExtensionContext);
return outerInstances.map(instances -> DefaultTestInstances.of(instances, instance)).orElse(
DefaultTestInstances.of(instance));
}

private Object invokeTestInstanceFactory(Optional<Object> outerInstance, ExtensionContext extensionContext) {
private Object invokeTestInstanceFactory(Optional<Object> outerInstance, ExtensionContext extensionContext,
ExtensionContext ourExtensionContext) {
Object instance;

try {
instance = this.testInstanceFactory.createTestInstance(
new DefaultTestInstanceFactoryContext(this.testClass, outerInstance), extensionContext);
if (AnnotationUtils.isAnnotated(this.testInstanceFactory.getClass(),
EnableTestScopedConstructorContext.class)) {
marcphilipp marked this conversation as resolved.
Show resolved Hide resolved
instance = this.testInstanceFactory.createTestInstance(
new DefaultTestInstanceFactoryContext(this.testClass, outerInstance), extensionContext);
}
else {
instance = this.testInstanceFactory.createTestInstance(
new DefaultTestInstanceFactoryContext(this.testClass, outerInstance), ourExtensionContext);
}
}
catch (Throwable throwable) {
UnrecoverableExceptions.rethrowIfUnrecoverable(throwable);
Expand Down Expand Up @@ -355,24 +368,36 @@ private Object invokeTestInstanceFactory(Optional<Object> outerInstance, Extensi
}

private Object invokeTestClassConstructor(Optional<Object> outerInstance, ExtensionRegistry registry,
ExtensionContext extensionContext) {
ExtensionContext extensionContext, ExtensionContext ourExtensionContext) {

Constructor<?> constructor = ReflectionUtils.getDeclaredConstructor(this.testClass);
return executableInvoker.invoke(constructor, outerInstance, extensionContext, registry,
InvocationInterceptor::interceptTestClassConstructor);
marcphilipp marked this conversation as resolved.
Show resolved Hide resolved
}

private void invokeTestInstancePreConstructCallbacks(TestInstanceFactoryContext factoryContext,
ExtensionRegistry registry, ExtensionContext context) {
registry.stream(TestInstancePreConstructCallback.class).forEach(
extension -> executeAndMaskThrowable(() -> extension.preConstructTestInstance(factoryContext, context)));
ExtensionRegistry registry, ExtensionContext context, ExtensionContext ourContext) {
registry.stream(TestInstancePreConstructCallback.class).forEach(extension -> {
if (AnnotationUtils.isAnnotated(extension.getClass(), EnableTestScopedConstructorContext.class)) {
executeAndMaskThrowable(() -> extension.preConstructTestInstance(factoryContext, context));
}
else {
executeAndMaskThrowable(() -> extension.preConstructTestInstance(factoryContext, ourContext));
}
});
}

private void invokeTestInstancePostProcessors(Object instance, ExtensionRegistry registry,
ExtensionContext context) {
private void invokeTestInstancePostProcessors(Object instance, ExtensionRegistry registry, ExtensionContext context,
ClassExtensionContext ourContext) {

registry.stream(TestInstancePostProcessor.class).forEach(
extension -> executeAndMaskThrowable(() -> extension.postProcessTestInstance(instance, context)));
registry.stream(TestInstancePostProcessor.class).forEach(extension -> {
if (AnnotationUtils.isAnnotated(extension.getClass(), EnableTestScopedConstructorContext.class)) {
executeAndMaskThrowable(() -> extension.postProcessTestInstance(instance, context));
}
else {
executeAndMaskThrowable(() -> extension.postProcessTestInstance(instance, ourContext));
}
});
}

private void executeAndMaskThrowable(Executable executable) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import java.util.Set;

import org.apiguardian.api.API;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.TestInstances;
import org.junit.jupiter.engine.config.JupiterConfiguration;
import org.junit.jupiter.engine.execution.JupiterEngineExecutionContext;
Expand Down Expand Up @@ -71,8 +72,8 @@ public ExecutionMode getExecutionMode() {

@Override
protected TestInstances instantiateTestClass(JupiterEngineExecutionContext parentExecutionContext,
ExtensionRegistry registry, JupiterEngineExecutionContext context) {
return instantiateTestClass(Optional.empty(), registry, context.getExtensionContext());
ExtensionContext ourExtensionContext, ExtensionRegistry registry, JupiterEngineExecutionContext context) {
return instantiateTestClass(Optional.empty(), registry, context.getExtensionContext(), ourExtensionContext);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import java.util.Set;

import org.apiguardian.api.API;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.TestInstances;
import org.junit.jupiter.engine.config.JupiterConfiguration;
import org.junit.jupiter.engine.execution.JupiterEngineExecutionContext;
Expand Down Expand Up @@ -74,13 +75,14 @@ public List<Class<?>> getEnclosingTestClasses() {

@Override
protected TestInstances instantiateTestClass(JupiterEngineExecutionContext parentExecutionContext,
ExtensionRegistry registry, JupiterEngineExecutionContext context) {
ExtensionContext ourExtensionContext, ExtensionRegistry registry, JupiterEngineExecutionContext context) {

// Extensions registered for nested classes and below are not to be used for instantiating and initializing outer classes
ExtensionRegistry extensionRegistryForOuterInstanceCreation = parentExecutionContext.getExtensionRegistry();
TestInstances outerInstances = parentExecutionContext.getTestInstancesProvider().getTestInstances(
extensionRegistryForOuterInstanceCreation, context);
return instantiateTestClass(Optional.of(outerInstances), registry, context.getExtensionContext());
return instantiateTestClass(Optional.of(outerInstances), registry, context.getExtensionContext(),
ourExtensionContext);
}

}
Loading