Skip to content

Commit

Permalink
Introduce zero invocations in test templates and parameterized tests
Browse files Browse the repository at this point in the history
Add a flag to ParameterizedTest to control arguments requirement. This
allows users to explicitly opt out from validation of arguments set
count and silently skip a test if no arguments are provided

In general, support TestTemplateInvocationContextProvider returning zero
invocation contexts. Such providers must override new interface method
to indicate that the framework should expect "no context returned"

Resolves junit-team#1477
  • Loading branch information
nskvortsov authored and marcphilipp committed Oct 15, 2024
1 parent a6230df commit 4f9c87d
Show file tree
Hide file tree
Showing 7 changed files with 168 additions and 16 deletions.
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,22 @@ public interface TestTemplateInvocationContextProvider extends Extension {
*/
Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContexts(ExtensionContext context);

/**
* Signals that in the supplied {@linkplain ExtensionContext context} the provider may return zero
* {@linkplain TestTemplateInvocationContext invocation contexts}.
*
* <p>If provider returns empty stream from {@link #provideTestTemplateInvocationContexts(ExtensionContext)}
* this will be considered an execution error. Override this method to ignore the absence of invocation contexts.
*
* @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 mayReturnZeroInvocationContexts(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,13 +101,17 @@ public JupiterEngineExecutionContext execute(JupiterEngineExecutionContext conte
context.getExtensionRegistry());
AtomicInteger invocationIndex = new AtomicInteger();
for (TestTemplateInvocationContextProvider provider : providers) {
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.mayReturnZeroInvocationContexts(extensionContext),
"Provider [" + provider.getClass().getSimpleName() //
+ "] did not provide invocation contexts, but is expected to do so");
}
validateWasAtLeastInvokedOnce(invocationIndex.get(), providers);
return context;
}

Expand Down Expand Up @@ -144,15 +147,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 @@ -291,4 +291,17 @@
@API(status = STABLE, since = "5.10")
boolean autoCloseArguments() default true;

/**
* Configure whether a test requires at least one set of arguments.
* Override this value if the absence of arguments is expected in some cases and
* should not cause the test framework errors.
*
* <p>Defaults to {@code true}.
*
* <p></p>
* @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 @@ -77,6 +77,8 @@ public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContex
ParameterizedTestNameFormatter formatter = createNameFormatter(extensionContext, templateMethod, methodContext,
displayName, argumentMaxLength);
AtomicLong invocationCount = new AtomicLong(0);
ParameterizedTest parameterizedTest = findAnnotation(templateMethod, ParameterizedTest.class).get();
boolean ignoreZeroInvocations = !parameterizedTest.requireArguments();

// @formatter:off
return findRepeatableAnnotations(templateMethod, ArgumentsSource.class)
Expand All @@ -90,11 +92,19 @@ public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContex
return createInvocationContext(formatter, methodContext, arguments, invocationCount.intValue());
})
.onClose(() ->
Preconditions.condition(invocationCount.get() > 0,
Preconditions.condition(invocationCount.get() > 0 || ignoreZeroInvocations,
"Configuration error: You must configure at least one set of arguments for this @ParameterizedTest"));
// @formatter:on
}

@Override
public boolean mayReturnZeroInvocationContexts(ExtensionContext extensionContext) {
Method templateMethod = extensionContext.getRequiredTestMethod();
return findAnnotation(templateMethod, ParameterizedTest.class) //
.map(parameterizedTest -> !parameterizedTest.requireArguments()) //
.orElse(false);
}

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 @@ -353,9 +353,38 @@ void templateWithSupportingProviderButNoInvocationsReportsFailure() {
wrappedInContainerEvents(MyTestTemplateTestCase.class, //
event(container("templateWithSupportingProviderButNoInvocations"), started()), //
event(container("templateWithSupportingProviderButNoInvocations"),
finishedWithFailure(message("None of the supporting TestTemplateInvocationContextProviders ["
finishedWithFailure(message("Provider ["
+ InvocationContextProviderThatSupportsEverythingButProvidesNothing.class.getSimpleName()
+ "] provided a non-empty stream")))));
+ "] did not provide invocation contexts, but is expected to do so")))));
}

@Test
void templateWithSupportingProviderAllowingNoInvocationsDoesNotFail() {
LauncherDiscoveryRequest request = request().selectors(
selectMethod(MyTestTemplateTestCase.class, "templateWithSupportingProviderAllowingNoInvocations")).build();

EngineExecutionResults executionResults = executeTests(request);

executionResults.allEvents().assertEventsMatchExactly( //
wrappedInContainerEvents(MyTestTemplateTestCase.class,
event(container("templateWithSupportingProviderAllowingNoInvocations"), started()),
event(container("templateWithSupportingProviderAllowingNoInvocations"), finishedSuccessfully())));
}

@Test
void templateWithMixedProvidersNoInvocationReportsFailure() {
LauncherDiscoveryRequest request = request().selectors(selectMethod(MyTestTemplateTestCase.class,
"templateWithMultipleProvidersAllowingAndRestrictingToProvideNothing")).build();

EngineExecutionResults executionResults = executeTests(request);

executionResults.allEvents().assertEventsMatchExactly( //
wrappedInContainerEvents(MyTestTemplateTestCase.class, //
event(container("templateWithMultipleProvidersAllowingAndRestrictingToProvideNothing"), started()), //
event(container("templateWithMultipleProvidersAllowingAndRestrictingToProvideNothing"),
finishedWithFailure(message("Provider ["
+ InvocationContextProviderThatSupportsEverythingButProvidesNothing.class.getSimpleName()
+ "] did not provide invocation contexts, but is expected to do so")))));
}

@Test
Expand Down Expand Up @@ -470,6 +499,19 @@ void templateWithSupportingProviderButNoInvocations() {
fail("never called");
}

@ExtendWith(InvocationContextProviderThatSupportsEverythingAllowsProvideNothing.class)
@TestTemplate
void templateWithSupportingProviderAllowingNoInvocations() {
fail("never called");
}

@ExtendWith(InvocationContextProviderThatSupportsEverythingButProvidesNothing.class)
@ExtendWith(InvocationContextProviderThatSupportsEverythingAllowsProvideNothing.class)
@TestTemplate
void templateWithMultipleProvidersAllowingAndRestrictingToProvideNothing() {
fail("never called");
}

@ExtendWith(InvocationContextProviderWithCloseableStream.class)
@TestTemplate
void templateWithCloseableStream() {
Expand Down Expand Up @@ -769,6 +811,25 @@ public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContex
}
}

private static class InvocationContextProviderThatSupportsEverythingAllowsProvideNothing
implements TestTemplateInvocationContextProvider {

@Override
public boolean supportsTestTemplate(ExtensionContext context) {
return true;
}

@Override
public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContexts(ExtensionContext context) {
return Stream.empty();
}

@Override
public boolean mayReturnZeroInvocationContexts(ExtensionContext extensionContext) {
return true;
}
}

private static class InvocationContextProviderWithCloseableStream implements TestTemplateInvocationContextProvider {

private static AtomicBoolean streamClosed = new AtomicBoolean(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,18 @@ void throwsExceptionWhenParameterizedTestIsNotInvokedAtLeastOnce() {
"Configuration error: You must configure at least one set of arguments for this @ParameterizedTest");
}

@Test
void doesNotThrowExceptionWhenParametrizedTestDoesNotRequireArguments() {
var extensionContextWithAnnotatedTestMethod = getExtensionContextReturningSingleMethod(
new TestCaseAllowNoArgumentsMethod());

var stream = this.parameterizedTestExtension.provideTestTemplateInvocationContexts(
extensionContextWithAnnotatedTestMethod);
// cause the stream to be evaluated
stream.toArray();
stream.close();
}

@Test
void throwsExceptionWhenArgumentsProviderIsNotStatic() {
var extensionContextWithAnnotatedTestMethod = getExtensionContextReturningSingleMethod(
Expand Down Expand Up @@ -310,6 +322,13 @@ void method() {
}
}

static class TestCaseAllowNoArgumentsMethod {

@ParameterizedTest(requireArguments = false)
void method() {
}
}

static class ArgumentsProviderWithCloseHandlerTestCase {

@ParameterizedTest
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import static org.junit.platform.testkit.engine.EventConditions.abortedWithReason;
import static org.junit.platform.testkit.engine.EventConditions.container;
import static org.junit.platform.testkit.engine.EventConditions.displayName;
import static org.junit.platform.testkit.engine.EventConditions.engine;
import static org.junit.platform.testkit.engine.EventConditions.event;
import static org.junit.platform.testkit.engine.EventConditions.finishedSuccessfully;
import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure;
Expand Down Expand Up @@ -447,6 +448,24 @@ void displayNamePatternFromConfiguration() {
.haveExactly(1, event(displayName("2"), started()));
}

@Test
void failsWhenArgumentsRequiredButNoneProvided() {
var result = execute(ZeroArgumentsTestCase.class, "testThatRequiresArguments", String.class);
result.containerEvents().assertThatEvents().haveExactly(1, event(finishedWithFailure(message(
"Configuration error: You must configure at least one set of arguments for this @ParameterizedTest"))));
}

@Test
void failsWhenArgumentsAreNotRequiredAndNoneProvided() {
var result = execute(ZeroArgumentsTestCase.class, "testThatDoesNotRequireArguments", String.class);
result.allEvents().assertEventsMatchExactly( //
event(engine(), started()), event(container(ZeroArgumentsTestCase.class), started()),
event(container("testThatDoesNotRequireArguments"), started()),
event(container("testThatDoesNotRequireArguments"), finishedSuccessfully()),
event(container(ZeroArgumentsTestCase.class), finishedSuccessfully()),
event(engine(), finishedSuccessfully()));
}

private EngineExecutionResults execute(DiscoverySelector... selectors) {
return EngineTestKit.engine(new JupiterTestEngine()).selectors(selectors).execute();
}
Expand Down Expand Up @@ -2309,6 +2328,25 @@ public Object aggregateArguments(ArgumentsAccessor accessor, ParameterContext co
}
}

static class ZeroArgumentsTestCase {

@ParameterizedTest
@MethodSource("zeroArgumentsProvider")
void testThatRequiresArguments(String argument) {
fail("This test should not be executed, because no arguments are provided.");
}

@ParameterizedTest(requireArguments = false)
@MethodSource("zeroArgumentsProvider")
void testThatDoesNotRequireArguments(String argument) {
fail("This test should not be executed, because no arguments are provided.");
}

public static Stream<Arguments> zeroArgumentsProvider() {
return Stream.empty();
}
}

private static class TwoSingleStringArgumentsProvider implements ArgumentsProvider {

@Override
Expand Down

0 comments on commit 4f9c87d

Please sign in to comment.