diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc index b5aa3793f508..92c97ce8dab6 100644 --- a/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc +++ b/documentation/src/docs/asciidoc/release-notes/release-notes-5.12.0-M1.adoc @@ -95,6 +95,12 @@ JUnit repository on GitHub. a test-scoped `ExtensionContext` in `Extension` methods called during test class instantiation. This behavior will become the default in future versions of JUnit. * `@TempDir` is now supported on test class constructors. +* Parameterized tests now support argument count validation. + If the `junit.jupiter.params.argumentCountValidation=strict` configuration parameter + or the `@ParameterizedTest(argumentCountValidation = STRICT)` attribute is set, any + mismatch between the declared number of arguments and the number of arguments provided + by the arguments source will result in an error. By default, it's still only an error if + there are fewer arguments provided than declared. * The new `PreInterruptCallback` extension point defines the API for `Extensions` that wish to be called prior to invocations of `Thread#interrupt()` by the `@Timeout` extension. diff --git a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc index 0e597ae4ab0a..7b2914a95216 100644 --- a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc +++ b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc @@ -2020,6 +2020,29 @@ The following annotations are repeatable: * `@CsvFileSource` * `@ArgumentsSource` +[[writing-tests-parameterized-tests-argument-count-validation]] +==== Argument Count Validation + +WARNING: Argument count validation is currently an _experimental_ feature. You're invited to +give it a try and provide feedback to the JUnit team so they can improve and eventually +<> this feature. + +By default, when an arguments source provides more arguments than the test method needs, +those additional arguments are ignored and the test executes as usual. +This can lead to bugs where arguments are never passed to the parameterized test method. + +To prevent this, you can set argument count validation to 'strict'. +Then, any additional arguments will cause an error instead. + +To change this behavior for all tests, set the `junit.jupiter.params.argumentCountValidation` +<> to `strict`. +To change this behavior for a single test, +use the `argumentCountValidation` attribute of the `@ParameterizedTest` annotation: + +[source,java,indent=0] +---- +include::{testDir}/example/ParameterizedTestDemo.java[tags=argument_count_validation] +---- [[writing-tests-parameterized-tests-argument-conversion]] ==== Argument Conversion diff --git a/documentation/src/test/java/example/ParameterizedTestDemo.java b/documentation/src/test/java/example/ParameterizedTestDemo.java index 9027b86d67e4..894b7617761d 100644 --- a/documentation/src/test/java/example/ParameterizedTestDemo.java +++ b/documentation/src/test/java/example/ParameterizedTestDemo.java @@ -51,6 +51,7 @@ import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.ParameterContext; import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.params.ArgumentCountValidationMode; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.aggregator.AggregateWith; import org.junit.jupiter.params.aggregator.ArgumentsAccessor; @@ -607,4 +608,13 @@ static Stream otherProvider() { return Stream.of("bar"); } // end::repeatable_annotations[] + + @extensions.ExpectToFail + // tag::argument_count_validation[] + @ParameterizedTest(argumentCountValidation = ArgumentCountValidationMode.STRICT) + @CsvSource({ "42, -666" }) + void testWithArgumentCountValidation(int number) { + assertTrue(number > 0); + } + // end::argument_count_validation[] } diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ArgumentCountValidationMode.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ArgumentCountValidationMode.java new file mode 100644 index 000000000000..38c9beb8982a --- /dev/null +++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ArgumentCountValidationMode.java @@ -0,0 +1,52 @@ +/* + * 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 org.apiguardian.api.API; +import org.junit.jupiter.params.provider.ArgumentsSource; + +/** + * Enumeration of argument count validation modes for {@link ParameterizedTest @ParameterizedTest}. + * + *

When an {@link ArgumentsSource} provides more arguments than declared by the test method, + * there might be a bug in the test method or the {@link ArgumentsSource}. + * By default, the additional arguments are ignored. + * {@link ArgumentCountValidationMode} allows you to control how additional arguments are handled. + * + * @since 5.12 + * @see ParameterizedTest + */ +@API(status = API.Status.EXPERIMENTAL, since = "5.12") +public enum ArgumentCountValidationMode { + /** + * Use the default validation mode. + * + *

The default validation mode may be changed via the + * {@value ArgumentCountValidator#ARGUMENT_COUNT_VALIDATION_KEY} configuration parameter + * (see the User Guide for details on configuration parameters). + */ + DEFAULT, + + /** + * Use the "none" argument count validation mode. + * + *

When there are more arguments provided than declared by the test method, + * these additional arguments are ignored. + */ + NONE, + + /** + * Use the strict argument count validation mode. + * + *

When there are more arguments provided than declared by the test method, this raises an error. + */ + STRICT, +} diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ArgumentCountValidator.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ArgumentCountValidator.java new file mode 100644 index 000000000000..220825d9817a --- /dev/null +++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ArgumentCountValidator.java @@ -0,0 +1,111 @@ +/* + * 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 java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Optional; + +import org.junit.jupiter.api.extension.ExtensionConfigurationException; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.InvocationInterceptor; +import org.junit.jupiter.api.extension.ReflectiveInvocationContext; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.platform.commons.logging.Logger; +import org.junit.platform.commons.logging.LoggerFactory; +import org.junit.platform.commons.util.Preconditions; + +class ArgumentCountValidator implements InvocationInterceptor { + private static final Logger logger = LoggerFactory.getLogger(ArgumentCountValidator.class); + + static final String ARGUMENT_COUNT_VALIDATION_KEY = "junit.jupiter.params.argumentCountValidation"; + private static final ExtensionContext.Namespace NAMESPACE = ExtensionContext.Namespace.create( + ArgumentCountValidator.class); + + private final ParameterizedTestMethodContext methodContext; + private final Arguments arguments; + + ArgumentCountValidator(ParameterizedTestMethodContext methodContext, Arguments arguments) { + this.methodContext = methodContext; + this.arguments = arguments; + } + + @Override + public void interceptTestTemplateMethod(InvocationInterceptor.Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + validateArgumentCount(extensionContext, arguments); + invocation.proceed(); + } + + private ExtensionContext.Store getStore(ExtensionContext context) { + return context.getRoot().getStore(NAMESPACE); + } + + private void validateArgumentCount(ExtensionContext extensionContext, Arguments arguments) { + ArgumentCountValidationMode argumentCountValidationMode = getArgumentCountValidationMode(extensionContext); + switch (argumentCountValidationMode) { + case DEFAULT: + case NONE: + return; + case STRICT: + int testParamCount = extensionContext.getRequiredTestMethod().getParameterCount(); + int argumentsCount = arguments.get().length; + Preconditions.condition(testParamCount == argumentsCount, () -> String.format( + "Configuration error: the @ParameterizedTest has %s argument(s) but there were %s argument(s) provided.%nNote: the provided arguments are %s", + testParamCount, argumentsCount, Arrays.toString(arguments.get()))); + break; + default: + throw new ExtensionConfigurationException( + "Unsupported argument count validation mode: " + argumentCountValidationMode); + } + } + + private ArgumentCountValidationMode getArgumentCountValidationMode(ExtensionContext extensionContext) { + ParameterizedTest parameterizedTest = methodContext.annotation; + if (parameterizedTest.argumentCountValidation() != ArgumentCountValidationMode.DEFAULT) { + return parameterizedTest.argumentCountValidation(); + } + else { + return getArgumentCountValidationModeConfiguration(extensionContext); + } + } + + private ArgumentCountValidationMode getArgumentCountValidationModeConfiguration(ExtensionContext extensionContext) { + String key = ARGUMENT_COUNT_VALIDATION_KEY; + ArgumentCountValidationMode fallback = ArgumentCountValidationMode.NONE; + ExtensionContext.Store store = getStore(extensionContext); + return store.getOrComputeIfAbsent(key, __ -> { + Optional optionalConfigValue = extensionContext.getConfigurationParameter(key); + if (optionalConfigValue.isPresent()) { + String configValue = optionalConfigValue.get(); + Optional enumValue = Arrays.stream( + ArgumentCountValidationMode.values()).filter( + mode -> mode.name().equalsIgnoreCase(configValue)).findFirst(); + if (enumValue.isPresent()) { + logger.config(() -> String.format( + "Using ArgumentCountValidationMode '%s' set via the '%s' configuration parameter.", + enumValue.get().name(), key)); + return enumValue.get(); + } + else { + logger.warn(() -> String.format( + "Invalid ArgumentCountValidationMode '%s' set via the '%s' configuration parameter. " + + "Falling back to the %s default value.", + configValue, key, fallback.name())); + return fallback; + } + } + else { + return fallback; + } + }, ArgumentCountValidationMode.class); + } +} diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ParameterizedTest.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ParameterizedTest.java index 67296b5a4158..03741d19f1fd 100644 --- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ParameterizedTest.java +++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ParameterizedTest.java @@ -22,6 +22,7 @@ import org.apiguardian.api.API; import org.junit.jupiter.api.TestTemplate; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.provider.ArgumentsSource; /** * {@code @ParameterizedTest} is used to signal that the annotated method is a @@ -305,4 +306,21 @@ @API(status = EXPERIMENTAL, since = "5.12") boolean requireArguments() default true; + /** + * Configure how the number of arguments provided by an {@link ArgumentsSource} are validated. + * + *

Defaults to {@link ArgumentCountValidationMode#DEFAULT}. + * + *

When an {@link ArgumentsSource} provides more arguments than declared by the test method, + * there might be a bug in the test method or the {@link ArgumentsSource}. + * By default, the additional arguments are ignored. + * {@code argumentCountValidation} allows you to control how additional arguments are handled. + * The default can be configured via the {@value ArgumentCountValidator#ARGUMENT_COUNT_VALIDATION_KEY} + * configuration parameter (see the User Guide for details on configuration parameters). + * + * @since 5.12 + * @see ArgumentCountValidationMode + */ + @API(status = EXPERIMENTAL, since = "5.12") + ArgumentCountValidationMode argumentCountValidation() default ArgumentCountValidationMode.DEFAULT; } diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ParameterizedTestInvocationContext.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ParameterizedTestInvocationContext.java index c0ac83e78717..ab26b362c362 100644 --- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ParameterizedTestInvocationContext.java +++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ParameterizedTestInvocationContext.java @@ -10,8 +10,6 @@ package org.junit.jupiter.params; -import static java.util.Collections.singletonList; - import java.util.Arrays; import java.util.List; @@ -47,8 +45,9 @@ public String getDisplayName(int invocationIndex) { @Override public List getAdditionalExtensions() { - return singletonList( - new ParameterizedTestParameterResolver(this.methodContext, this.consumedArguments, this.invocationIndex)); + return Arrays.asList( + new ParameterizedTestParameterResolver(this.methodContext, this.consumedArguments, this.invocationIndex), + new ArgumentCountValidator(this.methodContext, this.arguments)); } private static Object[] consumedArguments(ParameterizedTestMethodContext methodContext, Object[] arguments) { diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedTestIntegrationTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedTestIntegrationTests.java index c80708edfae5..50c131c449b3 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedTestIntegrationTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/params/ParameterizedTestIntegrationTests.java @@ -116,6 +116,7 @@ import org.junit.platform.testkit.engine.EngineExecutionResults; import org.junit.platform.testkit.engine.EngineTestKit; import org.junit.platform.testkit.engine.Event; +import org.junit.platform.testkit.engine.EventConditions; import org.opentest4j.TestAbortedException; /** @@ -1112,6 +1113,74 @@ private EngineExecutionResults execute(String methodName, Class... methodPara } + @Nested + class UnusedArgumentsWithStrictArgumentsCountIntegrationTests { + @Test + void failsWithArgumentsSourceProvidingUnusedArguments() { + var results = execute(ArgumentCountValidationMode.STRICT, UnusedArgumentsTestCase.class, + "testWithTwoUnusedStringArgumentsProvider", String.class); + results.allEvents().assertThatEvents() // + .haveExactly(1, event(EventConditions.finishedWithFailure(message(String.format( + "Configuration error: the @ParameterizedTest has 1 argument(s) but there were 2 argument(s) provided.%nNote: the provided arguments are [foo, unused1]"))))); + } + + @Test + void failsWithMethodSourceProvidingUnusedArguments() { + var results = execute(ArgumentCountValidationMode.STRICT, UnusedArgumentsTestCase.class, + "testWithMethodSourceProvidingUnusedArguments", String.class); + results.allEvents().assertThatEvents() // + .haveExactly(1, event(EventConditions.finishedWithFailure(message(String.format( + "Configuration error: the @ParameterizedTest has 1 argument(s) but there were 2 argument(s) provided.%nNote: the provided arguments are [foo, unused1]"))))); + } + + @Test + void failsWithCsvSourceUnusedArgumentsAndStrictArgumentCountValidationAnnotationAttribute() { + var results = execute(ArgumentCountValidationMode.NONE, UnusedArgumentsTestCase.class, + "testWithStrictArgumentCountValidation", String.class); + results.allEvents().assertThatEvents() // + .haveExactly(1, event(EventConditions.finishedWithFailure(message(String.format( + "Configuration error: the @ParameterizedTest has 1 argument(s) but there were 2 argument(s) provided.%nNote: the provided arguments are [foo, unused1]"))))); + } + + @Test + void failsWithCsvSourceUnusedArgumentsButExecutesRemainingArgumentsWhereThereIsNoUnusedArgument() { + var results = execute(ArgumentCountValidationMode.STRICT, UnusedArgumentsTestCase.class, + "testWithCsvSourceContainingDifferentNumbersOfArguments", String.class); + results.allEvents().assertThatEvents() // + .haveExactly(1, event(EventConditions.finishedWithFailure(message(String.format( + "Configuration error: the @ParameterizedTest has 1 argument(s) but there were 2 argument(s) provided.%nNote: the provided arguments are [foo, unused1]"))))) // + .haveExactly(1, + event(test(), displayName("[2] argument=bar"), finishedWithFailure(message("bar")))); + } + + @Test + void executesWithCsvSourceUnusedArgumentsAndArgumentCountValidationAnnotationAttribute() { + var results = execute(ArgumentCountValidationMode.NONE, UnusedArgumentsTestCase.class, + "testWithNoneArgumentCountValidation", String.class); + results.allEvents().assertThatEvents() // + .haveExactly(1, + event(test(), displayName("[1] argument=foo"), finishedWithFailure(message("foo")))); + } + + @Test + void executesWithMethodSourceProvidingUnusedArguments() { + var results = execute(ArgumentCountValidationMode.STRICT, RepeatableSourcesTestCase.class, + "testWithRepeatableCsvSource", String.class); + results.allEvents().assertThatEvents() // + .haveExactly(1, event(test(), displayName("[1] argument=a"), finishedWithFailure(message("a")))) // + .haveExactly(1, event(test(), displayName("[2] argument=b"), finishedWithFailure(message("b")))); + } + + private EngineExecutionResults execute(ArgumentCountValidationMode configurationValue, Class javaClass, + String methodName, Class... methodParameterTypes) { + return EngineTestKit.engine(new JupiterTestEngine()) // + .selectors(selectMethod(javaClass, methodName, methodParameterTypes)) // + .configurationParameter(ArgumentCountValidator.ARGUMENT_COUNT_VALIDATION_KEY, + configurationValue.name().toLowerCase()) // + .execute(); + } + } + @Nested class RepeatableSourcesIntegrationTests { @@ -2028,6 +2097,23 @@ void testWithFieldSourceProvidingUnusedArguments(String argument) { static Supplier> unusedArgumentsProviderField = // () -> Stream.of(arguments("foo", "unused1"), arguments("bar", "unused2")); + @ParameterizedTest(argumentCountValidation = ArgumentCountValidationMode.STRICT) + @CsvSource({ "foo, unused1" }) + void testWithStrictArgumentCountValidation(String argument) { + fail(argument); + } + + @ParameterizedTest(argumentCountValidation = ArgumentCountValidationMode.NONE) + @CsvSource({ "foo, unused1" }) + void testWithNoneArgumentCountValidation(String argument) { + fail(argument); + } + + @ParameterizedTest + @CsvSource({ "foo, unused1", "bar" }) + void testWithCsvSourceContainingDifferentNumbersOfArguments(String argument) { + fail(argument); + } } static class LifecycleTestCase {