Skip to content

Commit

Permalink
Add method-level sources to CartesianTest (#415 / #540)
Browse files Browse the repository at this point in the history
Cartesian tests version 1 required users to annotate the test method
to define individual arguments. While version 2 of this extension
moves the bulk of configuration to parameter-level annotations it
should still allow method-level annotations to define the full set of
sets, for example to define them in a CSV or JSON file.

This is now possible. It's achieved by turning
`CartesianArgumentsProvider` into an internal marker interface with
two public extensions:

* `CartesianParameterArgumentsProvider`
  (for providing arguments for an individual parameter,
   i.e. takes on the former meaning of `CartesianArgumentsProvider`)
* `CartesianMethodArgumentsProvider`
  (for providing arguments for all parameters)

Related to #415
PR: #540
  • Loading branch information
Michael1993 committed Feb 19, 2022
1 parent de82d95 commit 63d458d
Show file tree
Hide file tree
Showing 17 changed files with 1,298 additions and 107 deletions.
356 changes: 330 additions & 26 deletions docs/cartesian-product.adoc

Large diffs are not rendered by default.

32 changes: 22 additions & 10 deletions src/main/java/org/junitpioneer/internal/PioneerAnnotationUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
Expand Down Expand Up @@ -259,19 +260,30 @@ private static <A extends Annotation> List<A> findOnType(Class<?> element, Class
}

public static List<? extends Annotation> findParameterArgumentsSources(Method testMethod) {
//@formatter:off
return Arrays.stream(testMethod.getParameters())
.map(parameter -> {
List<Annotation> annotations = new ArrayList<>();
AnnotationSupport.findAnnotation(parameter, CartesianArgumentsSource.class)
.ifPresent(annotations::add);
annotations.addAll(AnnotationSupport.findRepeatableAnnotations(parameter, ArgumentsSource.class));
return annotations;
})
return Arrays
.stream(testMethod.getParameters())
.map(PioneerAnnotationUtils::collectArgumentSources)
.filter(list -> !list.isEmpty())
.map(annotations -> annotations.get(0))
.collect(Collectors.toList());
//@formatter:on
}

private static List<Annotation> collectArgumentSources(Parameter parameter) {
List<Annotation> annotations = new ArrayList<>();
AnnotationSupport.findAnnotation(parameter, CartesianArgumentsSource.class).ifPresent(annotations::add);
// ArgumentSource meta-annotations are allowed on parameters for
// CartesianTest because there is no overlap with ParameterizedTest
annotations.addAll(AnnotationSupport.findRepeatableAnnotations(parameter, ArgumentsSource.class));
return annotations;
}

public static List<Annotation> findMethodArgumentsSources(Method testMethod) {
return Arrays
.stream(testMethod.getAnnotations())
.filter(annotation -> AnnotationSupport
.findAnnotation(annotation.annotationType(), CartesianArgumentsSource.class)
.isPresent())
.collect(Collectors.toList());
}

}
149 changes: 149 additions & 0 deletions src/main/java/org/junitpioneer/jupiter/cartesian/ArgumentSets.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/*
* Copyright 2016-2021 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
*
* http://www.eclipse.org/legal/epl-v20.html
*/

package org.junitpioneer.jupiter.cartesian;

import static java.util.stream.Collectors.toList;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.stream.Stream;

/**
* Class for defining sets to a {@code CartesianTest} execution with arguments for each parameter
* in the order in which they appear in the test method.
*
* <p>Use the static factory method
* {@link ArgumentSets#argumentsForFirstParameter(Object[]) argumentsForFirstParameter}
* to create an instance and call
* {@link ArgumentSets#argumentsForNextParameter(Object[]) argumentsForNextParameter}
* for each parameter after the first.
* Alternatively, call the static factory method
* {@link ArgumentSets#create() create}
* to create an instance call {@code argumentsForNextParameter}
* for each parameter.
* </p>
*/
public class ArgumentSets {

private final List<List<?>> argumentSets;

private ArgumentSets() {
this.argumentSets = new ArrayList<>();
}

private ArgumentSets(Collection<?> arguments) {
this();
add(arguments);
}

private ArgumentSets add(Collection<?> arguments) {
argumentSets.add(new ArrayList<>(arguments));
return this;
}

/**
* Creates a new {@link ArgumentSets} without arguments for any parameters.
*/
public static ArgumentSets create() {
return new ArgumentSets();
}

/**
* Creates a single set of distinct objects (according to their
* {@link Object#equals(Object) equals}) for the first parameter of
* a {@code CartesianTest} from the elements of the passed
* {@link java.util.Collection Collection}.
* <p>
* The passed argument does not have to be an instance of {@link java.util.Set Set}.
*
* @param arguments the objects that should be passed to the parameter
* @return a new {@link ArgumentSets} object
*/
public static <T> ArgumentSets argumentsForFirstParameter(Collection<T> arguments) {
return new ArgumentSets(arguments);
}

/**
* Creates a single set of distinct objects (according to their
* {@link Object#equals(Object) equals}) for the first parameter of
* a {@code CartesianTest} from the elements of the passed
* objects.
*
* @param arguments the objects that should be passed to the parameter
* @return a new {@link ArgumentSets} object
*/
@SafeVarargs
public static <T> ArgumentSets argumentsForFirstParameter(T... arguments) {
return new ArgumentSets(Arrays.asList(arguments));
}

/**
* Creates a single set of distinct objects (according to their
* {@link Object#equals(Object) equals}) for the first parameter of
* a {@code CartesianTest} from the elements of the passed
* {@link java.util.stream.Stream Stream}.
*
* @param arguments the objects that should be passed to the parameter
* @return a new {@link ArgumentSets} object
*/
public static <T> ArgumentSets argumentsForFirstParameter(Stream<T> arguments) {
return new ArgumentSets(arguments.collect(toList()));
}

/**
* Creates a single set of distinct objects (according to their
* {@link Object#equals(Object) equals}) for the next parameter of
* a {@code CartesianTest} from the elements of the passed
* {@link Collection Collection}.
* <p>
* The passed argument does not have to be an instance of {@link java.util.Set Set}.
*
* @param arguments the objects that should be passed to the parameter
* @return this {@link ArgumentSets} object, for fluent set definitions
*/
public final <T> ArgumentSets argumentsForNextParameter(Collection<T> arguments) {
return add(arguments);
}

/**
* Creates a single set of distinct objects (according to their
* {@link Object#equals(Object) equals}) for the next parameter of
* a {@code CartesianTest} from the elements of the passed
* objects.
*
* @param arguments the objects that should be passed to the parameter
* @return this {@link ArgumentSets} object, for fluent set definitions
*/
@SafeVarargs
public final <T> ArgumentSets argumentsForNextParameter(T... arguments) {
return add(Arrays.asList(arguments));
}

/**
* Creates a single set of distinct objects (according to their
* {@link Object#equals(Object) equals}) for the next parameter of
* a {@code CartesianTest} from the elements of the passed
* {@link Stream Stream}.
*
* @param arguments the objects that should be passed to the parameter
* @return this {@link ArgumentSets} object, for fluent set definitions
*/
public final <T> ArgumentSets argumentsForNextParameter(Stream<T> arguments) {
return add(arguments.collect(toList()));
}

List<List<?>> getArguments() {
return argumentSets;
}

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

package org.junitpioneer.jupiter.cartesian;

import java.lang.reflect.Parameter;
import java.util.stream.Stream;

import org.junit.jupiter.api.extension.ExtensionContext;

/**
* If you are implementing an {@link org.junit.jupiter.params.provider.ArgumentsProvider ArgumentsProvider}
* for {@link CartesianTest}, it has to implement this interface <b>as well</b> to know which parameter it provides
* arguments to. For more information, see
* <a href="https://junit-pioneer.org/docs/cartesian-product/" target="_top">the Cartesian product documentation</a>.
*
* @param <T> type of arguments this provider returns
*
* @see org.junit.jupiter.params.provider.ArgumentsProvider
* @see CartesianTestExtension
* This is a marker interface. Users of {@code CartesianTest} must implement either
* {@link CartesianMethodArgumentsProvider} or {@link CartesianParameterArgumentsProvider}.
*/
public interface CartesianArgumentsProvider<T> {

/**
* Provider a {@link Stream} of values that needs to be used for a single parameter in {@code @CartesianTest}.
*
* @param context the current extension context; never {@code null}
* @param parameter the parameter for which the arguments needs to be provided
* @return a stream of values; never {@code null}
*/
Stream<T> provideArguments(ExtensionContext context, Parameter parameter) throws Exception;
interface CartesianArgumentsProvider {

}
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@

/**
* {@code @CartesianArgumentsSource} is an annotation
* that is used to register {@linkplain CartesianArgumentsProvider cartesian argument providers}
* for the annotated test parameter.
* that is used to register cartesian argument providers
* for the annotated test parameter in case of {@link CartesianParameterArgumentsProvider}
* or for all the test parameters in case of {@link CartesianMethodArgumentsProvider}.
*
* <p>{@code @CartesianArgumentsSource} may also be used as a meta-annotation in order to
* create a custom <em>composed annotation</em> that inherits the semantics
* of {@code @CartesianArgumentsSource}.
*
* This is similar to {@link org.junit.jupiter.params.provider.ArgumentsSource ArgumentsSource} and is used
* to provide arguments for {@link CartesianTest}.
* This annotation is used to provide arguments for a {@link CartesianTest}.
*
* @see CartesianTest
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,22 +24,17 @@

/**
* This is basically an enhanced copy of Jupiter's {@code EnumArgumentsProvider},
* except it does NOT support {@code @ParameterizedTest} and implements {@link CartesianArgumentsProvider}
* for use with {@code @CartesianTest}.
*
* @implNote This class does not implement {@code ArgumentsProvider} since the Jupiter's {@code EnumSource}
* should be used for that.
* except it does NOT support {@code @ParameterizedTest} and implements
* {@link CartesianParameterArgumentsProvider} for use with {@code @CartesianTest}.
*/
class CartesianEnumArgumentsProvider<E extends Enum<E>> implements CartesianArgumentsProvider<E> {
class CartesianEnumArgumentsProvider<E extends Enum<E>> implements CartesianParameterArgumentsProvider<E> {

@Override
public Stream<E> provideArguments(ExtensionContext context, Parameter parameter) {
Class<?> parameterType = parameter.getType();
if (!Enum.class.isAssignableFrom(parameterType))
throw new PreconditionViolationException(String
.format(
"Parameter of type %s must reference an Enum type (alternatively, use the annotation's 'value' attribute to specify the type explicitly)",
parameterType));
throw new PreconditionViolationException(
String.format("Parameter of type %s must reference an Enum type", parameterType));
CartesianTest.Enum enumSource = AnnotationSupport
.findAnnotation(parameter, CartesianTest.Enum.class)
.orElseThrow(() -> new PreconditionViolationException(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* Copyright 2016-2021 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
*
* http://www.eclipse.org/legal/epl-v20.html
*/

package org.junitpioneer.jupiter.cartesian;

import static java.lang.String.format;
import static org.junit.platform.commons.support.ReflectionSupport.invokeMethod;

import java.lang.reflect.Method;
import java.lang.reflect.Modifier;

import org.junit.jupiter.api.extension.ExtensionConfigurationException;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.params.support.AnnotationConsumer;
import org.junit.platform.commons.function.Try;
import org.junit.platform.commons.support.ReflectionSupport;
import org.junitpioneer.internal.PioneerUtils;

class CartesianFactoryArgumentsProvider
implements CartesianMethodArgumentsProvider, AnnotationConsumer<CartesianTest.MethodFactory> {

private String methodFactoryName;

@Override
public ArgumentSets provideArguments(ExtensionContext context) throws Exception {
Method testMethod = context.getRequiredTestMethod();
Method factory = findMethodFactory(testMethod, methodFactoryName);
return invokeMethodFactory(testMethod, factory);
}

private static Method findMethodFactory(Method testMethod, String methodFactoryName) {
String factoryName = extractMethodFactoryName(methodFactoryName);
Class<?> declaringClass = findExplicitOrImplicitClass(testMethod, methodFactoryName);
Method factory = PioneerUtils
.findMethodCurrentOrEnclosing(declaringClass, factoryName)
.orElseThrow(() -> new ExtensionConfigurationException("Method `Stream<? extends Arguments> "
+ factoryName + "()` not found in " + declaringClass + " or any enclosing class."));
String method = "Method `" + factory + "`";
if (!Modifier.isStatic(factory.getModifiers()))
throw new ExtensionConfigurationException(method + " must be static.");
if (!ArgumentSets.class.isAssignableFrom(factory.getReturnType()))
throw new ExtensionConfigurationException(
format("%s must return a `%s` object", method, ArgumentSets.class.getName()));
return factory;
}

private static String extractMethodFactoryName(String methodFactoryName) {
if (methodFactoryName.contains("("))
methodFactoryName = methodFactoryName.substring(0, methodFactoryName.indexOf('('));
if (methodFactoryName.contains("#"))
return methodFactoryName.substring(methodFactoryName.indexOf('#') + 1);
return methodFactoryName;
}

private static Class<?> findExplicitOrImplicitClass(Method testMethod, String methodFactoryName) {
if (!methodFactoryName.contains("#"))
return testMethod.getDeclaringClass();

String className = methodFactoryName.substring(0, methodFactoryName.indexOf('#'));
Try<Class<?>> tryToLoadClass = ReflectionSupport.tryToLoadClass(className);
// step (outwards) through all enclosing classes, trying to load the factory class by appending
// its name to the enclosing class' name (if a previous load didn't already succeed
Class<?> methodClass = testMethod.getDeclaringClass();
while (methodClass != null) {
String enclosingName = methodClass.getName();
tryToLoadClass = tryToLoadClass
.orElse(() -> ReflectionSupport.tryToLoadClass(enclosingName + "$" + className));
methodClass = methodClass.getEnclosingClass();
}
return tryToLoadClass
.getOrThrow(ex -> new ExtensionConfigurationException(
format("Class %s not found, referenced in method %s", className, testMethod.getName()), ex));
}

private ArgumentSets invokeMethodFactory(Method testMethod, Method factory) {
ArgumentSets argumentSets = (ArgumentSets) invokeMethod(factory, null);
long count = argumentSets.getArguments().size();
if (count > testMethod.getParameterCount()) {
// If arguments count == parameters but one of the parameters should be auto-injected by JUnit
// JUnit will throw a ParameterResolutionException for competing resolvers before we could get to this line
throw new ParameterResolutionException(format(
"Method `%s` must register values for each parameter exactly once. Expected [%d] parameter sets, but got [%d].",
factory, testMethod.getParameterCount(), count));
}
return argumentSets;
}

@Override
public void accept(CartesianTest.MethodFactory factory) {
this.methodFactoryName = factory.value();
}

}

0 comments on commit 63d458d

Please sign in to comment.