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

Add constructor injection support for ParameterizedTest SPIs #4025

Merged
merged 5 commits into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ JUnit repository on GitHub.
* In a `@ParameterizedTest` method, a `null` value can now be supplied for Java Date/Time
types such as `LocalDate` if the new `nullable` attribute in
`@JavaTimeConversionPattern` is set to `true`.
* `ArgumentsProvider` (declared via `@ArgumentsSource`), `ArgumentConverter` (declared via
`@ConvertWith`), and `ArgumentsAggregator` (declared via `@AggregateWith`)
implementations can now use constructor injection from registered `ParameterResolver`
extensions.


[[release-notes-5.12.0-M1-junit-vintage]]
Expand Down
9 changes: 9 additions & 0 deletions documentation/src/docs/asciidoc/user-guide/writing-tests.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -1984,6 +1984,15 @@ If you wish to implement a custom `ArgumentsProvider` that also consumes an anno
(like built-in providers such as `{ValueArgumentsProvider}` or `{CsvArgumentsProvider}`),
you have the possibility to extend the `{AnnotationBasedArgumentsProvider}` class.

Moreover, `ArgumentsProvider` implementations may declare constructor parameters in case
they need to be resolved by a registered `ParameterResolver` as demonstrated in the
following example.

[source,java,indent=0]
----
include::{testDir}/example/ParameterizedTestDemo.java[tags=ArgumentsProviderWithConstructorInjection_example]
----

[[writing-tests-parameterized-repeatable-sources]]
===== Multiple sources using repeatable annotations
Repeatable annotations provide a convenient way to specify multiple sources from
Expand Down
23 changes: 23 additions & 0 deletions documentation/src/test/java/example/ParameterizedTestDemo.java
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,29 @@ public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
}
// end::ArgumentsProvider_example[]

@ParameterizedTest
@ArgumentsSource(MyArgumentsProviderWithConstructorInjection.class)
void testWithArgumentsSourceWithConstructorInjection(String argument) {
assertNotNull(argument);
}

static
// tag::ArgumentsProviderWithConstructorInjection_example[]
public class MyArgumentsProviderWithConstructorInjection implements ArgumentsProvider {

private final TestInfo testInfo;

public MyArgumentsProviderWithConstructorInjection(TestInfo testInfo) {
this.testInfo = testInfo;
}

@Override
public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
return Stream.of(Arguments.of(testInfo.getDisplayName()));
}
}
// end::ArgumentsProviderWithConstructorInjection_example[]

// tag::ParameterResolver_example[]
@BeforeEach
void beforeEach(TestInfo testInfo) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ public void formatTestNames(Blackhole blackhole) throws Exception {
var formatter = new ParameterizedTestNameFormatter(
ParameterizedTest.DISPLAY_NAME_PLACEHOLDER + " " + ParameterizedTest.DEFAULT_DISPLAY_NAME + " ({0})",
"displayName",
new ParameterizedTestMethodContext(TestCase.class.getDeclaredMethod("parameterizedTest", int.class)), 512);
new ParameterizedTestMethodContext(TestCase.class.getDeclaredMethod("parameterizedTest", int.class), null),
512);
for (int i = 0; i < argumentsList.size(); i++) {
Arguments arguments = argumentsList.get(i);
blackhole.consume(formatter.format(i, arguments, arguments.get()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@
import org.junit.jupiter.params.provider.ArgumentsProvider;
import org.junit.jupiter.params.provider.ArgumentsSource;
import org.junit.jupiter.params.support.AnnotationConsumerInitializer;
import org.junit.platform.commons.JUnitException;
import org.junit.platform.commons.support.ReflectionSupport;
import org.junit.platform.commons.util.ExceptionUtils;
import org.junit.platform.commons.util.Preconditions;

Expand All @@ -52,7 +50,7 @@ public boolean supportsTestTemplate(ExtensionContext context) {
return false;
}

ParameterizedTestMethodContext methodContext = new ParameterizedTestMethodContext(testMethod);
ParameterizedTestMethodContext methodContext = new ParameterizedTestMethodContext(testMethod, context);

Preconditions.condition(methodContext.hasPotentiallyValidSignature(),
() -> String.format(
Expand Down Expand Up @@ -84,7 +82,7 @@ public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContex
return findRepeatableAnnotations(templateMethod, ArgumentsSource.class)
.stream()
.map(ArgumentsSource::value)
.map(this::instantiateArgumentsProvider)
.map(clazz -> ParameterizedTestSpiInstantiator.instantiate(ArgumentsProvider.class, clazz, extensionContext))
.map(provider -> AnnotationConsumerInitializer.initialize(templateMethod, provider))
.flatMap(provider -> arguments(provider, extensionContext))
.map(arguments -> {
Expand All @@ -97,23 +95,6 @@ public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContex
// @formatter:on
}

@SuppressWarnings("ConstantConditions")
private ArgumentsProvider instantiateArgumentsProvider(Class<? extends ArgumentsProvider> clazz) {
try {
return ReflectionSupport.newInstance(clazz);
}
catch (Exception ex) {
if (ex instanceof NoSuchMethodException) {
String message = String.format("Failed to find a no-argument constructor for ArgumentsProvider [%s]. "
+ "Please ensure that a no-argument constructor exists and "
+ "that the class is either a top-level class or a static nested class",
clazz.getName());
throw new JUnitException(message, ex);
}
throw ex;
}
}

private ExtensionContext.Store getStore(ExtensionContext context) {
return context.getStore(Namespace.create(ParameterizedTestExtension.class, context.getRequiredTestMethod()));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import java.util.List;
import java.util.Optional;

import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.params.aggregator.AggregateWith;
Expand All @@ -31,7 +32,6 @@
import org.junit.jupiter.params.converter.DefaultArgumentConverter;
import org.junit.jupiter.params.support.AnnotationConsumerInitializer;
import org.junit.platform.commons.support.AnnotationSupport;
import org.junit.platform.commons.support.ReflectionSupport;
import org.junit.platform.commons.util.StringUtils;

/**
Expand All @@ -43,16 +43,18 @@
class ParameterizedTestMethodContext {

private final Parameter[] parameters;
private final ExtensionContext extensionContext;
private final Resolver[] resolvers;
private final List<ResolverType> resolverTypes;

ParameterizedTestMethodContext(Method testMethod) {
ParameterizedTestMethodContext(Method testMethod, ExtensionContext extensionContext) {
this.parameters = testMethod.getParameters();
this.resolvers = new Resolver[this.parameters.length];
this.resolverTypes = new ArrayList<>(this.parameters.length);
for (Parameter parameter : this.parameters) {
this.resolverTypes.add(isAggregator(parameter) ? AGGREGATOR : CONVERTER);
}
this.extensionContext = extensionContext;
}

/**
Expand Down Expand Up @@ -167,7 +169,7 @@ Object resolve(ParameterContext parameterContext, Object[] arguments, int invoca
private Resolver getResolver(ParameterContext parameterContext) {
int index = parameterContext.getIndex();
if (resolvers[index] == null) {
resolvers[index] = resolverTypes.get(index).createResolver(parameterContext);
resolvers[index] = resolverTypes.get(index).createResolver(parameterContext, extensionContext);
}
return resolvers[index];
}
Expand All @@ -176,11 +178,11 @@ enum ResolverType {

CONVERTER {
@Override
Resolver createResolver(ParameterContext parameterContext) {
Resolver createResolver(ParameterContext parameterContext, ExtensionContext extensionContext) {
try { // @formatter:off
return AnnotationSupport.findAnnotation(parameterContext.getParameter(), ConvertWith.class)
.map(ConvertWith::value)
.map(clazz -> (ArgumentConverter) ReflectionSupport.newInstance(clazz))
.map(clazz -> ParameterizedTestSpiInstantiator.instantiate(ArgumentConverter.class, clazz, extensionContext))
.map(converter -> AnnotationConsumerInitializer.initialize(parameterContext.getParameter(), converter))
.map(Converter::new)
.orElse(Converter.DEFAULT);
Expand All @@ -193,11 +195,11 @@ Resolver createResolver(ParameterContext parameterContext) {

AGGREGATOR {
@Override
Resolver createResolver(ParameterContext parameterContext) {
Resolver createResolver(ParameterContext parameterContext, ExtensionContext extensionContext) {
try { // @formatter:off
return AnnotationSupport.findAnnotation(parameterContext.getParameter(), AggregateWith.class)
.map(AggregateWith::value)
.map(clazz -> (ArgumentsAggregator) ReflectionSupport.newInstance(clazz))
.map(clazz -> ParameterizedTestSpiInstantiator.instantiate(ArgumentsAggregator.class, clazz, extensionContext))
.map(Aggregator::new)
.orElse(Aggregator.DEFAULT);
} // @formatter:on
Expand All @@ -207,7 +209,7 @@ Resolver createResolver(ParameterContext parameterContext) {
}
};

abstract Resolver createResolver(ParameterContext parameterContext);
abstract Resolver createResolver(ParameterContext parameterContext, ExtensionContext extensionContext);

}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* 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.params;

import static org.junit.platform.commons.util.CollectionUtils.getFirstElement;

import java.lang.reflect.Constructor;
import java.util.Optional;

import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.platform.commons.JUnitException;
import org.junit.platform.commons.PreconditionViolationException;
import org.junit.platform.commons.util.Preconditions;
import org.junit.platform.commons.util.ReflectionUtils;

/**
* @since 5.12
*/
class ParameterizedTestSpiInstantiator {

static <T> T instantiate(Class<T> spiInterface, Class<? extends T> implementationClass,
ExtensionContext extensionContext) {
return extensionContext.getExecutableInvoker() //
.invoke(findConstructor(spiInterface, implementationClass));
}

/**
* Find the "best" constructor for the supplied class.
*
* <p>For backward compatibility, it first checks for a default constructor
* which takes precedence over any other constructor. If no default
* constructor is found, it checks for a single constructor and returns it.
*/
private static <T, V extends T> Constructor<? extends V> findConstructor(Class<T> spiInterface,
Class<V> implementationClass) {

Preconditions.condition(!ReflectionUtils.isInnerClass(implementationClass),
() -> String.format("The %s [%s] must be either a top-level class or a static nested class",
spiInterface.getSimpleName(), implementationClass.getName()));

return findDefaultConstructor(implementationClass) //
.orElseGet(() -> findSingleConstructor(spiInterface, implementationClass));
}
sbrannen marked this conversation as resolved.
Show resolved Hide resolved

@SuppressWarnings("unchecked")
private static <T> Optional<Constructor<T>> findDefaultConstructor(Class<T> clazz) {
return getFirstElement(ReflectionUtils.findConstructors(clazz, it -> it.getParameterCount() == 0)) //
.map(it -> (Constructor<T>) it);
}

private static <T, V extends T> Constructor<V> findSingleConstructor(Class<T> spiInterface,
Class<V> implementationClass) {

try {
return ReflectionUtils.getDeclaredConstructor(implementationClass);
}
catch (PreconditionViolationException ex) {
String message = String.format(
"Failed to find constructor for %s [%s]. "
+ "Please ensure that a no-argument or a single constructor exists.",
spiInterface.getSimpleName(), implementationClass.getName());
throw new JUnitException(message);
}
}
}
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.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolver;

/**
* {@code ArgumentsAggregator} is an abstraction for the aggregation of arguments
Expand All @@ -33,10 +34,11 @@
* in a CSV file into a domain object such as a {@code Person}, {@code Address},
* {@code Order}, etc.
*
* <p>Implementations must provide a no-args constructor and should not make any
* assumptions regarding when they are instantiated or how often they are called.
* Since instances may potentially be cached and called from different threads,
* they should be thread-safe and designed to be used as singletons.
* <p>Implementations must provide a no-args constructor or a single unambiguous
* constructor to use {@linkplain ParameterResolver parameter resolution}. They
* should not make any assumptions regarding when they are instantiated or how
* often they are called. Since instances may potentially be cached and called
* from different threads, they should be thread-safe.
*
* @since 5.2
* @see AggregateWith
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.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolver;

/**
* {@code ArgumentConverter} is an abstraction that allows an input object to
Expand All @@ -24,10 +25,11 @@
* method with the help of a
* {@link org.junit.jupiter.params.converter.ConvertWith @ConvertWith} annotation.
*
* <p>Implementations must provide a no-args constructor and should not make any
* assumptions regarding when they are instantiated or how often they are called.
* Since instances may potentially be cached and called from different threads,
* they should be thread-safe and designed to be used as singletons.
* <p>Implementations must provide a no-args constructor or a single unambiguous
* constructor to use {@linkplain ParameterResolver parameter resolution}. They
* should not make any assumptions regarding when they are instantiated or how
* often they are called. Since instances may potentially be cached and called
* from different threads, they should be thread-safe.
*
* <p>Extend {@link SimpleArgumentConverter} if your implementation only needs
* to know the target type and does not need access to the {@link ParameterContext}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import org.apiguardian.api.API;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterResolver;

/**
* An {@code ArgumentsProvider} is responsible for {@linkplain #provideArguments
Expand All @@ -25,7 +26,8 @@
* <p>An {@code ArgumentsProvider} can be registered via the
* {@link ArgumentsSource @ArgumentsSource} annotation.
*
* <p>Implementations must provide a no-args constructor.
* <p>Implementations must provide a no-args constructor or a single unambiguous
* constructor to use {@linkplain ParameterResolver parameter resolution}.
*
* @since 5.0
* @see org.junit.jupiter.params.ParameterizedTest
Expand Down
Loading