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 committed Jul 17, 2024
1 parent 8e8268c commit 17bb46b
Show file tree
Hide file tree
Showing 7 changed files with 173 additions and 6 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 given {@linkplain ExtensionContext extension 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 extensionContext 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.11
*/
@API(status = EXPERIMENTAL, since = "5.11")
default boolean mayReturnEmptyInvocationContext(ExtensionContext extensionContext) {
return false;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,11 @@ public JupiterEngineExecutionContext execute(JupiterEngineExecutionContext conte
.map(Optional::get)
.forEach(invocationTestDescriptor -> execute(dynamicTestExecutor, invocationTestDescriptor));
// @formatter:on
validateWasAtLeastInvokedOnce(invocationIndex.get(), providers);
boolean allProvidersMayBeEmpty = providers.stream().allMatch(
p -> p.mayReturnEmptyInvocationContext(extensionContext));
if (!allProvidersMayBeEmpty) {
validateWasAtLeastInvokedOnce(invocationIndex.get(), extensionContext, providers);
}
return context;
}

Expand Down Expand Up @@ -142,13 +146,16 @@ private void execute(DynamicTestExecutor dynamicTestExecutor, TestDescriptor tes
dynamicTestExecutor.execute(testDescriptor);
}

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

Preconditions.condition(invocationIndex > 0,
boolean allMayReturnEmptyContext = providers.stream().allMatch(
p -> p.mayReturnEmptyInvocationContext(extensionContext));

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,35 @@ void templateWithSupportingProviderButNoInvocationsReportsFailure() {
+ "] provided a non-empty stream")))));
}

@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("None of the supporting TestTemplateInvocationContextProviders ["
+ InvocationContextProviderThatSupportsEverythingButProvidesNothing.class.getSimpleName()
+ "] provided a non-empty stream")))));
}

@Test
void templateWithCloseableStream() {
LauncherDiscoveryRequest request = request().selectors(
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 mayReturnEmptyInvocationContext(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 @@ -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.11
*/
@API(status = EXPERIMENTAL, since = "5.11")
boolean requireArguments() default true;

}
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,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 @@ -92,11 +94,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 mayReturnEmptyInvocationContext(ExtensionContext extensionContext) {
Method templateMethod = extensionContext.getRequiredTestMethod();
return findAnnotation(templateMethod, ParameterizedTest.class).map(
parameterizedTest -> !parameterizedTest.requireArguments()).orElse(
TestTemplateInvocationContextProvider.super.mayReturnEmptyInvocationContext(extensionContext));
}

@SuppressWarnings("ConstantConditions")
private ArgumentsProvider instantiateArgumentsProvider(Class<? extends ArgumentsProvider> clazz) {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,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 @@ -298,6 +310,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 @@ -30,6 +30,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 @@ -444,6 +445,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 @@ -2119,6 +2138,25 @@ void testWithRepeatableArgumentsSource(String argument) {
}
}

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 17bb46b

Please sign in to comment.