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 zero invocations in test templates and parameterized tests #3890

Merged
merged 6 commits into from
Oct 15, 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 @@ -66,6 +66,11 @@ JUnit repository on GitHub.
`@ConvertWith`), and `ArgumentsAggregator` (declared via `@AggregateWith`)
implementations can now use constructor injection from registered `ParameterResolver`
extensions.
* Extensions based on `TestTemplateInvocationContextProvider` can now allow returning zero
invocation contexts by overriding the new `mayReturnZeroTestTemplateInvocationContexts`
method.
* The new `@ParameterizedTest(requireArguments = false)` attribute allows to specify that
the absence of arguments is expected in some cases and should not cause a test failure.
* Allow determining "shared resources" at runtime via the new `@ResourceLock#providers`
attribute that accepts implementations of `ResourceLocksProvider`.
* Extensions that implement `TestInstancePreConstructCallback`, `TestInstanceFactory`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

package org.junit.jupiter.api.extension;

import static org.apiguardian.api.API.Status.EXPERIMENTAL;
import static org.apiguardian.api.API.Status.STABLE;

import java.util.stream.Stream;
Expand Down Expand Up @@ -86,4 +87,26 @@ public interface TestTemplateInvocationContextProvider extends Extension {
*/
Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContexts(ExtensionContext context);

/**
* Signal that in the supplied {@linkplain ExtensionContext context} the
* provider may return zero
* {@linkplain TestTemplateInvocationContext invocation contexts}.
*
* <p>If this method returns {@code false} and the provider returns an empty
* stream from {@link #provideTestTemplateInvocationContexts}, this will be
* considered an execution error. Override this method to ignore the absence
* of invocation contexts for this provider.
*
* @param context the extension context for the test template method about
* to be invoked; never {@code null}
* @return {@code true} to allow zero contexts, {@code false} (default) to
* fail execution in case of zero contexts.
*
* @since 5.12
*/
@API(status = EXPERIMENTAL, since = "5.12")
default boolean mayReturnZeroTestTemplateInvocationContexts(ExtensionContext context) {
return false;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

package org.junit.jupiter.engine.descriptor;

import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList;
import static org.apiguardian.api.API.Status.INTERNAL;
import static org.junit.jupiter.engine.descriptor.ExtensionUtils.populateNewExtensionRegistryFromExtendWithAnnotation;
Expand Down Expand Up @@ -102,16 +101,30 @@ public JupiterEngineExecutionContext execute(JupiterEngineExecutionContext conte
context.getExtensionRegistry());
AtomicInteger invocationIndex = new AtomicInteger();
for (TestTemplateInvocationContextProvider provider : providers) {
try (Stream<TestTemplateInvocationContext> stream = invocationContexts(provider, extensionContext)) {
stream.forEach(
invocationContext -> toTestDescriptor(invocationContext, invocationIndex.incrementAndGet()) //
.ifPresent(testDescriptor -> execute(dynamicTestExecutor, testDescriptor)));
}
executeForProvider(provider, invocationIndex, dynamicTestExecutor, extensionContext);
}
validateWasAtLeastInvokedOnce(invocationIndex.get(), providers);
return context;
}

private void executeForProvider(TestTemplateInvocationContextProvider provider, AtomicInteger invocationIndex,
DynamicTestExecutor dynamicTestExecutor, ExtensionContext extensionContext) {

int initialValue = invocationIndex.get();

try (Stream<TestTemplateInvocationContext> stream = invocationContexts(provider, extensionContext)) {
stream.forEach(invocationContext -> toTestDescriptor(invocationContext, invocationIndex.incrementAndGet()) //
.ifPresent(testDescriptor -> execute(dynamicTestExecutor, testDescriptor)));
}

Preconditions.condition(
invocationIndex.get() != initialValue
|| provider.mayReturnZeroTestTemplateInvocationContexts(extensionContext),
String.format(
"Provider [%s] did not provide any invocation contexts, but was expected to do so. "
+ "You may override mayReturnZeroTestTemplateInvocationContexts() to allow this.",
provider.getClass().getSimpleName()));
}

private static Stream<TestTemplateInvocationContext> invocationContexts(
TestTemplateInvocationContextProvider provider, ExtensionContext extensionContext) {
return provider.provideTestTemplateInvocationContexts(extensionContext);
Expand Down Expand Up @@ -144,15 +157,4 @@ private void execute(DynamicTestExecutor dynamicTestExecutor, TestDescriptor tes
testDescriptor.setParent(this);
dynamicTestExecutor.execute(testDescriptor);
}

private void validateWasAtLeastInvokedOnce(int invocationIndex,
List<TestTemplateInvocationContextProvider> providers) {

Preconditions.condition(invocationIndex > 0,
() -> "None of the supporting " + TestTemplateInvocationContextProvider.class.getSimpleName() + "s "
+ providers.stream().map(provider -> provider.getClass().getSimpleName()).collect(
joining(", ", "[", "]"))
+ " provided a non-empty stream");
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -44,19 +44,21 @@ public void setUp() {

@Benchmark
public void formatTestNames(Blackhole blackhole) throws Exception {
var method = TestCase.class.getDeclaredMethod("parameterizedTest", int.class);
var formatter = new ParameterizedTestNameFormatter(
ParameterizedTest.DISPLAY_NAME_PLACEHOLDER + " " + ParameterizedTest.DEFAULT_DISPLAY_NAME + " ({0})",
"displayName",
new ParameterizedTestMethodContext(TestCase.class.getDeclaredMethod("parameterizedTest", int.class), null),
"displayName", new ParameterizedTestMethodContext(method, method.getAnnotation(ParameterizedTest.class)),
512);
for (int i = 0; i < argumentsList.size(); i++) {
Arguments arguments = argumentsList.get(i);
blackhole.consume(formatter.format(i, arguments, arguments.get()));
}
}

@SuppressWarnings("JUnitMalformedDeclaration")
static class TestCase {
@SuppressWarnings("unused")
@ParameterizedTest
void parameterizedTest(int param) {
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,4 +291,18 @@
@API(status = STABLE, since = "5.10")
boolean autoCloseArguments() default true;

/**
* Configure whether at least one set of arguments is required for this
* parameterized test.
*
* <p>Set this attribute to {@code false} if the absence of arguments is
* expected in some cases and should not cause a test failure.
*
* <p>Defaults to {@code true}.
*
* @since 5.12
*/
@API(status = EXPERIMENTAL, since = "5.12")
boolean requireArguments() default true;

}
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@

import static org.junit.platform.commons.support.AnnotationSupport.findAnnotation;
import static org.junit.platform.commons.support.AnnotationSupport.findRepeatableAnnotations;
import static org.junit.platform.commons.support.AnnotationSupport.isAnnotated;

import java.lang.reflect.Method;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Stream;

Expand All @@ -34,7 +34,7 @@
*/
class ParameterizedTestExtension implements TestTemplateInvocationContextProvider {

private static final String METHOD_CONTEXT_KEY = "context";
static final String METHOD_CONTEXT_KEY = "context";
static final String ARGUMENT_MAX_LENGTH_KEY = "junit.jupiter.params.displayname.argument.maxlength";
static final String DEFAULT_DISPLAY_NAME = "{default_display_name}";
static final String DISPLAY_NAME_PATTERN_KEY = "junit.jupiter.params.displayname.default";
Expand All @@ -45,19 +45,21 @@ public boolean supportsTestTemplate(ExtensionContext context) {
return false;
}

Method testMethod = context.getTestMethod().get();
if (!isAnnotated(testMethod, ParameterizedTest.class)) {
Method templateMethod = context.getTestMethod().get();
Optional<ParameterizedTest> annotation = findAnnotation(templateMethod, ParameterizedTest.class);
if (!annotation.isPresent()) {
return false;
}

ParameterizedTestMethodContext methodContext = new ParameterizedTestMethodContext(testMethod, context);
ParameterizedTestMethodContext methodContext = new ParameterizedTestMethodContext(templateMethod,
annotation.get());

Preconditions.condition(methodContext.hasPotentiallyValidSignature(),
() -> String.format(
"@ParameterizedTest method [%s] declares formal parameters in an invalid order: "
+ "argument aggregators must be declared after any indexed arguments "
+ "and before any arguments resolved by another ParameterResolver.",
testMethod.toGenericString()));
templateMethod.toGenericString()));

getStore(context).put(METHOD_CONTEXT_KEY, methodContext);

Expand All @@ -68,33 +70,38 @@ public boolean supportsTestTemplate(ExtensionContext context) {
public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContexts(
ExtensionContext extensionContext) {

Method templateMethod = extensionContext.getRequiredTestMethod();
String displayName = extensionContext.getDisplayName();
ParameterizedTestMethodContext methodContext = getStore(extensionContext)//
.get(METHOD_CONTEXT_KEY, ParameterizedTestMethodContext.class);
int argumentMaxLength = extensionContext.getConfigurationParameter(ARGUMENT_MAX_LENGTH_KEY,
Integer::parseInt).orElse(512);
ParameterizedTestNameFormatter formatter = createNameFormatter(extensionContext, templateMethod, methodContext,
displayName, argumentMaxLength);
ParameterizedTestMethodContext methodContext = getMethodContext(extensionContext);
ParameterizedTestNameFormatter formatter = createNameFormatter(extensionContext, methodContext);
AtomicLong invocationCount = new AtomicLong(0);

// @formatter:off
return findRepeatableAnnotations(templateMethod, ArgumentsSource.class)
return findRepeatableAnnotations(methodContext.method, ArgumentsSource.class)
.stream()
.map(ArgumentsSource::value)
.map(clazz -> ParameterizedTestSpiInstantiator.instantiate(ArgumentsProvider.class, clazz, extensionContext))
.map(provider -> AnnotationConsumerInitializer.initialize(templateMethod, provider))
.map(provider -> AnnotationConsumerInitializer.initialize(methodContext.method, provider))
.flatMap(provider -> arguments(provider, extensionContext))
.map(arguments -> {
invocationCount.incrementAndGet();
return createInvocationContext(formatter, methodContext, arguments, invocationCount.intValue());
})
.onClose(() ->
Preconditions.condition(invocationCount.get() > 0,
Preconditions.condition(invocationCount.get() > 0 || !methodContext.annotation.requireArguments(),
"Configuration error: You must configure at least one set of arguments for this @ParameterizedTest"));
// @formatter:on
}

@Override
public boolean mayReturnZeroTestTemplateInvocationContexts(ExtensionContext extensionContext) {
ParameterizedTestMethodContext methodContext = getMethodContext(extensionContext);
return !methodContext.annotation.requireArguments();
}

private ParameterizedTestMethodContext getMethodContext(ExtensionContext extensionContext) {
return getStore(extensionContext)//
.get(METHOD_CONTEXT_KEY, ParameterizedTestMethodContext.class);
}

private ExtensionContext.Store getStore(ExtensionContext context) {
return context.getStore(Namespace.create(ParameterizedTestExtension.class, context.getRequiredTestMethod()));
}
Expand All @@ -105,19 +112,24 @@ private TestTemplateInvocationContext createInvocationContext(ParameterizedTestN
return new ParameterizedTestInvocationContext(formatter, methodContext, arguments, invocationIndex);
}

private ParameterizedTestNameFormatter createNameFormatter(ExtensionContext extensionContext, Method templateMethod,
ParameterizedTestMethodContext methodContext, String displayName, int argumentMaxLength) {
private ParameterizedTestNameFormatter createNameFormatter(ExtensionContext extensionContext,
ParameterizedTestMethodContext methodContext) {

ParameterizedTest parameterizedTest = findAnnotation(templateMethod, ParameterizedTest.class).get();
String pattern = parameterizedTest.name().equals(DEFAULT_DISPLAY_NAME)
? extensionContext.getConfigurationParameter(DISPLAY_NAME_PATTERN_KEY).orElse(
ParameterizedTest.DEFAULT_DISPLAY_NAME)
: parameterizedTest.name();
String name = methodContext.annotation.name();
String pattern = name.equals(DEFAULT_DISPLAY_NAME)
? extensionContext.getConfigurationParameter(DISPLAY_NAME_PATTERN_KEY) //
.orElse(ParameterizedTest.DEFAULT_DISPLAY_NAME)
: name;
pattern = Preconditions.notBlank(pattern.trim(),
() -> String.format(
"Configuration error: @ParameterizedTest on method [%s] must be declared with a non-empty name.",
templateMethod));
return new ParameterizedTestNameFormatter(pattern, displayName, methodContext, argumentMaxLength);
methodContext.method));

int argumentMaxLength = extensionContext.getConfigurationParameter(ARGUMENT_MAX_LENGTH_KEY, Integer::parseInt) //
.orElse(512);

return new ParameterizedTestNameFormatter(pattern, extensionContext.getDisplayName(), methodContext,
argumentMaxLength);
}

protected static Stream<? extends Arguments> arguments(ArgumentsProvider provider, ExtensionContext context) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
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.util.Preconditions;
import org.junit.platform.commons.util.StringUtils;

/**
Expand All @@ -42,19 +43,22 @@
*/
class ParameterizedTestMethodContext {

final Method method;
final ParameterizedTest annotation;

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

ParameterizedTestMethodContext(Method testMethod, ExtensionContext extensionContext) {
this.parameters = testMethod.getParameters();
ParameterizedTestMethodContext(Method method, ParameterizedTest annotation) {
this.method = Preconditions.notNull(method, "method must not be null");
this.annotation = Preconditions.notNull(annotation, "annotation must not be null");
this.parameters = method.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 @@ -162,11 +166,12 @@ int indexOfFirstAggregator() {
* Resolve the parameter for the supplied context using the supplied
* arguments.
*/
Object resolve(ParameterContext parameterContext, Object[] arguments, int invocationIndex) {
return getResolver(parameterContext).resolve(parameterContext, arguments, invocationIndex);
Object resolve(ParameterContext parameterContext, ExtensionContext extensionContext, Object[] arguments,
int invocationIndex) {
return getResolver(parameterContext, extensionContext).resolve(parameterContext, arguments, invocationIndex);
}

private Resolver getResolver(ParameterContext parameterContext) {
private Resolver getResolver(ParameterContext parameterContext, ExtensionContext extensionContext) {
int index = parameterContext.getIndex();
if (resolvers[index] == null) {
resolvers[index] = resolverTypes.get(index).createResolver(parameterContext, extensionContext);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ public boolean supportsParameter(ParameterContext parameterContext, ExtensionCon
@Override
public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
throws ParameterResolutionException {
return this.methodContext.resolve(parameterContext, extractPayloads(this.arguments), this.invocationIndex);
return this.methodContext.resolve(parameterContext, extensionContext, extractPayloads(this.arguments),
this.invocationIndex);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,11 @@ public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContex
return null;
}

@Override
public boolean mayReturnZeroTestTemplateInvocationContexts(ExtensionContext context) {
return false;
}

// --- TestWatcher ---------------------------------------------------------

@Override
Expand Down
Loading