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

Issue: #3708 add ParameterizedTest#argumentCountValidation #4045

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2e56d56
Issue: #3708 add ParameterizedTest#argumentCountValidation
jonas-jebing-at-ebay Oct 4, 2024
3d40ebe
Merge branch 'main' into argument-count-validation-mode
JonasJebing Oct 23, 2024
98cdf69
address PR comments
JonasJebing Oct 23, 2024
89a7443
gradle spotlessApply
JonasJebing Oct 23, 2024
5c71632
move example to .adoc because it's a test that should error
JonasJebing Oct 23, 2024
edd9f46
fix newline char assertion for Windows
JonasJebing Oct 23, 2024
beb53ab
gradle spotlessApply
JonasJebing Oct 23, 2024
b117b69
Merge branch 'main' into argument-count-validation-mode
JonasJebing Oct 23, 2024
37216f8
Merge branch 'main' into argument-count-validation-mode
JonasJebing Oct 24, 2024
f87404f
add change to release-notes 5.12.0-M1
JonasJebing Oct 24, 2024
14a071b
fix ArgumentCountValidationMode javadoc typo
JonasJebing Oct 24, 2024
762c39a
improve ParameterizedTest javadoc
JonasJebing Oct 24, 2024
8a84dc2
use underscores for unused lambda parameter
JonasJebing Oct 24, 2024
09ced82
use root context store for caching config value
JonasJebing Oct 24, 2024
0bf0b05
remove mention of experimental status from release note
JonasJebing Oct 24, 2024
f189f07
Merge branch 'main' into argument-count-validation-mode
JonasJebing Oct 25, 2024
36e7729
`@ParameterizedTest`s to Parameterized tests
JonasJebing Oct 25, 2024
851a4db
improve release note
JonasJebing Oct 25, 2024
a986bba
move user guide example to ParameterizedTestDemo
JonasJebing Oct 25, 2024
2dafffb
Merge branch 'main' into argument-count-validation-mode
JonasJebing Nov 12, 2024
e70240c
move argument count validation to happen later
JonasJebing Nov 12, 2024
3d84b9f
update javadoc to use new ArgumentCountValidator
JonasJebing Nov 12, 2024
dd6b55a
retrigger checks
JonasJebing Nov 13, 2024
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 @@ -87,6 +87,11 @@ 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.
* `@ParameterizedTest`s now support argument count validation.
JonasJebing marked this conversation as resolved.
Show resolved Hide resolved
With the `junit.jupiter.params.argumentCountValidation=strict` configuration
or the `@ParameterizedTest(argumentCountValidation = ArgumentCountValidationMode.STRICT)` attribute,
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 there is only an error if there are less arguments provided than declared.
JonasJebing marked this conversation as resolved.
Show resolved Hide resolved


[[release-notes-5.12.0-M1-junit-vintage]]
Expand Down
27 changes: 27 additions & 0 deletions documentation/src/docs/asciidoc/user-guide/writing-tests.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -2020,6 +2020,33 @@ 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
<<api-evolution, promote>> 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`
<<running-tests-config-params, configuration parameter>> to `strict`.
To change this behavior for a single test,
use the `argumentCountValidation` attribute of the `@ParameterizedTest` annotation:

[source,java,indent=0]
----
@ParameterizedTest(argumentCountValidation = ArgumentCountValidationMode.STRICT)
@CsvSource({ "42, -666" })
void testWithArgumentCountValidation(int number) {
assertTrue(number > 0);
}
JonasJebing marked this conversation as resolved.
Show resolved Hide resolved
----

[[writing-tests-parameterized-tests-argument-conversion]]
==== Argument Conversion
Expand Down
Original file line number Diff line number Diff line change
@@ -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}.
*
* <p>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.
*
* <p>The default validation mode may be changed via the
* {@value ParameterizedTestExtension#ARGUMENT_COUNT_VALIDATION_KEY} configuration parameter
* (see the User Guide for details on configuration parameters).
*/
DEFAULT,

/**
* Use the "none" argument count validation mode.
*
* <p>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.
*
* <p>When there are more arguments provided than declared by the test method, this raises an error.
*/
STRICT,
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
*
* <p>Defaults to {@link ArgumentCountValidationMode#DEFAULT}.
*
* <p>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 ParameterizedTestExtension#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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@
import static org.junit.platform.commons.support.AnnotationSupport.findRepeatableAnnotations;

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

import org.junit.jupiter.api.extension.ExtensionConfigurationException;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
import org.junit.jupiter.api.extension.TestTemplateInvocationContext;
Expand All @@ -26,6 +29,8 @@
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.logging.Logger;
import org.junit.platform.commons.logging.LoggerFactory;
import org.junit.platform.commons.util.ExceptionUtils;
import org.junit.platform.commons.util.Preconditions;

Expand All @@ -34,10 +39,13 @@
*/
class ParameterizedTestExtension implements TestTemplateInvocationContextProvider {

private static final Logger logger = LoggerFactory.getLogger(ParameterizedTestExtension.class);

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";
static final String ARGUMENT_COUNT_VALIDATION_KEY = "junit.jupiter.params.argumentCountValidation";

@Override
public boolean supportsTestTemplate(ExtensionContext context) {
Expand All @@ -61,7 +69,7 @@ public boolean supportsTestTemplate(ExtensionContext context) {
+ "and before any arguments resolved by another ParameterResolver.",
templateMethod.toGenericString()));

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

return true;
}
Expand All @@ -82,6 +90,7 @@ public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContex
.map(provider -> AnnotationConsumerInitializer.initialize(methodContext.method, provider))
.flatMap(provider -> arguments(provider, extensionContext))
.map(arguments -> {
validateArgumentCount(extensionContext, arguments);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Playing around with this made me realize that we need to defer this validation until the invocation is being executed. Otherwise, all invocations will fail rather than just the offending ones. For example, annotating the added test in ParameterizedTestDemo with @CsvSource({ "42, -666", "1", "2", "3" }) should result in the first invocation being reported as failed due to the new validation but all others should pass.

I think the cleanest way to do this is to return another extension from ParameterizedTestInvocationContext#getAdditionalExtensions, say ArgumentCountValidator, that implements InvocationInterceptor and overrides interceptTestTemplateMethod with the validation before calling invocation.proceed().

Please let me know whether that's enough information for you to proceed. Alternatively, I'd also be willing to take over if you'd prefer that.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I also thought something wasn't quite right after playing around with ParameterizedTestDemo. Thanks for the pointers with ParameterizedTestInvocationContext#getAdditionalExtensions. That definitely seems doable to me.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've updated the code, I think the Windows check is failing for unrelated reasons

invocationCount.incrementAndGet();
marcphilipp marked this conversation as resolved.
Show resolved Hide resolved
return createInvocationContext(formatter, methodContext, arguments, invocationCount.intValue());
})
Expand All @@ -98,14 +107,80 @@ public boolean mayReturnZeroTestTemplateInvocationContexts(ExtensionContext exte
}

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

private ExtensionContext.Store getStore(ExtensionContext context) {
private ExtensionContext.Store getStoreInMethodNamespace(ExtensionContext context) {
return context.getStore(Namespace.create(ParameterizedTestExtension.class, context.getRequiredTestMethod()));
}

private ExtensionContext.Store getStoreInExtensionNamespace(ExtensionContext context) {
return context.getRoot().getStore(Namespace.create(ParameterizedTestExtension.class));
}

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 = findAnnotation(//
extensionContext.getRequiredTestMethod(), ParameterizedTest.class//
).orElseThrow(NoSuchElementException::new);
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 = getStoreInExtensionNamespace(extensionContext);
return store.getOrComputeIfAbsent(key, __ -> {
Optional<String> optionalConfigValue = extensionContext.getConfigurationParameter(key);
if (optionalConfigValue.isPresent()) {
String configValue = optionalConfigValue.get();
Optional<ArgumentCountValidationMode> 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);
}

private TestTemplateInvocationContext createInvocationContext(ParameterizedTestNameFormatter formatter,
ParameterizedTestMethodContext methodContext, Arguments arguments, int invocationIndex) {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -1112,6 +1113,63 @@ 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 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(ParameterizedTestExtension.ARGUMENT_COUNT_VALIDATION_KEY,
configurationValue.name().toLowerCase()) //
.execute();
}
}

marcphilipp marked this conversation as resolved.
Show resolved Hide resolved
@Nested
class RepeatableSourcesIntegrationTests {

Expand Down Expand Up @@ -2028,6 +2086,17 @@ void testWithFieldSourceProvidingUnusedArguments(String argument) {
static Supplier<Stream<Arguments>> 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);
}
}

static class LifecycleTestCase {
Expand Down