From b51cebd6ba9ca7f524418ed3b2d9b2540308b8d7 Mon Sep 17 00:00:00 2001 From: Jonathan Scott Date: Thu, 5 Aug 2021 16:56:21 +0100 Subject: Import TestParameterInjector. Test: atest TestParameterInjectorTest Change-Id: I4eee53057041be2223ae133c2d2cf1d14fa752d0 --- .../BaseTestParameterValidator.java | 83 ++ .../ParameterValueParsing.java | 233 ++++ .../ParameterizedTestMethodProcessor.java | 226 ++++ .../testparameterinjector/PluggableTestRunner.java | 412 ++++++ .../testparameterinjector/ProtoValueParsing.java | 25 + .../junit/testparameterinjector/TestInfo.java | 308 +++++ .../testparameterinjector/TestMethodProcessor.java | 99 ++ .../TestMethodProcessors.java | 54 + .../junit/testparameterinjector/TestParameter.java | 224 ++++ .../TestParameterAnnotation.java | 266 ++++ .../TestParameterAnnotationMethodProcessor.java | 1382 ++++++++++++++++++++ .../TestParameterInjector.java | 36 + .../TestParameterProcessor.java | 31 + .../TestParameterValidator.java | 68 + .../TestParameterValueProvider.java | 52 + .../testparameterinjector/TestParameterValues.java | 27 + .../testparameterinjector/TestParameters.java | 208 +++ .../TestParametersMethodProcessor.java | 426 ++++++ 18 files changed, 4160 insertions(+) create mode 100644 src/main/java/com/google/testing/junit/testparameterinjector/BaseTestParameterValidator.java create mode 100644 src/main/java/com/google/testing/junit/testparameterinjector/ParameterValueParsing.java create mode 100644 src/main/java/com/google/testing/junit/testparameterinjector/ParameterizedTestMethodProcessor.java create mode 100644 src/main/java/com/google/testing/junit/testparameterinjector/PluggableTestRunner.java create mode 100644 src/main/java/com/google/testing/junit/testparameterinjector/ProtoValueParsing.java create mode 100644 src/main/java/com/google/testing/junit/testparameterinjector/TestInfo.java create mode 100644 src/main/java/com/google/testing/junit/testparameterinjector/TestMethodProcessor.java create mode 100644 src/main/java/com/google/testing/junit/testparameterinjector/TestMethodProcessors.java create mode 100644 src/main/java/com/google/testing/junit/testparameterinjector/TestParameter.java create mode 100644 src/main/java/com/google/testing/junit/testparameterinjector/TestParameterAnnotation.java create mode 100644 src/main/java/com/google/testing/junit/testparameterinjector/TestParameterAnnotationMethodProcessor.java create mode 100644 src/main/java/com/google/testing/junit/testparameterinjector/TestParameterInjector.java create mode 100644 src/main/java/com/google/testing/junit/testparameterinjector/TestParameterProcessor.java create mode 100644 src/main/java/com/google/testing/junit/testparameterinjector/TestParameterValidator.java create mode 100644 src/main/java/com/google/testing/junit/testparameterinjector/TestParameterValueProvider.java create mode 100644 src/main/java/com/google/testing/junit/testparameterinjector/TestParameterValues.java create mode 100644 src/main/java/com/google/testing/junit/testparameterinjector/TestParameters.java create mode 100644 src/main/java/com/google/testing/junit/testparameterinjector/TestParametersMethodProcessor.java (limited to 'src/main/java/com/google/testing/junit') diff --git a/src/main/java/com/google/testing/junit/testparameterinjector/BaseTestParameterValidator.java b/src/main/java/com/google/testing/junit/testparameterinjector/BaseTestParameterValidator.java new file mode 100644 index 0000000..ab5003e --- /dev/null +++ b/src/main/java/com/google/testing/junit/testparameterinjector/BaseTestParameterValidator.java @@ -0,0 +1,83 @@ +/* + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.testing.junit.testparameterinjector; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkState; +import static java.lang.Math.min; + +import java.lang.annotation.Annotation; +import java.util.Comparator; +import java.util.List; + +/** + * Default base class for {@link TestParameterValidator}, simplifying how validators can exclude + * variable independent test parameters annotations. + */ +abstract class BaseTestParameterValidator implements TestParameterValidator { + + @Override + public boolean shouldSkip(Context context) { + for (List> parameters : getIndependentParameters(context)) { + checkArgument(!parameters.isEmpty()); + // For independent test parameters, the only allowed tests will be those that use the same + // Nth specified parameter, except for parameter values that have less specified values than + // others. + + // For example, if parameter A has values a1 and a2, parameter B has values b1 and b2, and + // parameter C has values c1, c2 and c3, given that A, B and C are independent, the only + // tests that will not be skipped will be {(a1, b1, c1), (a2, b2, c2), (a2, b2, c3)}, + // instead of 12 tests that would constitute their cartesian product. + + // First, find the largest specified value count (parameter C in the example above), + // so that we can easily determine which parameter value should be used for validating the + // other parameters (e.g. should this test be for (a1, b1, c1), (a2, b2, c2), or + // (a2, b2, c3). The test parameter 'C' will be the 'leadingParameter'. + Class leadingParameter = + parameters.stream() + .max(Comparator.comparing(parameter -> context.getSpecifiedValues(parameter).size())) + .get(); + // Second, determine which index is the current value in the specified value list of + // the leading parameter. In the example above, the index of the current value 'c2' of the + // leading parameter 'C' would be '1', given the specified values (c1, c2, c3). + int leadingParameterValueIndex = + getValueIndex(context, leadingParameter, context.getValue(leadingParameter).get()); + checkState(leadingParameterValueIndex >= 0); + // Each independent test parameter should be the same index, or the last available index. + // For example, if the parameter is A, and the leading parameter (C) index is 2, the A's index + // should be 1, since a2 is the only available value. + for (Class parameter : parameters) { + List specifiedValues = context.getSpecifiedValues(parameter); + int valueIndex = specifiedValues.indexOf(context.getValue(parameter).get()); + int requiredValueIndex = min(leadingParameterValueIndex, specifiedValues.size() - 1); + if (valueIndex != requiredValueIndex) { + return true; + } + } + } + return false; + } + + private int getValueIndex(Context context, Class annotation, Object value) { + return context.getSpecifiedValues(annotation).indexOf(value); + } + + /** + * Returns a list of TestParameterAnnotation annotated annotation types that are mutually + * independent, and therefore the combinations of their values do not need to be tested. + */ + protected abstract List>> getIndependentParameters( + Context context); +} diff --git a/src/main/java/com/google/testing/junit/testparameterinjector/ParameterValueParsing.java b/src/main/java/com/google/testing/junit/testparameterinjector/ParameterValueParsing.java new file mode 100644 index 0000000..624ee9b --- /dev/null +++ b/src/main/java/com/google/testing/junit/testparameterinjector/ParameterValueParsing.java @@ -0,0 +1,233 @@ +/* + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.testing.junit.testparameterinjector; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; +import static java.util.function.Function.identity; + +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.common.primitives.Primitives; +import com.google.common.reflect.TypeToken; +import com.google.protobuf.ByteString; +import com.google.protobuf.MessageLite; +import java.lang.reflect.ParameterizedType; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import javax.annotation.Nullable; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; + +/** A helper class for parsing parameter values from strings. */ +final class ParameterValueParsing { + + @SuppressWarnings("unchecked") + static > Enum parseEnum(String str, Class enumType) { + return Enum.valueOf((Class) enumType, str); + } + + static MessageLite parseTextprotoMessage(String textprotoString, Class javaType) { + return getProtoValueParser().parseTextprotoMessage(textprotoString, javaType); + } + + static boolean isValidYamlString(String yamlString) { + try { + new Yaml(new SafeConstructor()).load(yamlString); + return true; + } catch (RuntimeException e) { + return false; + } + } + + static Object parseYamlStringToJavaType(String yamlString, Class javaType) { + return parseYamlObjectToJavaType(parseYamlStringToObject(yamlString), TypeToken.of(javaType)); + } + + static Object parseYamlStringToObject(String yamlString) { + return new Yaml(new SafeConstructor()).load(yamlString); + } + + @SuppressWarnings("unchecked") + static Object parseYamlObjectToJavaType(Object parsedYaml, TypeToken javaType) { + // Pass along null so we don't have to worry about it below + if (parsedYaml == null) { + return null; + } + + YamlValueTransformer yamlValueTransformer = + new YamlValueTransformer(parsedYaml, javaType.getRawType()); + + yamlValueTransformer + .ifJavaType(String.class) + .supportParsedType(String.class, identity()) + // Also support other primitives because it's easy to accidentally write e.g. a number when + // a string was intended in YAML + .supportParsedType(Boolean.class, Object::toString) + .supportParsedType(Integer.class, Object::toString) + .supportParsedType(Long.class, Object::toString) + .supportParsedType(Double.class, Object::toString); + + yamlValueTransformer.ifJavaType(Boolean.class).supportParsedType(Boolean.class, identity()); + + yamlValueTransformer.ifJavaType(Integer.class).supportParsedType(Integer.class, identity()); + + yamlValueTransformer + .ifJavaType(Long.class) + .supportParsedType(Long.class, identity()) + .supportParsedType(Integer.class, Integer::longValue); + + yamlValueTransformer + .ifJavaType(Float.class) + .supportParsedType(Float.class, identity()) + .supportParsedType(Double.class, Double::floatValue) + .supportParsedType(Integer.class, Integer::floatValue); + + yamlValueTransformer + .ifJavaType(Double.class) + .supportParsedType(Double.class, identity()) + .supportParsedType(Integer.class, Integer::doubleValue) + .supportParsedType(Long.class, Long::doubleValue); + + yamlValueTransformer + .ifJavaType(Enum.class) + .supportParsedType( + String.class, str -> ParameterValueParsing.parseEnum(str, javaType.getRawType())); + + yamlValueTransformer + .ifJavaType(MessageLite.class) + .supportParsedType(String.class, str -> parseTextprotoMessage(str, javaType.getRawType())) + .supportParsedType( + Map.class, + map -> + getProtoValueParser() + .parseProtobufMessage((Map) map, javaType.getRawType())); + + yamlValueTransformer + .ifJavaType(byte[].class) + .supportParsedType(byte[].class, identity()) + .supportParsedType(String.class, s -> s.getBytes(StandardCharsets.UTF_8)); + + yamlValueTransformer + .ifJavaType(ByteString.class) + .supportParsedType(String.class, ByteString::copyFromUtf8) + .supportParsedType(byte[].class, ByteString::copyFrom); + + // Added mainly for protocol buffer parsing + yamlValueTransformer + .ifJavaType(List.class) + .supportParsedType( + List.class, + list -> + Lists.transform( + list, + e -> + parseYamlObjectToJavaType( + e, getGenericParameterType(javaType, /* parameterIndex= */ 0)))); + yamlValueTransformer + .ifJavaType(Map.class) + .supportParsedType( + Map.class, + map -> + Maps.transformValues( + map, + v -> + parseYamlObjectToJavaType( + v, getGenericParameterType(javaType, /* parameterIndex= */ 1)))); + + return yamlValueTransformer.transformedJavaValue(); + } + + private static TypeToken getGenericParameterType(TypeToken typeToken, int parameterIndex) { + checkArgument( + typeToken.getType() instanceof ParameterizedType, + "Could not parse the generic parameter of type %s", + typeToken); + + ParameterizedType parameterizedType = (ParameterizedType) typeToken.getType(); + return TypeToken.of(parameterizedType.getActualTypeArguments()[parameterIndex]); + } + + private static final class YamlValueTransformer { + private final Object parsedYaml; + private final Class javaType; + @Nullable private Object transformedJavaValue; + + YamlValueTransformer(Object parsedYaml, Class javaType) { + this.parsedYaml = parsedYaml; + this.javaType = javaType; + } + + SupportedJavaType ifJavaType(Class supportedJavaType) { + return new SupportedJavaType<>(supportedJavaType); + } + + Object transformedJavaValue() { + checkArgument( + transformedJavaValue != null, + "Could not map YAML value %s (class = %s) to java class %s", + parsedYaml, + parsedYaml.getClass(), + javaType); + return transformedJavaValue; + } + + final class SupportedJavaType { + + private final Class supportedJavaType; + + private SupportedJavaType(Class supportedJavaType) { + this.supportedJavaType = supportedJavaType; + } + + @SuppressWarnings("unchecked") + SupportedJavaType supportParsedType( + Class parsedYamlType, Function transformation) { + if (Primitives.wrap(supportedJavaType).isAssignableFrom(Primitives.wrap(javaType))) { + if (Primitives.wrap(parsedYamlType).isInstance(parsedYaml)) { + checkState( + transformedJavaValue == null, + "This case is already handled. This is a bug in" + + " testparameterinjector.TestParametersMethodProcessor."); + transformedJavaValue = checkNotNull(transformation.apply((ParsedYamlT) parsedYaml)); + } + } + + return this; + } + } + } + + static ProtoValueParsing getProtoValueParser() { + try { + // This is called reflectively so that the android target doesn't have to build in + // ProtoValueParsing, which has no Android-compatible target. + Class clazz = + Class.forName("com.google.testing.junit.testparameterinjector.ProtoValueParsingImpl"); + return (ProtoValueParsing) clazz.getDeclaredConstructor().newInstance(); + } catch (ClassNotFoundException unused) { + throw new UnsupportedOperationException( + "Textproto support is not available when using the Android version of" + + " testparameterinjector."); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } + + private ParameterValueParsing() {} +} diff --git a/src/main/java/com/google/testing/junit/testparameterinjector/ParameterizedTestMethodProcessor.java b/src/main/java/com/google/testing/junit/testparameterinjector/ParameterizedTestMethodProcessor.java new file mode 100644 index 0000000..dbafc6a --- /dev/null +++ b/src/main/java/com/google/testing/junit/testparameterinjector/ParameterizedTestMethodProcessor.java @@ -0,0 +1,226 @@ +/* + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.testing.junit.testparameterinjector; + +import static com.google.common.base.Preconditions.checkState; + +import com.google.auto.value.AutoAnnotation; +import com.google.common.base.Optional; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +import com.google.testing.junit.testparameterinjector.TestInfo.TestInfoParameter; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.reflect.Constructor; +import java.text.MessageFormat; +import java.util.List; +import org.junit.runner.Description; +import org.junit.runners.Parameterized.Parameters; +import org.junit.runners.model.FrameworkMethod; +import org.junit.runners.model.Statement; +import org.junit.runners.model.TestClass; + +/** + * {@code TestMethodProcessor} implementation for supporting {@link org.junit.runners.Parameterized} + * tests. + * + *

Supports parameterized class if a method with the {@link Parameters} annotation is defined. As + * opposed to the junit {@link org.junit.runners.Parameterized} class, only one method can have the + * {@link Parameters} annotation, and has to be both public and static. + * + *

The {@link Parameters} annotated method can return either a {@code Collection} or a + * {@code Collection}. + * + *

Does not support injected {@link org.junit.runners.Parameterized.Parameter} fields, and + * instead requires a single class constructor with one argument for each parameter returned by the + * {@link Parameters} method. + */ +class ParameterizedTestMethodProcessor implements TestMethodProcessor { + + /** + * The parameters as returned by the {@link Parameters} annotated method, or {@link + * Optional#absent()} if the class is not parameterized. + */ + private final Optional> parametersForAllTests; + /** + * The test name pattern as defined by the 'name' attribute of the {@link Parameters} annotation, + * or {@link Optional#absent()} if the class is not parameterized. + */ + private final Optional testNamePattern; + + ParameterizedTestMethodProcessor(TestClass testClass) { + Optional parametersMethod = getParametersMethod(testClass); + if (parametersMethod.isPresent()) { + Object parameters; + try { + parameters = parametersMethod.get().invokeExplosively(null); + } catch (Throwable t) { + throw new RuntimeException(t); + } + if (parameters instanceof Iterable) { + parametersForAllTests = Optional.>of((Iterable) parameters); + } else if (parameters instanceof Object[]) { + parametersForAllTests = + Optional.>of(ImmutableList.copyOf((Object[]) parameters)); + } else { + throw new IllegalStateException( + "Unsupported @Parameters return value type: " + parameters.getClass()); + } + testNamePattern = Optional.of(parametersMethod.get().getAnnotation(Parameters.class).name()); + } else { + parametersForAllTests = Optional.absent(); + testNamePattern = Optional.absent(); + } + } + + @Override + public ValidationResult validateConstructor(TestClass testClass, List list) { + if (parametersForAllTests.isPresent()) { + if (testClass.getJavaClass().getConstructors().length != 1) { + list.add( + new IllegalStateException("Test class should have exactly one public constructor")); + return ValidationResult.HANDLED; + } + Constructor constructor = testClass.getOnlyConstructor(); + Class[] parameterTypes = constructor.getParameterTypes(); + Object[] testParameters = getTestParameters(0); + if (parameterTypes.length != testParameters.length) { + list.add( + new IllegalStateException( + "Mismatch constructor parameter count with values" + + " returned by the @Parameters method")); + return ValidationResult.HANDLED; + } + for (int i = 0; i < testParameters.length; i++) { + if (!parameterTypes[i].isAssignableFrom(testParameters[i].getClass())) { + list.add( + new IllegalStateException( + String.format( + "Mismatch constructor parameter type %s with value" + + " returned by the @Parameters method: %s", + parameterTypes[i], testParameters[i]))); + } + } + return ValidationResult.HANDLED; + } + return ValidationResult.NOT_HANDLED; + } + + @Override + public ValidationResult validateTestMethod( + TestClass testClass, FrameworkMethod testMethod, List errorsReturned) { + return ValidationResult.NOT_HANDLED; + } + + @Override + public List processTest(Class testClass, TestInfo originalTest) { + if (parametersForAllTests.isPresent()) { + ImmutableList.Builder tests = ImmutableList.builder(); + int testIndex = 0; + for (Object parameters : parametersForAllTests.get()) { + Object[] parametersForOneTest; + if (parameters instanceof Object[]) { + parametersForOneTest = (Object[]) parameters; + } else { + parametersForOneTest = new Object[] {parameters}; + } + String namePattern = testNamePattern.get().replace("{index}", Integer.toString(testIndex)); + String testParametersString = MessageFormat.format(namePattern, parametersForOneTest); + tests.add( + originalTest + .withExtraParameters( + ImmutableList.of( + TestInfoParameter.create( + testParametersString, parametersForOneTest, testIndex))) + .withExtraAnnotation(TestIndexHolderFactory.create(testIndex))); + testIndex++; + } + return tests.build(); + } + return ImmutableList.of(originalTest); + } + + @Override + public Statement processStatement(Statement originalStatement, Description finalTestDescription) { + return originalStatement; + } + + @Override + public Optional createTest( + TestClass testClass, FrameworkMethod method, Optional test) { + if (parametersForAllTests.isPresent()) { + Object[] testParameters = + getTestParameters(method.getAnnotation(TestIndexHolder.class).testIndex()); + try { + Constructor constructor = testClass.getOnlyConstructor(); + return Optional.of(constructor.newInstance(testParameters)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + return test; + } + + @Override + public Optional createStatement( + TestClass testClass, + FrameworkMethod method, + Object testObject, + Optional statement) { + return statement; + } + + /** + * This mechanism is a workaround to be able to store the test index in the annotation list of the + * {@link TestInfo}, since we cannot carry other information through the test runner. + */ + @Retention(RetentionPolicy.RUNTIME) + @interface TestIndexHolder { + int testIndex(); + } + + /** Factory for {@link TestIndexHolder}. */ + static class TestIndexHolderFactory { + @AutoAnnotation + static TestIndexHolder create(int testIndex) { + return new AutoAnnotation_ParameterizedTestMethodProcessor_TestIndexHolderFactory_create( + testIndex); + } + + private TestIndexHolderFactory() {} + } + + private Object[] getTestParameters(int testIndex) { + Object parameters = Iterables.get(parametersForAllTests.get(), testIndex); + if (parameters instanceof Object[]) { + return (Object[]) parameters; + } else { + return new Object[] {parameters}; + } + } + + private Optional getParametersMethod(TestClass testClass) { + List methods = testClass.getAnnotatedMethods(Parameters.class); + if (methods.isEmpty()) { + return Optional.absent(); + } + FrameworkMethod method = Iterables.getOnlyElement(methods); + checkState( + method.isPublic() && method.isStatic(), + "@Parameters method %s should be static and public", + method.getName()); + return Optional.of(method); + } +} diff --git a/src/main/java/com/google/testing/junit/testparameterinjector/PluggableTestRunner.java b/src/main/java/com/google/testing/junit/testparameterinjector/PluggableTestRunner.java new file mode 100644 index 0000000..2c9a199 --- /dev/null +++ b/src/main/java/com/google/testing/junit/testparameterinjector/PluggableTestRunner.java @@ -0,0 +1,412 @@ +/* + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.testing.junit.testparameterinjector; + +import static java.util.Comparator.comparing; +import static java.util.stream.Collectors.joining; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Optional; +import com.google.common.base.Throwables; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +import com.google.common.collect.Lists; +import com.google.testing.junit.testparameterinjector.TestMethodProcessor.ValidationResult; +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.List; +import java.util.stream.Collector; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.Test; +import org.junit.internal.runners.model.ReflectiveCallable; +import org.junit.internal.runners.statements.Fail; +import org.junit.rules.MethodRule; +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runner.notification.Failure; +import org.junit.runner.notification.RunListener; +import org.junit.runner.notification.RunNotifier; +import org.junit.runners.BlockJUnit4ClassRunner; +import org.junit.runners.model.FrameworkMethod; +import org.junit.runners.model.InitializationError; +import org.junit.runners.model.Statement; + +/** + * Class to substitute JUnit4 runner in JUnit4 tests, adding additional functionality. + * + *

See {@link TestParameterInjector} for an example implementation. + */ +abstract class PluggableTestRunner extends BlockJUnit4ClassRunner { + + /** + * A {@link ThreadLocal} is used to handle cases where multiple tests are executing in the same + * java process in different threads. + * + *

A null value indicates that the TestInfo hasn't been set yet, which would typically happen + * if the test hasn't yet started, or the {@link PluggableTestRunner} is not the test runner. + */ + private static final ThreadLocal currentTestInfo = new ThreadLocal<>(); + + private ImmutableList testRules; + private List testMethodProcessors; + + protected PluggableTestRunner(Class klass) throws InitializationError { + super(klass); + } + + /** + * Returns the list of {@link TestMethodProcessor}s to use. This is meant to be overridden by + * subclasses. + */ + protected abstract List createTestMethodProcessorList(); + + /** + * This method is run to perform optional additional operations on the test instance, right after + * it was created. + */ + protected void finalizeCreatedTestInstance(Object testInstance) { + // Do nothing by default + } + + /** + * If true, all test methods (across different TestMethodProcessors) will be sorted in a + * deterministic way by their test name. + * + *

Deterministic means that the order will not change, even when tests are added/removed or + * between releases. + */ + protected boolean shouldSortTestMethodsDeterministically() { + return false; // Don't sort methods by default + } + + /** + * {@link TestRule}s that will be executed after the ones defined in the test class (but still + * before all {@link MethodRule}s). This is meant to be overridden by subclasses. + */ + protected List getInnerTestRules() { + return ImmutableList.of(); + } + + /** + * {@link TestRule}s that will be executed before the ones defined in the test class. This is + * meant to be overridden by subclasses. + */ + protected List getOuterTestRules() { + return ImmutableList.of(); + } + + /** + * {@link MethodRule}s that will be executed after the ones defined in the test class. This is + * meant to be overridden by subclasses. + */ + protected List getInnerMethodRules() { + return ImmutableList.of(); + } + + /** + * {@link MethodRule}s that will be executed before the ones defined in the test class (but still + * after all {@link TestRule}s). This is meant to be overridden by subclasses. + */ + protected List getOuterMethodRules() { + return ImmutableList.of(); + } + + /** + * Runs a {@code testClass} with the {@link PluggableTestRunner}, and returns a list of test + * {@link Failure}, or an empty list if no failure occurred. + */ + @VisibleForTesting + public static ImmutableList run(PluggableTestRunner testRunner) throws Exception { + final ImmutableList.Builder failures = ImmutableList.builder(); + RunNotifier notifier = new RunNotifier(); + notifier.addFirstListener( + new RunListener() { + @Override + public void testFailure(Failure failure) throws Exception { + failures.add(failure); + } + }); + testRunner.run(notifier); + return failures.build(); + } + + @Override + protected final ImmutableList computeTestMethods() { + Stream processedMethods = + super.computeTestMethods().stream().flatMap(method -> processMethod(method).stream()); + + if (shouldSortTestMethodsDeterministically()) { + processedMethods = + processedMethods.sorted( + comparing((FrameworkMethod method) -> method.getName().hashCode()) + .thenComparing(FrameworkMethod::getName)); + } + + return processedMethods.collect(toImmutableList()); + } + + /** Implementation of a JUnit FrameworkMethod where the name and annotation list is overridden. */ + private static class OverriddenFrameworkMethod extends FrameworkMethod { + + private final TestInfo testInfo; + + public OverriddenFrameworkMethod(Method method, TestInfo testInfo) { + super(method); + this.testInfo = testInfo; + } + + public TestInfo getTestInfo() { + return testInfo; + } + + @Override + public String getName() { + return testInfo.getName(); + } + + @Override + public Annotation[] getAnnotations() { + List annotations = testInfo.getAnnotations(); + return annotations.toArray(new Annotation[0]); + } + + @Override + public T getAnnotation(final Class annotationClass) { + return testInfo.getAnnotation(annotationClass); + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof PluggableTestRunner.OverriddenFrameworkMethod)) { + return false; + } + + OverriddenFrameworkMethod other = (OverriddenFrameworkMethod) obj; + return super.equals(other) && other.testInfo.equals(testInfo); + } + + @Override + public int hashCode() { + return super.hashCode() * 37 + testInfo.hashCode(); + } + } + + private ImmutableList processMethod(FrameworkMethod initialMethod) { + ImmutableList testInfos = + ImmutableList.of( + TestInfo.createWithoutParameters( + initialMethod.getMethod(), ImmutableList.copyOf(initialMethod.getAnnotations()))); + + for (final TestMethodProcessor testMethodProcessor : getTestMethodProcessors()) { + testInfos = + testInfos.stream() + .flatMap( + lastTestInfo -> + testMethodProcessor + .processTest(getTestClass().getJavaClass(), lastTestInfo) + .stream()) + .collect(toImmutableList()); + } + + testInfos = TestInfo.deduplicateTestNames(TestInfo.shortenNamesIfNecessary(testInfos)); + + return testInfos.stream() + .map(testInfo -> new OverriddenFrameworkMethod(testInfo.getMethod(), testInfo)) + .collect(toImmutableList()); + } + + // Note: This is a copy of the parent implementation, except that instead of calling + // #createTest(), this method calls #createTestForMethod(method). + @Override + protected final Statement methodBlock(final FrameworkMethod method) { + Object testObject; + try { + testObject = + new ReflectiveCallable() { + @Override + protected Object runReflectiveCall() throws Throwable { + return createTestForMethod(method); + } + }.run(); + } catch (Throwable e) { + return new Fail(e); + } + + Statement statement = methodInvoker(method, testObject); + statement = possiblyExpectingExceptions(method, testObject, statement); + statement = withPotentialTimeout(method, testObject, statement); + statement = withBefores(method, testObject, statement); + statement = withAfters(method, testObject, statement); + statement = withRules(method, testObject, statement); + return statement; + } + + @Override + protected final Statement methodInvoker(FrameworkMethod frameworkMethod, Object testObject) { + Optional statement = Optional.absent(); + for (TestMethodProcessor testMethodProcessor : getTestMethodProcessors()) { + statement = + testMethodProcessor.createStatement( + getTestClass(), frameworkMethod, testObject, statement); + } + if (statement.isPresent()) { + return statement.get(); + } + return super.methodInvoker(frameworkMethod, testObject); + } + + /** Modifies the statement with each {@link MethodRule} and {@link TestRule} */ + private Statement withRules(FrameworkMethod method, Object target, Statement statement) { + ImmutableList testRules = + Stream.of( + getTestRulesForProcessors().stream(), + getInnerTestRules().stream(), + getTestRules(target).stream(), + getOuterTestRules().stream()) + .flatMap(x -> x) + .collect(toImmutableList()); + + Iterable methodRules = + Iterables.concat( + Lists.reverse(getInnerMethodRules()), + rules(target), + Lists.reverse(getOuterMethodRules())); + for (MethodRule methodRule : methodRules) { + // For rules that implement both TestRule and MethodRule, only apply the TestRule. + if (!testRules.contains(methodRule)) { + statement = methodRule.apply(statement, method, target); + } + } + Description testDescription = describeChild(method); + for (TestRule testRule : testRules) { + statement = testRule.apply(statement, testDescription); + } + return new ContextMethodRule().apply(statement, method, target); + } + + private Object createTestForMethod(FrameworkMethod method) throws Exception { + Optional maybeTestInstance = Optional.absent(); + for (TestMethodProcessor testMethodProcessor : getTestMethodProcessors()) { + maybeTestInstance = testMethodProcessor.createTest(getTestClass(), method, maybeTestInstance); + } + // If no processor created the test instance, fallback on the default implementation. + Object testInstance = + maybeTestInstance.isPresent() ? maybeTestInstance.get() : super.createTest(); + + finalizeCreatedTestInstance(testInstance); + + return testInstance; + } + + @Override + protected final void validateZeroArgConstructor(List errorsReturned) { + for (TestMethodProcessor testMethodProcessor : getTestMethodProcessors()) { + if (testMethodProcessor.validateConstructor(getTestClass(), errorsReturned) + == ValidationResult.HANDLED) { + return; + } + } + super.validateZeroArgConstructor(errorsReturned); + } + + @Override + protected final void validateTestMethods(List list) { + List testMethods = getTestClass().getAnnotatedMethods(Test.class); + for (FrameworkMethod testMethod : testMethods) { + boolean isHandled = false; + for (TestMethodProcessor testMethodProcessor : getTestMethodProcessors()) { + if (testMethodProcessor.validateTestMethod(getTestClass(), testMethod, list) + == ValidationResult.HANDLED) { + isHandled = true; + break; + } + } + if (!isHandled) { + testMethod.validatePublicVoidNoArg(false /* isStatic */, list); + } + } + } + + // Fix for ParentRunner bug: + // Overriding this method because a superclass (ParentRunner) is calling this in its constructor + // and then throwing an InitializationError that doesn't have any of the causes in the exception + // message. + @Override + protected final void collectInitializationErrors(List errors) { + super.collectInitializationErrors(errors); + if (!errors.isEmpty()) { + throw new RuntimeException( + String.format( + "Found %s issues while initializing the test runner:\n\n - %s\n\n\n", + errors.size(), + errors.stream() + .map(Throwables::getStackTraceAsString) + .collect(joining("\n\n\n - ")))); + } + } + + // Override this test as final because it is not (always) invoked + @Override + protected final Object createTest() throws Exception { + return super.createTest(); + } + + // Override this test as final because it is not (always) invoked + @Override + protected final void validatePublicVoidNoArgMethods( + Class annotation, boolean isStatic, List errors) { + super.validatePublicVoidNoArgMethods(annotation, isStatic, errors); + } + + private synchronized List getTestMethodProcessors() { + if (testMethodProcessors == null) { + testMethodProcessors = createTestMethodProcessorList(); + } + return testMethodProcessors; + } + + private synchronized ImmutableList getTestRulesForProcessors() { + if (testRules == null) { + testRules = + testMethodProcessors.stream() + .map(testMethodProcessor -> (TestRule) testMethodProcessor::processStatement) + .collect(toImmutableList()); + } + return testRules; + } + + /** {@link MethodRule} that sets up the Context for each test. */ + private static class ContextMethodRule implements MethodRule { + @Override + public Statement apply(Statement statement, FrameworkMethod method, Object o) { + return new Statement() { + @Override + public void evaluate() throws Throwable { + currentTestInfo.set(((OverriddenFrameworkMethod) method).getTestInfo()); + try { + statement.evaluate(); + } finally { + currentTestInfo.set(null); + } + } + }; + } + } + + private static Collector> toImmutableList() { + return Collectors.collectingAndThen(Collectors.toList(), ImmutableList::copyOf); + } +} diff --git a/src/main/java/com/google/testing/junit/testparameterinjector/ProtoValueParsing.java b/src/main/java/com/google/testing/junit/testparameterinjector/ProtoValueParsing.java new file mode 100644 index 0000000..61cf13b --- /dev/null +++ b/src/main/java/com/google/testing/junit/testparameterinjector/ProtoValueParsing.java @@ -0,0 +1,25 @@ +/* + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.testing.junit.testparameterinjector; + +import com.google.protobuf.MessageLite; +import java.util.Map; + +/** A helper class for parsing proto values from strings. */ +interface ProtoValueParsing { + MessageLite parseTextprotoMessage(String textprotoString, Class javaType); + + MessageLite parseProtobufMessage(Map map, Class javaType); +} diff --git a/src/main/java/com/google/testing/junit/testparameterinjector/TestInfo.java b/src/main/java/com/google/testing/junit/testparameterinjector/TestInfo.java new file mode 100644 index 0000000..7d16b6e --- /dev/null +++ b/src/main/java/com/google/testing/junit/testparameterinjector/TestInfo.java @@ -0,0 +1,308 @@ +/* + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.testing.junit.testparameterinjector; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static java.lang.Math.min; +import static java.util.stream.Collectors.joining; +import static java.util.stream.Collectors.toSet; + +import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Multimap; +import com.google.common.collect.MultimapBuilder; +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.stream.Collector; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import javax.annotation.Nullable; + +/** A POJO containing information about a test (name and anotations). */ +@AutoValue +abstract class TestInfo { + + /** + * The maximum amount of characters that {@link #getName()} can have. + * + *

See b/168325767 for the reason behind this. tl;dr the name is put into a Unix file with max + * 255 characters. The surrounding constant characters take up 31 characters. The max is reduced + * by an additional 24 characters to account for future changes. + */ + static final int MAX_TEST_NAME_LENGTH = 200; + + /** The maximum amount of characters that a single parameter can take up in {@link #getName()}. */ + static final int MAX_PARAMETER_NAME_LENGTH = 100; + + public abstract Method getMethod(); + + public String getName() { + if (getParameters().isEmpty()) { + return getMethod().getName(); + } else { + return String.format( + "%s[%s]", + getMethod().getName(), + getParameters().stream().map(TestInfoParameter::getName).collect(joining(","))); + } + } + + abstract ImmutableList getParameters(); + + public abstract ImmutableList getAnnotations(); + + @Nullable + public T getAnnotation(Class annotationClass) { + for (Annotation annotation : getAnnotations()) { + if (annotationClass.isInstance(annotation)) { + return annotationClass.cast(annotation); + } + } + return null; + } + + TestInfo withExtraParameters(List parameters) { + return new AutoValue_TestInfo( + getMethod(), + ImmutableList.builder() + .addAll(this.getParameters()) + .addAll(parameters) + .build(), + getAnnotations()); + } + + TestInfo withExtraAnnotation(Annotation annotation) { + ImmutableList newAnnotations = + ImmutableList.builder().addAll(this.getAnnotations()).add(annotation).build(); + return new AutoValue_TestInfo(getMethod(), getParameters(), newAnnotations); + } + + /** + * Returns a new TestInfo instance with updated parameter names. + * + * @param parameterWithIndexToNewName A function of the parameter and its index in the {@link + * #getParameters()} list to the new name. + */ + private TestInfo withUpdatedParameterNames( + BiFunction parameterWithIndexToNewName) { + return new AutoValue_TestInfo( + getMethod(), + IntStream.range(0, getParameters().size()) + .mapToObj( + parameterIndex -> { + TestInfoParameter parameter = getParameters().get(parameterIndex); + return parameter.withName( + parameterWithIndexToNewName.apply(parameter, parameterIndex)); + }) + .collect(toImmutableList()), + getAnnotations()); + } + + public static TestInfo legacyCreate(Method method, String name, List annotations) { + return new AutoValue_TestInfo( + method, /* parameters= */ ImmutableList.of(), ImmutableList.copyOf(annotations)); + } + + static TestInfo createWithoutParameters(Method method, List annotations) { + return new AutoValue_TestInfo( + method, /* parameters= */ ImmutableList.of(), ImmutableList.copyOf(annotations)); + } + + static ImmutableList shortenNamesIfNecessary(List testInfos) { + if (testInfos.stream() + .anyMatch( + info -> + info.getName().length() > MAX_TEST_NAME_LENGTH + || info.getParameters().stream() + .anyMatch(param -> param.getName().length() > MAX_PARAMETER_NAME_LENGTH))) { + int numberOfParameters = testInfos.get(0).getParameters().size(); + + if (numberOfParameters == 0) { + return ImmutableList.copyOf(testInfos); + } else { + Set parameterIndicesThatNeedUpdate = + IntStream.range(0, numberOfParameters) + .filter( + parameterIndex -> + testInfos.stream() + .anyMatch( + info -> + info.getParameters().get(parameterIndex).getName().length() + > getMaxCharactersPerParameter(info, numberOfParameters))) + .boxed() + .collect(toSet()); + + return testInfos.stream() + .map( + info -> + info.withUpdatedParameterNames( + (parameter, parameterIndex) -> + parameterIndicesThatNeedUpdate.contains(parameterIndex) + ? getShortenedName( + parameter, + getMaxCharactersPerParameter(info, numberOfParameters)) + : info.getParameters().get(parameterIndex).getName())) + .collect(toImmutableList()); + } + } else { + return ImmutableList.copyOf(testInfos); + } + } + + private static int getMaxCharactersPerParameter(TestInfo testInfo, int numberOfParameters) { + int maxLengthOfAllParameters = + // Subtract 2 characters for square brackets + MAX_TEST_NAME_LENGTH - testInfo.getMethod().getName().length() - 2; + return min( + // Subtract 4 characters to leave place for joining commas and the parameter index. + maxLengthOfAllParameters / numberOfParameters - 4, + // Subtract 3 characters to leave place for the parameter index + MAX_PARAMETER_NAME_LENGTH - 3); + } + + static ImmutableList deduplicateTestNames(List testInfos) { + long uniqueTestNameCount = testInfos.stream().map(TestInfo::getName).distinct().count(); + if (testInfos.size() == uniqueTestNameCount) { + // Return early if there are no duplicates + return ImmutableList.copyOf(testInfos); + } else { + return deduplicateWithNumberPrefixes(maybeAddTypesIfDuplicate(testInfos)); + } + } + + private static String getShortenedName( + TestInfoParameter parameter, int maxCharactersPerParameter) { + if (maxCharactersPerParameter < 4) { + // Not enough characters for "..." suffix + return String.valueOf(parameter.getIndexInValueSource() + 1); + } else { + String shortenedName = + parameter.getName().length() > maxCharactersPerParameter + ? parameter.getName().substring(0, maxCharactersPerParameter - 3) + "..." + : parameter.getName(); + return String.format("%s.%s", parameter.getIndexInValueSource() + 1, shortenedName); + } + } + + private static ImmutableList maybeAddTypesIfDuplicate(List testInfos) { + Multimap testNameToInfo = + MultimapBuilder.linkedHashKeys().arrayListValues().build(); + for (TestInfo testInfo : testInfos) { + testNameToInfo.put(testInfo.getName(), testInfo); + } + + return testNameToInfo.keySet().stream() + .flatMap( + testName -> { + Collection matchedInfos = testNameToInfo.get(testName); + if (matchedInfos.size() == 1) { + // There was only one method with this name, so no deduplication is necessary + return matchedInfos.stream(); + } else { + // Found tests with duplicate test names + int numParameters = matchedInfos.iterator().next().getParameters().size(); + Set indicesThatShouldGetSuffix = + // Find parameter indices for which a suffix would allow the reader to + // differentiate + IntStream.range(0, numParameters) + .filter( + parameterIndex -> + matchedInfos.stream() + .map( + info -> + getTypeSuffix( + info.getParameters() + .get(parameterIndex) + .getValue())) + .distinct() + .count() + > 1) + .boxed() + .collect(toSet()); + + return matchedInfos.stream() + .map( + testInfo -> + testInfo.withUpdatedParameterNames( + (parameter, parameterIndex) -> + indicesThatShouldGetSuffix.contains(parameterIndex) + ? parameter.getName() + getTypeSuffix(parameter.getValue()) + : parameter.getName())); + } + }) + .collect(toImmutableList()); + } + + private static String getTypeSuffix(@Nullable Object value) { + if (value == null) { + return " (null reference)"; + } else { + return String.format(" (%s)", value.getClass().getSimpleName()); + } + } + + private static ImmutableList deduplicateWithNumberPrefixes( + ImmutableList testInfos) { + long uniqueTestNameCount = testInfos.stream().map(TestInfo::getName).distinct().count(); + if (testInfos.size() == uniqueTestNameCount) { + return ImmutableList.copyOf(testInfos); + } else { + // There are still duplicates, even after adding type suffixes. As a last resort: add a + // counter to all parameters to guarantee that each case is unique. + return testInfos.stream() + .map( + testInfo -> + testInfo.withUpdatedParameterNames( + (parameter, parameterIndex) -> + String.format( + "%s.%s", parameter.getIndexInValueSource() + 1, parameter.getName()))) + .collect(toImmutableList()); + } + } + + private static Collector> toImmutableList() { + return Collectors.collectingAndThen(Collectors.toList(), ImmutableList::copyOf); + } + + @AutoValue + abstract static class TestInfoParameter { + + abstract String getName(); + + @Nullable + abstract Object getValue(); + + /** + * The index of this parameter value in the list of all values provided by the provider that + * returned this value. + */ + abstract int getIndexInValueSource(); + + TestInfoParameter withName(String newName) { + return create(newName, getValue(), getIndexInValueSource()); + } + + static TestInfoParameter create(String name, @Nullable Object value, int indexInValueSource) { + checkArgument(indexInValueSource >= 0); + return new AutoValue_TestInfo_TestInfoParameter( + checkNotNull(name), value, indexInValueSource); + } + } +} diff --git a/src/main/java/com/google/testing/junit/testparameterinjector/TestMethodProcessor.java b/src/main/java/com/google/testing/junit/testparameterinjector/TestMethodProcessor.java new file mode 100644 index 0000000..880327f --- /dev/null +++ b/src/main/java/com/google/testing/junit/testparameterinjector/TestMethodProcessor.java @@ -0,0 +1,99 @@ +/* + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.testing.junit.testparameterinjector; + +import com.google.common.base.Optional; +import java.util.List; +import org.junit.runner.Description; +import org.junit.runners.BlockJUnit4ClassRunner; +import org.junit.runners.model.FrameworkMethod; +import org.junit.runners.model.Statement; +import org.junit.runners.model.TestClass; + +/** + * Interface to change the list of methods used in a test. + * + *

Note: Implementations of this interface are expected to be immutable, i.e. they no longer + * change after construction. + */ +interface TestMethodProcessor { + + /** Allows to transform the test information (name and annotations). */ + List processTest(Class testClass, TestInfo originalTest); + + /** + * Allows to change the code executed during the test. + * + * @param finalTestDescription the final description calculated taking into account this and all + * other test processors + */ + Statement processStatement(Statement originalStatement, Description finalTestDescription); + + /** + * This method allows to transform the test object used for {@link #processStatement(Statement, + * Description)}. + * + * @param test the value returned by the previous processor, or {@link Optional#absent()} if this + * processor is the first. + * @return {@link Optional#absent()} if the default test instance will be used from instantiating + * the test class with the default constructor. + *

The default implementation should return {@code test}. + */ + Optional createTest(TestClass testClass, FrameworkMethod method, Optional test); + + /** + * This method allows to transform the statement object used for {@link + * #processStatement(Statement, Description)}. + * + * @param statement the value returned by the previous processor, or {@link Optional#absent()} if + * this processor is the first. + * @return {@link Optional#absent()} if the default statement will be used from invoking the test + * method with no parameters. + *

The default implementation should return {@code statement}. + */ + Optional createStatement( + TestClass testClass, + FrameworkMethod method, + Object testObject, + Optional statement); + + /** + * Optionally validates the {@code testClass} constructor, and returns whether the validation + * should continue or stop. + * + * @param errorsReturned A mutable list that any validation error should be added to. + */ + ValidationResult validateConstructor(TestClass testClass, List errorsReturned); + + /** + * Optionally validates the {@code testClass} methods, and returns whether the validation should + * continue or stop. + * + * @param errorsReturned A mutable list that any validation error should be added to. + */ + ValidationResult validateTestMethod( + TestClass testClass, FrameworkMethod testMethod, List errorsReturned); + + /** + * Whether the constructor or method validation has been handled or not. + * + *

If the validation is not handled by a processor, it will be handled using the default {@link + * BlockJUnit4ClassRunner} validator. + */ + enum ValidationResult { + NOT_HANDLED, + HANDLED, + } +} diff --git a/src/main/java/com/google/testing/junit/testparameterinjector/TestMethodProcessors.java b/src/main/java/com/google/testing/junit/testparameterinjector/TestMethodProcessors.java new file mode 100644 index 0000000..b6dc4c2 --- /dev/null +++ b/src/main/java/com/google/testing/junit/testparameterinjector/TestMethodProcessors.java @@ -0,0 +1,54 @@ +/* + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.testing.junit.testparameterinjector; + +import com.google.common.collect.ImmutableList; +import org.junit.runners.model.TestClass; + +/** Factory for all {@link TestMethodProcessor} implementations that this package supports. */ +final class TestMethodProcessors { + + /** + * Returns a new instance of every {@link TestMethodProcessor} implementation that this package + * supports. + * + *

Note that this includes support for {@link org.junit.runners.Parameterized}. + */ + public static ImmutableList + createNewParameterizedProcessorsWithLegacyFeatures(TestClass testClass) { + return ImmutableList.of( + new ParameterizedTestMethodProcessor(testClass), + new TestParametersMethodProcessor(testClass), + TestParameterAnnotationMethodProcessor.forAllAnnotationPlacements(testClass)); + } + + /** + * Returns a new instance of every {@link TestMethodProcessor} implementation that this package + * supports, except the following legacy features: + * + *

    + *
  • No support for {@link org.junit.runners.Parameterized} + *
  • No support for class and method-level parameters, except for @TestParameters + *
+ */ + public static ImmutableList createNewParameterizedProcessors( + TestClass testClass) { + return ImmutableList.of( + new TestParametersMethodProcessor(testClass), + TestParameterAnnotationMethodProcessor.onlyForFieldsAndParameters(testClass)); + } + + private TestMethodProcessors() {} +} diff --git a/src/main/java/com/google/testing/junit/testparameterinjector/TestParameter.java b/src/main/java/com/google/testing/junit/testparameterinjector/TestParameter.java new file mode 100644 index 0000000..6725d16 --- /dev/null +++ b/src/main/java/com/google/testing/junit/testparameterinjector/TestParameter.java @@ -0,0 +1,224 @@ +/* + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.testing.junit.testparameterinjector; + +import static com.google.common.base.Preconditions.checkState; +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.toList; + +import com.google.common.collect.ImmutableList; +import com.google.common.primitives.Primitives; +import com.google.protobuf.MessageLite; +import com.google.testing.junit.testparameterinjector.TestParameter.InternalImplementationOfThisParameter; +import java.lang.annotation.Annotation; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; +import java.lang.reflect.Constructor; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Test parameter annotation that defines the values that a single parameter can have. + * + *

For enums and booleans, the values can be automatically derived as all possible values: + * + *

+ * {@literal @}Test
+ * public void test1(@TestParameter MyEnum myEnum, @TestParameter boolean myBoolean) {
+ *   // ... will run for [(A,false), (A,true), (B,false), (B,true), (C,false), (C,true)]
+ * }
+ *
+ * enum MyEnum { A, B, C }
+ * 
+ * + *

The values can be explicitly defined as a parsed string: + * + *

+ * public void test1(
+ *     {@literal @}TestParameter({"{name: Hermione, age: 18}", "{name: Dumbledore, age: 115}"})
+ *         UpdateCharacterRequest request,
+ *     {@literal @}TestParameter({"1", "4"}) int bookNumber) {
+ *   // ... will run for [(Hermione,1), (Hermione,4), (Dumbledore,1), (Dumbledore,4)]
+ * }
+ * 
+ * + *

For more flexibility, see {{@link #valuesProvider()}}. If you don't want to test all possible + * combinations but instead want to specify sets of parameters explicitly, use @{@link + * TestParameters}. + */ +@Retention(RUNTIME) +@Target({FIELD, PARAMETER}) +@TestParameterAnnotation(valueProvider = InternalImplementationOfThisParameter.class) +public @interface TestParameter { + + /** + * Array of stringified values for the annotated type. + * + *

Types that are supported: + * + *

    + *
  • String: No parsing happens + *
  • boolean: Specified as YAML boolean + *
  • long and int: Specified as YAML integer + *
  • float and double: Specified as YAML floating point or integer + *
  • Enum value: Specified as a String that can be parsed by {@code Enum.valueOf()} + *
  • Byte array or com.google.protobuf.ByteString: Specified as an UTF8 String or YAML bytes + * (example: "!!binary 'ZGF0YQ=='") + *
+ * + *

For dynamic sets of parameters or parameter types that are not supported here, use {@link + * #valuesProvider()} and leave this field empty. + * + *

For examples, see {@link TestParameter}. + */ + String[] value() default {}; + + /** + * Sets a provider that will return a list of parameter values. + * + *

If this field is set, {@link #value()} must be empty and vice versa. + * + *

Example + * + *

+   * {@literal @}Test
+   * public void matchesAllOf_throwsOnNull(
+   *     {@literal @}TestParameter(valuesProvider = CharMatcherProvider.class)
+   *         CharMatcher charMatcher) {
+   *   assertThrows(NullPointerException.class, () -> charMatcher.matchesAllOf(null));
+   * }
+   *
+   * private static final class CharMatcherProvider implements TestParameterValuesProvider {
+   *   {@literal @}Override
+   *   public {@literal List} provideValues() {
+   *     return ImmutableList.of(CharMatcher.any(), CharMatcher.ascii(), CharMatcher.whitespace());
+   *   }
+   * }
+   * 
+ */ + Class valuesProvider() default + DefaultTestParameterValuesProvider.class; + + /** Interface for custom providers of test parameter values. */ + interface TestParameterValuesProvider { + List provideValues(); + } + + /** Default {@link TestParameterValuesProvider} implementation that does nothing. */ + class DefaultTestParameterValuesProvider implements TestParameterValuesProvider { + @Override + public List provideValues() { + return ImmutableList.of(); + } + } + + /** Implementation of this parameter annotation. */ + final class InternalImplementationOfThisParameter implements TestParameterValueProvider { + @Override + public List provideValues( + Annotation uncastAnnotation, Optional> maybeParameterClass) { + TestParameter annotation = (TestParameter) uncastAnnotation; + Class parameterClass = getValueType(annotation.annotationType(), maybeParameterClass); + + boolean valueIsSet = annotation.value().length > 0; + boolean valuesProviderIsSet = + !annotation.valuesProvider().equals(DefaultTestParameterValuesProvider.class); + checkState( + !(valueIsSet && valuesProviderIsSet), + "It is not allowed to specify both value and valuesProvider on annotation %s", + annotation); + + if (valueIsSet) { + return stream(annotation.value()) + .map(v -> parseStringValue(v, parameterClass)) + .collect(toList()); + } else if (valuesProviderIsSet) { + return getValuesFromProvider(annotation.valuesProvider()); + } else { + if (Enum.class.isAssignableFrom(parameterClass)) { + return ImmutableList.copyOf(parameterClass.asSubclass(Enum.class).getEnumConstants()); + } else if (Primitives.wrap(parameterClass).equals(Boolean.class)) { + return ImmutableList.of(false, true); + } else { + throw new IllegalStateException( + String.format( + "A @TestParameter without values can only be placed at an enum or a boolean, but" + + " was placed by a %s", + parameterClass)); + } + } + } + + @Override + public Class getValueType( + Class annotationType, Optional> parameterClass) { + return parameterClass.orElseThrow( + () -> + new AssertionError( + String.format( + "An empty parameter class should not be possible since" + + " @TestParameter can only target FIELD or PARAMETER, both" + + " of which are supported for annotation %s.", + annotationType))); + } + + private static Object parseStringValue(String value, Class parameterClass) { + if (parameterClass.equals(String.class)) { + return value.equals("null") ? null : value; + } else if (Enum.class.isAssignableFrom(parameterClass)) { + return value.equals("null") ? null : ParameterValueParsing.parseEnum(value, parameterClass); + } else if (MessageLite.class.isAssignableFrom(parameterClass)) { + if (ParameterValueParsing.isValidYamlString(value)) { + return ParameterValueParsing.parseYamlStringToJavaType(value, parameterClass); + } else { + return ParameterValueParsing.parseTextprotoMessage(value, parameterClass); + } + } else { + return ParameterValueParsing.parseYamlStringToJavaType(value, parameterClass); + } + } + + private static List getValuesFromProvider( + Class valuesProvider) { + try { + Constructor constructor = + valuesProvider.getDeclaredConstructor(); + constructor.setAccessible(true); + return new ArrayList<>(constructor.newInstance().provideValues()); + } catch (NoSuchMethodException e) { + if (!Modifier.isStatic(valuesProvider.getModifiers()) && valuesProvider.isMemberClass()) { + throw new IllegalStateException( + String.format( + "Could not find a no-arg constructor for %s, probably because it is a not-static" + + " inner class. You can fix this by making %s static.", + valuesProvider.getSimpleName(), valuesProvider.getSimpleName()), + e); + } else { + throw new IllegalStateException( + String.format( + "Could not find a no-arg constructor for %s.", valuesProvider.getSimpleName()), + e); + } + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + } +} diff --git a/src/main/java/com/google/testing/junit/testparameterinjector/TestParameterAnnotation.java b/src/main/java/com/google/testing/junit/testparameterinjector/TestParameterAnnotation.java new file mode 100644 index 0000000..a859a4f --- /dev/null +++ b/src/main/java/com/google/testing/junit/testparameterinjector/TestParameterAnnotation.java @@ -0,0 +1,266 @@ +/* + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.testing.junit.testparameterinjector; + +import static com.google.common.base.Preconditions.checkState; +import static com.google.common.base.Verify.verify; +import static java.lang.annotation.ElementType.ANNOTATION_TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import com.google.common.collect.ImmutableList; +import com.google.common.primitives.Primitives; +import java.lang.annotation.Annotation; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; +import java.lang.reflect.Array; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.text.MessageFormat; +import java.util.List; +import java.util.Optional; + +/** + * Annotation to define a test annotation used to have parameterized methods, in either a + * parameterized or non parameterized test. + * + *

Parameterized tests enabled by defining a annotation (see {@link TestParameter} as an example) + * for the type of the parameter, defining a member variable annotated with this annotation, and + * specifying the parameter with the same annotation for each test, or for the whole class, for + * example: + * + *

{@code
+ * @RunWith(TestParameterInjector.class)
+ * public class ColorTest {
+ *     @Retention(RUNTIME)
+ *     @Target({TYPE, METHOD, FIELD})
+ *     @TestParameterAnnotation
+ *     public @interface ColorParameter {
+ *       Color[] value() default {};
+ *     }
+ *
+ *     @ColorParameter({BLUE, WHITE, RED}) private Color color;
+ *
+ *     @Test
+ *     public void test() {
+ *       assertThat(paint(color)).isSuccessful();
+ *     }
+ * }
+ * }
+ * + *

An alternative is to use a method parameter for injection: + * + *

{@code
+ * @RunWith(TestParameterInjector.class)
+ * public class ColorTest {
+ *     @Retention(RUNTIME)
+ *     @Target({TYPE, METHOD, FIELD})
+ *     @TestParameterAnnotation
+ *     public @interface ColorParameter {
+ *       Color[] value() default {};
+ *     }
+ *
+ *     @Test
+ *     @ColorParameter({BLUE, WHITE, RED})
+ *     public void test(Color color) {
+ *       assertThat(paint(color)).isSuccessful();
+ *     }
+ * }
+ * }
+ * + *

Yet another alternative is to use a method parameter for injection, but with the annotation + * specified on the parameter itself, which helps when multiple arguments share the + * same @TestParameterAnnotation annotation. + * + *

{@code
+ * @RunWith(TestParameterInjector.class)
+ * public class ColorTest {
+ *     @Retention(RUNTIME)
+ *     @Target({TYPE, METHOD, FIELD})
+ *     @TestParameterAnnotation
+ *     public @interface ColorParameter {
+ *       Color[] value() default {};
+ *     }
+ *
+ *     @Test
+ *     public void test(@ColorParameter({BLUE, WHITE}) Color color1,
+ *                      @ColorParameter({WHITE, RED}) Color color2) {
+ *       assertThat(paint(color1. color2)).isSuccessful();
+ *     }
+ * }
+ * }
+ * + *

Class constructors can also be annotated with @TestParameterAnnotation annotations, as shown + * below: + * + *

{@code
+ * @RunWith(TestParameterInjector.class)
+ * public class ColorTest {
+ *     @Retention(RUNTIME)
+ *     @Target({TYPE, METHOD, FIELD})
+ *     public @TestParameterAnnotation
+ *     public @interface ColorParameter {
+ *       Color[] value() default {};
+ *     }
+ *
+ *     public ColorTest(@ColorParameter({BLUE, WHITE}) Color color) {
+ *       ...
+ *     }
+ *
+ *     @Test
+ *     public void test() {...}
+ * }
+ * }
+ * + *

Each field that needs to be injected from a parameter requires its dedicated distinct + * annotation. + * + *

If the same annotation is defined both on the class and method, the method parameter values + * take precedence. + * + *

If the same annotation is defined both on the class and constructor, the constructor parameter + * values take precedence. + * + *

Annotations cannot be duplicated between the constructor or constructor parameters and a + * method or method parameter. + * + *

Since the parameter values must be specified in an annotation return value, they are + * restricted to the annotation method return type set (primitive, Class, Enum, String, etc...). If + * parameters have to be dynamically generated, the conventional Parameterized mechanism with {@code + * Parameters} has to be used instead. + */ +@Retention(RUNTIME) +@Target({ANNOTATION_TYPE}) +@interface TestParameterAnnotation { + /** + * Pattern of the {@link MessageFormat} format to derive the test's name from the parameters. + * + * @see {@code Parameters#name()} + */ + String name() default "{0}"; + + /** Specifies a validator for the parameter to determine whether test should be skipped. */ + Class validator() default DefaultValidator.class; + + /** + * Specifies a processor for the parameter to invoke arbitrary code before and after the test + * statement's execution. + */ + Class processor() default DefaultProcessor.class; + + /** Specifies a value provider for the parameter to provide the values to test. */ + Class valueProvider() default DefaultValueProvider.class; + + /** Default {@link TestParameterValidator} implementation which skips no test. */ + class DefaultValidator implements TestParameterValidator { + + @Override + public boolean shouldSkip(Context context) { + return false; + } + } + + /** Default {@link TestParameterProcessor} implementation which does nothing. */ + class DefaultProcessor implements TestParameterProcessor { + @Override + public void before(Object testParameterValue) {} + + @Override + public void after(Object testParameterValue) {} + } + + /** + * Default {@link TestParameterValueProvider} implementation that gets its values from the + * annotation's `value` method. + */ + class DefaultValueProvider implements TestParameterValueProvider { + + @Override + public List provideValues(Annotation annotation, Optional> parameterClass) { + Object parameters = getParametersAnnotationValues(annotation, annotation.annotationType()); + checkState( + parameters.getClass().isArray(), + "The return value of the value method should be an array"); + + int parameterCount = Array.getLength(parameters); + ImmutableList.Builder resultBuilder = ImmutableList.builder(); + for (int i = 0; i < parameterCount; i++) { + Object value = Array.get(parameters, i); + if (parameterClass.isPresent()) { + verify( + Primitives.wrap(parameterClass.get()).isInstance(value), + "Found %s annotation next to a parameter of type %s which doesn't match" + + " (annotation = %s)", + annotation.annotationType().getSimpleName(), + parameterClass.get().getSimpleName(), + annotation); + } + resultBuilder.add(value); + } + return resultBuilder.build(); + } + + @Override + public Class getValueType( + Class annotationType, Optional> parameterClass) { + try { + Method valueMethod = annotationType.getMethod("value"); + return valueMethod.getReturnType().getComponentType(); + } catch (NoSuchMethodException e) { + throw new RuntimeException( + "The @TestParameterAnnotation annotation should have a single value() method.", e); + } + } + + /** + * Returns the parameters of the test parameter, by calling the {@code value} method on the + * annotation. + */ + private static Object getParametersAnnotationValues( + Annotation annotation, Class annotationType) { + Method valueMethod; + try { + valueMethod = annotationType.getMethod("value"); + } catch (NoSuchMethodException e) { + throw new RuntimeException( + "The @TestParameterAnnotation annotation should have a single value() method.", e); + } + Object parameters; + try { + parameters = valueMethod.invoke(annotation); + } catch (InvocationTargetException e) { + if (e.getCause() instanceof IllegalAccessError) { + // There seems to be a bug or at least something weird with the JVM that causes + // IllegalAccessError to be thrown because the return value is not visible when it is a + // non-public nested type. See + // http://mail.openjdk.java.net/pipermail/core-libs-dev/2014-January/024180.html for more + // info. + throw new RuntimeException( + String.format( + "Could not access %s.value(). This is probably because %s is not visible to the" + + " annotation proxy. To fix this, make %s public.", + annotationType.getSimpleName(), + valueMethod.getReturnType().getSimpleName(), + valueMethod.getReturnType().getSimpleName())); + // Note: Not chaining the exception to reduce the clutter for the reader + } else { + throw new RuntimeException("Unexpected exception while invoking " + valueMethod, e); + } + } catch (Exception e) { + throw new RuntimeException("Unexpected exception while invoking " + valueMethod, e); + } + return parameters; + } + } +} diff --git a/src/main/java/com/google/testing/junit/testparameterinjector/TestParameterAnnotationMethodProcessor.java b/src/main/java/com/google/testing/junit/testparameterinjector/TestParameterAnnotationMethodProcessor.java new file mode 100644 index 0000000..4380f57 --- /dev/null +++ b/src/main/java/com/google/testing/junit/testparameterinjector/TestParameterAnnotationMethodProcessor.java @@ -0,0 +1,1382 @@ +/* + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.testing.junit.testparameterinjector; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkState; +import static com.google.common.base.Verify.verify; +import static java.lang.annotation.RetentionPolicy.RUNTIME; +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.toCollection; +import static java.util.stream.Collectors.toSet; + +import com.google.auto.value.AutoAnnotation; +import com.google.auto.value.AutoValue; +import com.google.common.base.Optional; +import com.google.common.base.Throwables; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Lists; +import com.google.common.primitives.Primitives; +import com.google.common.util.concurrent.UncheckedExecutionException; +import com.google.testing.junit.testparameterinjector.TestInfo.TestInfoParameter; +import java.io.Serializable; +import java.lang.annotation.Annotation; +import java.lang.annotation.Retention; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.function.Predicate; +import java.util.stream.Collector; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import javax.annotation.Nullable; +import org.junit.runner.Description; +import org.junit.runners.model.FrameworkMethod; +import org.junit.runners.model.Statement; +import org.junit.runners.model.TestClass; + +/** + * {@code TestMethodProcessor} implementation for supporting parameterized tests annotated with + * {@link TestParameterAnnotation}. + * + * @see TestParameterAnnotation + */ +class TestParameterAnnotationMethodProcessor implements TestMethodProcessor { + + /** + * Class to hold an annotation type and origin and one of the values as returned by the {@code + * value()} method. + */ + @AutoValue + abstract static class TestParameterValue implements Serializable { + + private static final long serialVersionUID = -6491624726743872379L; + + /** + * Annotation type and origin of the annotation annotated with {@link TestParameterAnnotation}. + */ + abstract AnnotationTypeOrigin annotationTypeOrigin(); + + /** + * The value used for the test as returned by the @TestParameterAnnotation annotated + * annotation's {@code value()} method (e.g. 'true' or 'false' in the case of a Boolean + * parameter). + */ + @Nullable + abstract Object value(); + + /** The index of this value in {@link #specifiedValues()}. */ + abstract int valueIndex(); + + /** + * The list of values specified by the @TestParameterAnnotation annotated annotation's {@code + * value()} method (e.g. {true, false} in the case of a boolean parameter). + */ + @SuppressWarnings("AutoValueImmutableFields") // intentional to allow null values + abstract List specifiedValues(); + + /** + * The class of the parameter or field that is being annotated. In case the annotation is + * annotating a method, constructor or class, {@code paramClass} is an absent optional. + */ + abstract Optional> paramClass(); + + /** + * The name of the parameter or field that is being annotated. In case the annotation is + * annotating a method, constructor or class, {@code paramName} is an absent optional. + */ + abstract Optional paramName(); + + /** + * Returns a String that represents this value and is fit for use in a test name (between + * brackets). + */ + String toTestNameString() { + Class annotationType = annotationTypeOrigin().annotationType(); + String namePattern = annotationType.getAnnotation(TestParameterAnnotation.class).name(); + + if (paramName().isPresent() + && paramClass().isPresent() + && namePattern.equals("{0}") + && Primitives.unwrap(paramClass().get()).isPrimitive()) { + // If no custom name pattern was set and this parameter is a primitive (e.g. + // boolean + // or integer), prefix the parameter value with its field name. This is to avoid + // test names such as myMethod_success[true,false,2]. Instead, it'll be + // myMethod_success[dryRun=true,experimentFlag=false,retries=2]. + return String.format("%s=%s", paramName().get(), value()).trim().replaceAll("\\s+", " "); + } else { + return MessageFormat.format(namePattern, value()).trim().replaceAll("\\s+", " "); + } + } + + public static ImmutableList create( + AnnotationWithMetadata annotationWithMetadata, Origin origin) { + List specifiedValues = getParametersAnnotationValues(annotationWithMetadata); + checkState( + !specifiedValues.isEmpty(), + "The number of parameter values should not be 0" + + ", otherwise the parameter would cause the test to be skipped."); + return IntStream.range(0, specifiedValues.size()) + .mapToObj( + valueIndex -> + new AutoValue_TestParameterAnnotationMethodProcessor_TestParameterValue( + AnnotationTypeOrigin.create( + annotationWithMetadata.annotation().annotationType(), origin), + specifiedValues.get(valueIndex), + valueIndex, + new ArrayList<>(specifiedValues), + annotationWithMetadata.paramClass(), + annotationWithMetadata.paramName())) + .collect(toImmutableList()); + } + } + /** + * Returns a {@link TestParameterValues} for retrieving the {@link TestParameterAnnotation} + * annotation values for a the {@code testInfo}. + */ + public static TestParameterValues getTestParameterValues(TestInfo testInfo) { + TestIndexHolder testIndexHolder = testInfo.getAnnotation(TestIndexHolder.class); + if (testIndexHolder == null) { + return annotationType -> Optional.absent(); + } else { + return annotationType -> + Optional.fromNullable( + new TestParameterAnnotationMethodProcessor( + new TestClass(testInfo.getMethod().getDeclaringClass()), + /* onlyForFieldsAndParameters= */ false) + .getParameterValuesForTest(testIndexHolder).stream() + .filter(matches(annotationType)) + .map(TestParameterValue::value) + .findFirst() + .orElse(null)); + } + } + + /** + * Returns a {@link TestParameterAnnotation} value for the current test as specified by {@code + * testInfo}, or {@link Optional#absent()} if the {@code annotationType} is not found. + */ + public static Optional getTestParameterValue( + TestInfo testInfo, Class annotationType) { + return getTestParameterValues(testInfo).getValue(annotationType); + } + + private static List getParametersAnnotationValues( + AnnotationWithMetadata annotationWithMetadata) { + Annotation annotation = annotationWithMetadata.annotation(); + TestParameterAnnotation testParameter = + annotation.annotationType().getAnnotation(TestParameterAnnotation.class); + Class valueProvider = testParameter.valueProvider(); + try { + return valueProvider + .getConstructor() + .newInstance() + .provideValues( + annotation, + java.util.Optional.ofNullable(annotationWithMetadata.paramClass().orNull())); + } catch (ReflectiveOperationException e) { + throw new RuntimeException( + "Unexpected exception while invoking value provider " + valueProvider, e); + } + } + + private static Predicate matches(Class annotationType) { + return testParameterValue -> + testParameterValue.annotationTypeOrigin().annotationType().equals(annotationType); + } + + /** The origin of an annotation type. */ + enum Origin { + CLASS, + FIELD, + METHOD, + METHOD_PARAMETER, + CONSTRUCTOR, + CONSTRUCTOR_PARAMETER, + } + + /** Class to hold an annotation type and the element where it was declared. */ + @AutoValue + abstract static class AnnotationTypeOrigin implements Serializable { + + private static final long serialVersionUID = 4909750539931241385L; + + /** Annotation type of the @TestParameterAnnotation annotated annotation. */ + abstract Class annotationType(); + + /** Where the annotation was declared. */ + abstract Origin origin(); + + public static AnnotationTypeOrigin create( + Class annotationType, Origin origin) { + return new AutoValue_TestParameterAnnotationMethodProcessor_AnnotationTypeOrigin( + annotationType, origin); + } + + @Override + public final String toString() { + return annotationType().getSimpleName() + ":" + origin(); + } + } + + /** Class to hold an annotation type and metadata about the annotated parameter. */ + @AutoValue + abstract static class AnnotationWithMetadata implements Serializable { + + /** + * The annotation whose interface is itself annotated by the @TestParameterAnnotation + * annotation. + */ + abstract Annotation annotation(); + + /** + * The class of the parameter or field that is being annotated. In case the annotation is + * annotating a method, constructor or class, {@code paramClass} is an absent optional. + */ + abstract Optional> paramClass(); + + /** + * The name of the parameter or field that is being annotated. In case the annotation is + * annotating a method, constructor or class, {@code paramName} is an absent optional. + */ + abstract Optional paramName(); + + public static AnnotationWithMetadata withMetadata( + Annotation annotation, Class paramClass, String paramName) { + return new AutoValue_TestParameterAnnotationMethodProcessor_AnnotationWithMetadata( + annotation, Optional.of(paramClass), Optional.of(paramName)); + } + + public static AnnotationWithMetadata withMetadata(Annotation annotation, Class paramClass) { + return new AutoValue_TestParameterAnnotationMethodProcessor_AnnotationWithMetadata( + annotation, Optional.of(paramClass), Optional.absent()); + } + + public static AnnotationWithMetadata withoutMetadata(Annotation annotation) { + return new AutoValue_TestParameterAnnotationMethodProcessor_AnnotationWithMetadata( + annotation, Optional.absent(), Optional.absent()); + } + } + + private final TestClass testClass; + private final boolean onlyForFieldsAndParameters; + private volatile ImmutableList cachedAnnotationTypeOrigins; + private final Cache>> parameterValuesCache = + CacheBuilder.newBuilder().maximumSize(1000).build(); + + private TestParameterAnnotationMethodProcessor( + TestClass testClass, boolean onlyForFieldsAndParameters) { + this.testClass = testClass; + this.onlyForFieldsAndParameters = onlyForFieldsAndParameters; + } + + /** + * Constructs a new {@link TestMethodProcessor} that handles {@link + * TestParameterAnnotation}-annotated annotations that are placed anywhere: + * + *
    + *
  • At a method / constructor parameter + *
  • At a field + *
  • At a method / constructor on the class + *
  • At the test class + *
+ */ + static TestMethodProcessor forAllAnnotationPlacements(TestClass testClass) { + return new TestParameterAnnotationMethodProcessor( + testClass, /* onlyForFieldsAndParameters= */ false); + } + + /** + * Constructs a new {@link TestMethodProcessor} that handles {@link + * TestParameterAnnotation}-annotated annotations that are placed at fields or parameters. + * + *

Note that this excludes class and method-level annotations, as is the default (using the + * constructor). + */ + static TestMethodProcessor onlyForFieldsAndParameters(TestClass testClass) { + return new TestParameterAnnotationMethodProcessor( + testClass, /* onlyForFieldsAndParameters= */ true); + } + + private ImmutableList getAnnotationTypeOrigins( + Origin firstOrigin, Origin... otherOrigins) { + if (cachedAnnotationTypeOrigins == null) { + // Collect all annotations used in declared fields and methods that have themselves a + // @TestParameterAnnotation annotation. + List fieldAnnotations = + extractTestParameterAnnotations( + streamWithParents(testClass.getJavaClass()) + .flatMap(c -> stream(c.getDeclaredFields())) + .flatMap(field -> stream(field.getAnnotations())), + Origin.FIELD); + List methodAnnotations = + extractTestParameterAnnotations( + stream(testClass.getJavaClass().getMethods()) + .flatMap(method -> stream(method.getAnnotations())), + Origin.METHOD); + List parameterAnnotations = + extractTestParameterAnnotations( + stream(testClass.getJavaClass().getMethods()) + .flatMap(method -> stream(method.getParameterAnnotations()).flatMap(Stream::of)), + Origin.METHOD_PARAMETER); + List classAnnotations = + extractTestParameterAnnotations( + stream(testClass.getJavaClass().getAnnotations()), Origin.CLASS); + List constructorAnnotations = + extractTestParameterAnnotations( + stream(testClass.getJavaClass().getConstructors()) + .flatMap(constructor -> stream(constructor.getAnnotations())), + Origin.CONSTRUCTOR); + List constructorParameterAnnotations = + extractTestParameterAnnotations( + stream(testClass.getJavaClass().getConstructors()) + .flatMap( + constructor -> + stream(constructor.getParameterAnnotations()).flatMap(Stream::of)), + Origin.CONSTRUCTOR_PARAMETER); + + checkDuplicatedClassAndFieldAnnotations( + constructorAnnotations, classAnnotations, fieldAnnotations); + + checkDuplicatedFieldsAnnotations(methodAnnotations, fieldAnnotations); + + checkState( + constructorAnnotations.stream().distinct().count() == constructorAnnotations.size(), + "Annotations should not be duplicated on the constructor."); + + checkState( + classAnnotations.stream().distinct().count() == classAnnotations.size(), + "Annotations should not be duplicated on the class."); + + if (onlyForFieldsAndParameters) { + checkState( + methodAnnotations.isEmpty(), + "This test runner (constructed by the testparameterinjector package) was configured" + + " to disallow method-level annotations that could be field/parameter" + + " annotations, but found %s", + methodAnnotations); + checkState( + classAnnotations.isEmpty(), + "This test runner (constructed by the testparameterinjector package) was configured" + + " to disallow class-level annotations that could be field/parameter annotations," + + " but found %s", + classAnnotations); + checkState( + constructorAnnotations.isEmpty(), + "This test runner (constructed by the testparameterinjector package) was configured" + + " to disallow constructor-level annotations that could be field/parameter" + + " annotations, but found %s", + constructorAnnotations); + } + + cachedAnnotationTypeOrigins = + Stream.of( + // The order matters, since it will determine which annotation processor is + // called first. + classAnnotations.stream(), + fieldAnnotations.stream(), + constructorAnnotations.stream(), + constructorParameterAnnotations.stream(), + methodAnnotations.stream(), + parameterAnnotations.stream()) + .flatMap(x -> x) + .distinct() + .collect(toImmutableList()); + } + + Set originsToFilterBy = + ImmutableSet.builder().add(firstOrigin).add(otherOrigins).build(); + return cachedAnnotationTypeOrigins.stream() + .filter(annotationTypeOrigin -> originsToFilterBy.contains(annotationTypeOrigin.origin())) + .collect(toImmutableList()); + } + + private void checkDuplicatedFieldsAnnotations( + List methodAnnotations, List fieldAnnotations) { + // If an annotation is duplicated on two fields, then it becomes specific, and cannot be + // overridden by a method. + if (fieldAnnotations.stream().distinct().count() != fieldAnnotations.size()) { + List> methodOrFieldAnnotations = + Stream.concat(methodAnnotations.stream(), fieldAnnotations.stream().distinct()) + .map(AnnotationTypeOrigin::annotationType) + .collect(toCollection(ArrayList::new)); + + checkState( + methodOrFieldAnnotations.stream().distinct().count() == methodOrFieldAnnotations.size(), + "Annotations should not be duplicated on a method and field" + + " if they are present on multiple fields"); + } + } + + private void checkDuplicatedClassAndFieldAnnotations( + List constructorAnnotations, + List classAnnotations, + List fieldAnnotations) { + ImmutableSet> classAnnotationTypes = + classAnnotations.stream() + .map(AnnotationTypeOrigin::annotationType) + .collect(toImmutableSet()); + + ImmutableSet> uniqueFieldAnnotations = + fieldAnnotations.stream() + .map(AnnotationTypeOrigin::annotationType) + .collect(toImmutableSet()); + ImmutableSet> uniqueConstructorAnnotations = + constructorAnnotations.stream() + .map(AnnotationTypeOrigin::annotationType) + .collect(toImmutableSet()); + + checkState( + Collections.disjoint(classAnnotationTypes, uniqueFieldAnnotations), + "Annotations should not be duplicated on a class and field"); + + checkState( + Collections.disjoint(classAnnotationTypes, uniqueConstructorAnnotations), + "Annotations should not be duplicated on a class and constructor"); + + checkState( + Collections.disjoint(uniqueConstructorAnnotations, uniqueFieldAnnotations), + "Annotations should not be duplicated on a field and constructor"); + } + + /** Returns a list of annotation types that are a {@link TestParameterAnnotation}. */ + private List extractTestParameterAnnotations( + Stream annotations, Origin origin) { + return annotations + .map(Annotation::annotationType) + .filter(annotationType -> annotationType.isAnnotationPresent(TestParameterAnnotation.class)) + .map(annotationType -> AnnotationTypeOrigin.create(annotationType, origin)) + .collect(toCollection(ArrayList::new)); + } + + @Override + public ValidationResult validateConstructor(TestClass testClass, List errorsReturned) { + if (testClass.getJavaClass().getConstructors().length != 1) { + errorsReturned.add( + new IllegalStateException("Test class should have exactly one public constructor")); + return ValidationResult.HANDLED; + } + Constructor constructor = testClass.getOnlyConstructor(); + Class[] parameterTypes = constructor.getParameterTypes(); + if (parameterTypes.length == 0) { + return ValidationResult.NOT_HANDLED; + } + // The constructor has parameters, they must be injected by a TestParameterAnnotation + // annotation. + Annotation[][] parameterAnnotations = constructor.getParameterAnnotations(); + validateMethodOrConstructorParameters( + removeOverrides( + getAnnotationTypeOrigins( + Origin.CLASS, Origin.CONSTRUCTOR, Origin.CONSTRUCTOR_PARAMETER), + testClass.getJavaClass()), + testClass, + errorsReturned, + constructor, + parameterTypes, + parameterAnnotations); + + return ValidationResult.HANDLED; + } + + @Override + public ValidationResult validateTestMethod( + TestClass testClass, FrameworkMethod testMethod, List errorsReturned) { + Class[] methodParameterTypes = testMethod.getMethod().getParameterTypes(); + if (methodParameterTypes.length == 0) { + return ValidationResult.NOT_HANDLED; + } else { + Method method = testMethod.getMethod(); + // The method has parameters, they must be injected by a TestParameterAnnotation annotation. + testMethod.validatePublicVoid(false /* isStatic */, errorsReturned); + Annotation[][] parametersAnnotations = method.getParameterAnnotations(); + validateMethodOrConstructorParameters( + getAnnotationTypeOrigins(Origin.CLASS, Origin.METHOD, Origin.METHOD_PARAMETER), + testClass, + errorsReturned, + method, + methodParameterTypes, + parametersAnnotations); + return ValidationResult.HANDLED; + } + } + + private void validateMethodOrConstructorParameters( + List annotationTypeOrigins, + TestClass testClass, + List errors, + AnnotatedElement methodOrConstructor, + Class[] parameterTypes, + Annotation[][] parametersAnnotations) { + for (int parameterIndex = 0; parameterIndex < parameterTypes.length; parameterIndex++) { + Class parameterType = parameterTypes[parameterIndex]; + Annotation[] parameterAnnotations = parametersAnnotations[parameterIndex]; + boolean matchingTestParameterAnnotationFound = false; + // First, handle the case where the method parameter specifies the test parameter explicitly, + // e.g. {@code public void test(@ColorParameter({...}) Color c)}. + for (AnnotationTypeOrigin testParameterAnnotationType : annotationTypeOrigins) { + for (Annotation parameterAnnotation : parameterAnnotations) { + if (parameterAnnotation + .annotationType() + .equals(testParameterAnnotationType.annotationType())) { + // Verify that the type is assignable with the return type of the 'value' method. + Class valueMethodReturnType = + getValueMethodReturnType( + testParameterAnnotationType.annotationType(), + /* paramClass = */ Optional.of(parameterType)); + if (!parameterType.isAssignableFrom(valueMethodReturnType)) { + errors.add( + new IllegalStateException( + String.format( + "Parameter of type %s annotated with %s does not match" + + " expected type %s in method/constructor %s", + parameterType.getName(), + testParameterAnnotationType.annotationType().getName(), + valueMethodReturnType.getName(), + methodOrConstructor))); + } else { + matchingTestParameterAnnotationFound = true; + } + } + } + } + // Second, handle the case where the method parameter does not specify the test parameter, + // and instead relies on the type matching, e.g. {@code public void test(Color c)}. + if (!matchingTestParameterAnnotationFound) { + List> testParameterAnnotationTypes = + getTestParameterAnnotations( + // Do not include METHOD_PARAMETER or CONSTRUCTOR_PARAMETER since they have already + // been evaluated. + filterAnnotationTypeOriginsByOrigin( + annotationTypeOrigins, Origin.CLASS, Origin.CONSTRUCTOR, Origin.METHOD), + testClass.getJavaClass(), + methodOrConstructor); + // If no annotation is present, simply compare the type. + for (Class testParameterAnnotationType : + testParameterAnnotationTypes) { + if (parameterType.isAssignableFrom( + getValueMethodReturnType( + testParameterAnnotationType, /* paramClass = */ Optional.absent()))) { + if (matchingTestParameterAnnotationFound) { + errors.add( + new IllegalStateException( + String.format( + "Ambiguous method/constructor parameter type, matching multiple" + + " annotations for parameter of type %s in method %s", + parameterType.getName(), methodOrConstructor))); + } + matchingTestParameterAnnotationFound = true; + } + } + } + if (!matchingTestParameterAnnotationFound) { + errors.add( + new IllegalStateException( + String.format( + "No matching test parameter annotation found" + + " for parameter of type %s in method/constructor %s", + parameterType.getName(), methodOrConstructor))); + } + } + } + + @Override + public Optional createStatement( + TestClass testClass, + FrameworkMethod frameworkMethod, + Object testObject, + Optional statement) { + if (frameworkMethod.getAnnotation(TestIndexHolder.class) == null + // Explicitly skip @TestParameters annotated methods to ensure compatibility. + // + // Reason (see b/175678220): @TestIndexHolder will even be present when the only (supported) + // parameterization is at the field level (e.g. @TestParameter private TestEnum enum;). + // Without the @TestParameters check below, InvokeParameterizedMethod would be invoked for + // these methods. When there are no method parameters, this is a no-op, but when the method + // is annotated with @TestParameters, this throws an exception (because there are method + // parameters that this processor has no values for - they are provided by the + // @TestParameters processor). + || frameworkMethod.getAnnotation(TestParameters.class) != null) { + return statement; + } else { + return Optional.of(new InvokeParameterizedMethod(frameworkMethod, testObject)); + } + } + + /** + * Returns the {@link TestInfo}, one for each result of the cartesian product of each test + * parameter values. + * + *

For example, given the annotation {@code @ColorParameter({BLUE, WHITE, RED})} on a method, + * it method will return the TestParameterValues: "(@ColorParameter, BLUE), (@ColorParameter, + * WHITE), (@ColorParameter, RED)}). + * + *

For multiple annotations (say, {@code @TestParameter("foo", "bar")} and + * {@code @ColorParameter({BLUE, WHITE})}), it will generate the following result: + * + *

    + *
  • ("foo", BLUE) + *
  • ("foo", WHITE) + *
  • ("bar", BLUE) + *
  • ("bar", WHITE) + *
  • + *
+ * + * corresponding to the cartesian product of both annotations. + */ + @Override + public List processTest(Class testClass, TestInfo originalTest) { + List> parameterValuesForMethod = + getParameterValuesForMethod(originalTest.getMethod()); + + if (parameterValuesForMethod.equals(ImmutableList.of(ImmutableList.of()))) { + // This test is not parameterized + return ImmutableList.of(originalTest); + } + + ImmutableList.Builder testInfos = ImmutableList.builder(); + for (int parametersIndex = 0; + parametersIndex < parameterValuesForMethod.size(); + ++parametersIndex) { + List testParameterValues = parameterValuesForMethod.get(parametersIndex); + testInfos.add( + originalTest + .withExtraParameters( + testParameterValues.stream() + .map( + param -> + TestInfoParameter.create( + param.toTestNameString(), param.value(), param.valueIndex())) + .collect(toImmutableList())) + .withExtraAnnotation( + TestIndexHolderFactory.create( + /* methodIndex= */ strictIndexOf( + getMethodsIncludingParents(testClass), originalTest.getMethod()), + parametersIndex, + testClass.getName()))); + } + + return testInfos.build(); + } + + private List> getParameterValuesForMethod(Method method) { + try { + return parameterValuesCache.get( + method, + () -> { + List> testParameterValuesList = + getAnnotationValuesForUsedAnnotationTypes(testClass.getJavaClass(), method); + + return Lists.cartesianProduct(testParameterValuesList).stream() + .filter( + // Skip tests based on the annotations' {@link Validator#shouldSkip} return + // value. + testParameterValues -> + testParameterValues.stream() + .noneMatch( + testParameterValue -> + callShouldSkip( + testParameterValue.annotationTypeOrigin().annotationType(), + testParameterValues))) + .collect(toImmutableList()); + }); + } catch (ExecutionException | UncheckedExecutionException e) { + Throwables.throwIfUnchecked(e.getCause()); + throw new RuntimeException(e); + } + } + + private List getParameterValuesForTest(TestIndexHolder testIndexHolder) { + verify( + testIndexHolder.testClassName().equals(testClass.getName()), + "The class for which the given annotation was created (%s) is not the same as the test" + + " class that this runner is handling (%s)", + testIndexHolder.testClassName(), + testClass.getName()); + Method testMethod = + getMethodsIncludingParents(testClass.getJavaClass()).get(testIndexHolder.methodIndex()); + return getParameterValuesForMethod(testMethod).get(testIndexHolder.parametersIndex()); + } + + /** + * Returns the list of annotation index for all annotations defined in a given test method and its + * class. + */ + private ImmutableList> getAnnotationValuesForUsedAnnotationTypes( + Class testClass, Method method) { + ImmutableList annotationTypes = + Stream.of( + getAnnotationTypeOrigins(Origin.CLASS).stream(), + getAnnotationTypeOrigins(Origin.FIELD).stream(), + getAnnotationTypeOrigins(Origin.CONSTRUCTOR).stream(), + getAnnotationTypeOrigins(Origin.CONSTRUCTOR_PARAMETER).stream(), + getAnnotationTypeOrigins(Origin.METHOD).stream(), + getAnnotationTypeOrigins(Origin.METHOD_PARAMETER).stream() + .sorted(annotationComparator(method.getParameterAnnotations()))) + .flatMap(x -> x) + .collect(toImmutableList()); + + return removeOverrides(annotationTypes, testClass, method).stream() + .map( + annotationTypeOrigin -> + getAnnotationFromParametersOrTestOrClass(annotationTypeOrigin, method, testClass)) + .filter(l -> !l.isEmpty()) + .flatMap(List::stream) + .collect(toImmutableList()); + } + + private Comparator annotationComparator( + Annotation[][] parameterAnnotations) { + ImmutableList annotationOrdering = + stream(parameterAnnotations) + .flatMap(Arrays::stream) + .map(Annotation::annotationType) + .map(Class::getName) + .collect(toImmutableList()); + return Comparator.comparingInt(o -> annotationOrdering.indexOf(o.annotationType().getName())); + } + + /** + * Returns a list of {@link AnnotationTypeOrigin} where the overridden annotation are removed for + * the current {@code originalTest} and {@code testClass}. + * + *

Specifically, annotation defined on CLASS and FIELD elements will be removed if they are + * also defined on the method, method parameter, constructor, or constructor parameters. + */ + private List removeOverrides( + List annotationTypeOrigins, Class testClass, Method method) { + return removeOverrides( + annotationTypeOrigins.stream() + .filter( + annotationTypeOrigin -> { + switch (annotationTypeOrigin.origin()) { + case FIELD: // Fall through. + case CLASS: + return getAnnotationListWithType( + method.getAnnotations(), annotationTypeOrigin.annotationType()) + .isEmpty(); + default: + return true; + } + }) + .collect(toCollection(ArrayList::new)), + testClass); + } + + /** @see #removeOverrides(List, Class) */ + private List removeOverrides( + List annotationTypeOrigins, Class testClass) { + return annotationTypeOrigins.stream() + .filter( + annotationTypeOrigin -> { + switch (annotationTypeOrigin.origin()) { + case FIELD: // Fall through. + case CLASS: + return getAnnotationListWithType( + getOnlyConstructor(testClass).getAnnotations(), + annotationTypeOrigin.annotationType()) + .isEmpty() + && getAnnotationListWithType( + getOnlyConstructor(testClass).getParameterAnnotations(), + annotationTypeOrigin.annotationType()) + .isEmpty(); + default: + return true; + } + }) + .collect(toCollection(ArrayList::new)); + } + + /** + * Returns the given annotations defined either on the method parameters, method or the test + * class. + * + *

The annotation from the parameters takes precedence over the same annotation defined on the + * method, and the one defined on the method takes precedence over the same annotation defined on + * the class. + */ + private ImmutableList> getAnnotationFromParametersOrTestOrClass( + AnnotationTypeOrigin annotationTypeOrigin, Method method, Class testClass) { + Origin origin = annotationTypeOrigin.origin(); + Class annotationType = annotationTypeOrigin.annotationType(); + if (origin == Origin.CONSTRUCTOR_PARAMETER) { + Constructor constructor = getOnlyConstructor(testClass); + List annotations = + getAnnotationWithMetadataListWithType(constructor, annotationType); + + if (!annotations.isEmpty()) { + return toTestParameterValueList(annotations, origin); + } + } else if (origin == Origin.CONSTRUCTOR) { + Annotation annotation = getOnlyConstructor(testClass).getAnnotation(annotationType); + if (annotation != null) { + return ImmutableList.of( + TestParameterValue.create(AnnotationWithMetadata.withoutMetadata(annotation), origin)); + } + + } else if (origin == Origin.METHOD_PARAMETER) { + List annotations = + getAnnotationWithMetadataListWithType(method, annotationType); + if (!annotations.isEmpty()) { + return toTestParameterValueList(annotations, origin); + } + } else if (origin == Origin.METHOD) { + if (method.isAnnotationPresent(annotationType)) { + return ImmutableList.of( + TestParameterValue.create( + AnnotationWithMetadata.withoutMetadata(method.getAnnotation(annotationType)), + origin)); + } + } else if (origin == Origin.FIELD) { + List annotations = + streamWithParents(testClass) + .flatMap(c -> stream(c.getDeclaredFields())) + .flatMap( + field -> + getAnnotationListWithType(field.getAnnotations(), annotationType).stream() + .map( + annotation -> + AnnotationWithMetadata.withMetadata( + annotation, field.getType(), field.getName()))) + .collect(toCollection(ArrayList::new)); + if (!annotations.isEmpty()) { + return toTestParameterValueList(annotations, origin); + } + } else if (origin == Origin.CLASS) { + Annotation annotation = testClass.getAnnotation(annotationType); + if (annotation != null) { + return ImmutableList.of( + TestParameterValue.create(AnnotationWithMetadata.withoutMetadata(annotation), origin)); + } + } + return ImmutableList.of(); + } + + private static ImmutableList> toTestParameterValueList( + List annotationWithMetadatas, Origin origin) { + return annotationWithMetadatas.stream() + .map(annotationWithMetadata -> TestParameterValue.create(annotationWithMetadata, origin)) + .collect(toImmutableList()); + } + + private static ImmutableList getAnnotationWithMetadataListWithType( + Method callable, Class annotationType) { + try { + return getAnnotationWithMetadataListWithType(callable.getParameters(), annotationType); + } catch (NoSuchMethodError ignored) { + return getAnnotationWithMetadataListWithType( + callable.getParameterTypes(), callable.getParameterAnnotations(), annotationType); + } + } + + private static ImmutableList getAnnotationWithMetadataListWithType( + Constructor callable, Class annotationType) { + try { + return getAnnotationWithMetadataListWithType(callable.getParameters(), annotationType); + } catch (NoSuchMethodError ignored) { + return getAnnotationWithMetadataListWithType( + callable.getParameterTypes(), callable.getParameterAnnotations(), annotationType); + } + } + + // Parameter is not available on old Android SDKs, and isn't desugared. That's why this method + // has a fallback that takes the parameter types and annotations (without the parameter names, + // which are optional anyway). + @SuppressWarnings("AndroidJdkLibsChecker") + private static ImmutableList getAnnotationWithMetadataListWithType( + Parameter[] parameters, Class annotationType) { + return stream(parameters) + .map( + parameter -> { + Annotation annotation = parameter.getAnnotation(annotationType); + return annotation == null + ? null + : parameter.isNamePresent() + ? AnnotationWithMetadata.withMetadata( + annotation, parameter.getType(), parameter.getName()) + : AnnotationWithMetadata.withMetadata(annotation, parameter.getType()); + }) + .filter(Objects::nonNull) + .collect(toImmutableList()); + } + + private static ImmutableList getAnnotationWithMetadataListWithType( + Class[] parameterTypes, + Annotation[][] annotations, + Class annotationType) { + checkArgument(parameterTypes.length == annotations.length); + + ImmutableList.Builder resultBuilder = ImmutableList.builder(); + for (int i = 0; i < annotations.length; i++) { + for (Annotation annotation : annotations[i]) { + if (annotation.annotationType().equals(annotationType)) { + resultBuilder.add(AnnotationWithMetadata.withMetadata(annotation, parameterTypes[i])); + } + } + } + return resultBuilder.build(); + } + + private ImmutableList getAnnotationListWithType( + Annotation[][] parameterAnnotations, Class annotationType) { + return stream(parameterAnnotations) + .flatMap(Stream::of) + .filter(annotation -> annotation.annotationType().equals(annotationType)) + .collect(toImmutableList()); + } + + private ImmutableList getAnnotationListWithType( + Annotation[] annotations, Class annotationType) { + return stream(annotations) + .filter(annotation -> annotation.annotationType().equals(annotationType)) + .collect(toImmutableList()); + } + + private static Constructor getOnlyConstructor(Class testClass) { + Constructor[] constructors = testClass.getConstructors(); + checkState( + constructors.length == 1, + "a single public constructor is required for class %s", + testClass); + return constructors[0]; + } + + @Override + public Optional createTest( + TestClass testClass, FrameworkMethod method, Optional test) { + TestIndexHolder testIndexHolder = method.getAnnotation(TestIndexHolder.class); + if (testIndexHolder == null) { + return test; + } + try { + List testParameterValues = getParameterValuesForTest(testIndexHolder); + + Object testObject; + if (test.isPresent()) { + testObject = test.get(); + } else { + Constructor constructor = testClass.getOnlyConstructor(); + Class[] parameterTypes = constructor.getParameterTypes(); + if (parameterTypes.length == 0) { + testObject = constructor.newInstance(); + } else { + // The constructor has parameters, they must be injected by a TestParameterAnnotation + // annotation. + Annotation[][] parameterAnnotations = constructor.getParameterAnnotations(); + Object[] arguments = new Object[parameterTypes.length]; + List> processedAnnotationTypes = new ArrayList<>(); + List parameterValuesForConstructor = + filterByOrigin( + testParameterValues, + Origin.CLASS, + Origin.CONSTRUCTOR, + Origin.CONSTRUCTOR_PARAMETER); + for (int i = 0; i < arguments.length; i++) { + // Initialize each parameter value from the corresponding TestParameterAnnotation value. + arguments[i] = + getParameterValue( + parameterValuesForConstructor, + parameterTypes[i], + parameterAnnotations[i], + processedAnnotationTypes); + } + testObject = constructor.newInstance(arguments); + } + } + // Do not include {@link Origin#METHOD_PARAMETER} nor {@link Origin#CONSTRUCTOR_PARAMETER} + // annotations. + List testParameterValuesForFieldInjection = + filterByOrigin(testParameterValues, Origin.CLASS, Origin.FIELD, Origin.METHOD); + // The annotationType corresponding to the annotationIndex, e.g ColorParameter.class + // in the example above. + List remainingTestParameterValuesForFieldInjection = + new ArrayList<>(testParameterValuesForFieldInjection); + for (Field declaredField : + streamWithParents(testObject.getClass()) + .flatMap(c -> stream(c.getDeclaredFields())) + .collect(toImmutableList())) { + for (TestParameterValue testParameterValue : + remainingTestParameterValuesForFieldInjection) { + if (declaredField.isAnnotationPresent( + testParameterValue.annotationTypeOrigin().annotationType())) { + declaredField.setAccessible(true); + declaredField.set(testObject, testParameterValue.value()); + remainingTestParameterValuesForFieldInjection.remove(testParameterValue); + break; + } + } + } + return Optional.of(testObject); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * Returns an {@link TestParameterValue} list that contains only the values originating from one + * of the {@code origins}. + */ + private static ImmutableList filterByOrigin( + List testParameterValues, Origin... origins) { + Set originsToFilterBy = ImmutableSet.copyOf(origins); + return testParameterValues.stream() + .filter( + testParameterValue -> + originsToFilterBy.contains(testParameterValue.annotationTypeOrigin().origin())) + .collect(toImmutableList()); + } + + /** + * Returns an {@link AnnotationTypeOrigin} list that contains only the values originating from one + * of the {@code origins}. + */ + private static ImmutableList filterAnnotationTypeOriginsByOrigin( + List annotationTypeOrigins, Origin... origins) { + List originList = Arrays.asList(origins); + return annotationTypeOrigins.stream() + .filter(annotationTypeOrigin -> originList.contains(annotationTypeOrigin.origin())) + .collect(toImmutableList()); + } + + @Override + public Statement processStatement(Statement originalStatement, Description finalTestDescription) { + TestIndexHolder testIndexHolder = finalTestDescription.getAnnotation(TestIndexHolder.class); + if (testIndexHolder == null) { + return originalStatement; + } + List testParameterValues = getParameterValuesForTest(testIndexHolder); + + return new Statement() { + @Override + public void evaluate() throws Throwable { + for (TestParameterValue testParameterValue : testParameterValues) { + callBefore( + testParameterValue.annotationTypeOrigin().annotationType(), + testParameterValue.value()); + } + try { + originalStatement.evaluate(); + } finally { + // In reverse order. + for (TestParameterValue testParameterValue : Lists.reverse(testParameterValues)) { + callAfter( + testParameterValue.annotationTypeOrigin().annotationType(), + testParameterValue.value()); + } + } + } + }; + } + + /** + * Class to invoke the test method if it has parameters, and they need to be injected from the + * TestParameterAnnotation values. + */ + private class InvokeParameterizedMethod extends Statement { + + private final FrameworkMethod frameworkMethod; + private final Object testObject; + private final List testParameterValues; + + public InvokeParameterizedMethod(FrameworkMethod frameworkMethod, Object testObject) { + this.frameworkMethod = frameworkMethod; + this.testObject = testObject; + TestIndexHolder testIndexHolder = frameworkMethod.getAnnotation(TestIndexHolder.class); + checkState(testIndexHolder != null); + testParameterValues = + filterByOrigin( + getParameterValuesForTest(testIndexHolder), + Origin.CLASS, + Origin.METHOD, + Origin.METHOD_PARAMETER); + } + + @Override + public void evaluate() throws Throwable { + Class[] parameterTypes = frameworkMethod.getMethod().getParameterTypes(); + Annotation[][] parametersAnnotations = frameworkMethod.getMethod().getParameterAnnotations(); + Object[] parameterValues = new Object[parameterTypes.length]; + + List> processedAnnotationTypes = new ArrayList<>(); + // Initialize each parameter value from the corresponding TestParameterAnnotation value. + for (int i = 0; i < parameterTypes.length; i++) { + parameterValues[i] = + getParameterValue( + testParameterValues, + parameterTypes[i], + parametersAnnotations[i], + processedAnnotationTypes); + } + frameworkMethod.invokeExplosively(testObject, parameterValues); + } + } + + /** Returns a {@link TestParameterAnnotation}'s value for a method or constructor parameter. */ + private Object getParameterValue( + List testParameterValues, + Class methodParameterType, + Annotation[] parameterAnnotations, + List> processedAnnotationTypes) { + List> iteratedAnnotationTypes = new ArrayList<>(); + for (TestParameterValue testParameterValue : testParameterValues) { + // The annotationType corresponding to the annotationIndex, e.g ColorParameter.class + // in the example above. + for (Annotation parameterAnnotation : parameterAnnotations) { + Class annotationType = + testParameterValue.annotationTypeOrigin().annotationType(); + if (parameterAnnotation.annotationType().equals(annotationType)) { + // If multiple annotations exist, ensure that the proper one is selected. + // For instance, for: + // + // test(@FooParameter(1,2) Foo foo, @FooParameter(3,4) Foo bar) {} + // + // Verifies that the correct @FooParameter annotation value will be assigned to the + // corresponding variable. + if (Collections.frequency(processedAnnotationTypes, annotationType) + == Collections.frequency(iteratedAnnotationTypes, annotationType)) { + processedAnnotationTypes.add(annotationType); + return testParameterValue.value(); + } + iteratedAnnotationTypes.add(annotationType); + } + } + } + // If no annotation matches, use the method parameter type. + for (TestParameterValue testParameterValue : testParameterValues) { + // The annotationType corresponding to the annotationIndex, e.g ColorParameter.class + // in the example above. + if (methodParameterType.isAssignableFrom( + getValueMethodReturnType( + testParameterValue.annotationTypeOrigin().annotationType(), + /* paramClass = */ Optional.absent()))) { + return testParameterValue.value(); + } + } + throw new IllegalStateException( + "The method parameter should have matched a TestParameterAnnotation"); + } + + /** + * This mechanism is a workaround to be able to store the annotation values in the annotation list + * of the {@link TestInfo}, since we cannot carry other information through the test runner. + */ + @Retention(RUNTIME) + @interface TestIndexHolder { + + /** The index of the test method in {@code getMethodsIncludingParents(testClass)} */ + int methodIndex(); + + /** + * The index of the set of parameters to run the test method with in the list produced by {@link + * #getParameterValuesForMethod(Method)}. + */ + int parametersIndex(); + + /** + * The full name of the test class. Only used for verifying that assumptions about the above + * indices are valid. + */ + String testClassName(); + } + + /** Factory for {@link TestIndexHolder}. */ + static class TestIndexHolderFactory { + @AutoAnnotation + static TestIndexHolder create(int methodIndex, int parametersIndex, String testClassName) { + return new AutoAnnotation_TestParameterAnnotationMethodProcessor_TestIndexHolderFactory_create( + methodIndex, parametersIndex, testClassName); + } + + private TestIndexHolderFactory() {} + } + + /** Invokes the {@link TestParameterProcessor#before} method of an annotation. */ + private static void callBefore( + Class annotationType, Object annotationValue) { + TestParameterAnnotation annotation = + annotationType.getAnnotation(TestParameterAnnotation.class); + Class processor = annotation.processor(); + try { + processor.getConstructor().newInstance().before(annotationValue); + } catch (Exception e) { + throw new RuntimeException("Unexpected exception while invoking processor " + processor, e); + } + } + + /** Invokes the {@link TestParameterProcessor#after} method of an annotation. */ + private static void callAfter( + Class annotationType, Object annotationValue) { + TestParameterAnnotation annotation = + annotationType.getAnnotation(TestParameterAnnotation.class); + Class processor = annotation.processor(); + try { + processor.getConstructor().newInstance().after(annotationValue); + } catch (Exception e) { + throw new RuntimeException("Unexpected exception while invoking processor " + processor, e); + } + } + + /** + * Returns whether the test should be skipped according to the {@code annotationType}'s {@link + * TestParameterValidator} and the current list of {@link TestParameterValue}. + */ + private static boolean callShouldSkip( + Class annotationType, List testParameterValues) { + TestParameterAnnotation annotation = + annotationType.getAnnotation(TestParameterAnnotation.class); + Class validator = annotation.validator(); + try { + return validator + .getConstructor() + .newInstance() + .shouldSkip(new ValidatorContext(testParameterValues)); + } catch (Exception e) { + throw new RuntimeException("Unexpected exception while invoking validator " + validator, e); + } + } + + private static class ValidatorContext implements TestParameterValidator.Context { + + private final List testParameterValues; + private final Set valueList; + + public ValidatorContext(List testParameterValues) { + this.testParameterValues = testParameterValues; + this.valueList = testParameterValues.stream().map(TestParameterValue::value).collect(toSet()); + } + + @Override + public boolean has(Class testParameter, Object value) { + return getValue(testParameter).transform(value::equals).or(false); + } + + @Override + public , U extends Enum> boolean has(T value1, U value2) { + return valueList.contains(value1) && valueList.contains(value2); + } + + @Override + public Optional getValue(Class testParameter) { + return getParameter(testParameter).transform(TestParameterValue::value); + } + + @Override + public List getSpecifiedValues(Class testParameter) { + return getParameter(testParameter) + .transform(TestParameterValue::specifiedValues) + .or(ImmutableList.of()); + } + + private Optional getParameter(Class testParameter) { + return Optional.fromNullable( + testParameterValues.stream() + .filter(value -> value.annotationTypeOrigin().annotationType().equals(testParameter)) + .findAny() + .orElse(null)); + } + } + + /** + * Returns the class of the list elements returned by {@code provideValues()}. + * + * @param annotationType The type of the annotation that was encountered in the test class. The + * definition of this annotation is itself annotated with the {@link TestParameterAnnotation} + * annotation. + * @param paramClass The class of the parameter or field that is being annotated. In case the + * annotation is annotating a method, constructor or class, {@code paramClass} is an absent + * optional. + */ + private static Class getValueMethodReturnType( + Class annotationType, Optional> paramClass) { + TestParameterAnnotation testParameter = + annotationType.getAnnotation(TestParameterAnnotation.class); + Class valueProvider = testParameter.valueProvider(); + try { + return valueProvider + .getConstructor() + .newInstance() + .getValueType(annotationType, java.util.Optional.ofNullable(paramClass.orNull())); + } catch (Exception e) { + throw new RuntimeException( + "Unexpected exception while invoking value provider " + valueProvider, e); + } + } + + /** Returns the TestParameterAnnotation annotation types defined for a method or constructor. */ + private ImmutableList> getTestParameterAnnotations( + List annotationTypeOrigins, + final Class testClass, + AnnotatedElement methodOrConstructor) { + return annotationTypeOrigins.stream() + .map(AnnotationTypeOrigin::annotationType) + .filter( + annotationType -> + testClass.isAnnotationPresent(annotationType) + || methodOrConstructor.isAnnotationPresent(annotationType)) + .collect(toImmutableList()); + } + + private int strictIndexOf(List haystack, T needle) { + int index = haystack.indexOf(needle); + checkArgument(index >= 0, "Could not find '%s' in %s", needle, haystack); + return index; + } + + private ImmutableList getMethodsIncludingParents(Class clazz) { + ImmutableList.Builder resultBuilder = ImmutableList.builder(); + while (clazz != null) { + resultBuilder.add(clazz.getMethods()); + clazz = clazz.getSuperclass(); + } + return resultBuilder.build(); + } + + private static Stream> streamWithParents(Class clazz) { + Stream.Builder> resultBuilder = Stream.builder(); + + Class currentClass = clazz; + while (currentClass != null) { + resultBuilder.add(currentClass); + currentClass = currentClass.getSuperclass(); + } + + return resultBuilder.build(); + } + + // Immutable collectors are re-implemented here because they are missing from the Android + // collection library. + private static Collector> toImmutableList() { + return Collectors.collectingAndThen(Collectors.toList(), ImmutableList::copyOf); + } + + private static Collector> toImmutableSet() { + return Collectors.collectingAndThen(Collectors.toList(), ImmutableSet::copyOf); + } +} diff --git a/src/main/java/com/google/testing/junit/testparameterinjector/TestParameterInjector.java b/src/main/java/com/google/testing/junit/testparameterinjector/TestParameterInjector.java new file mode 100644 index 0000000..dd6c63f --- /dev/null +++ b/src/main/java/com/google/testing/junit/testparameterinjector/TestParameterInjector.java @@ -0,0 +1,36 @@ +/* + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.testing.junit.testparameterinjector; + +import java.util.List; +import org.junit.runners.model.InitializationError; + +/** + * A JUnit test runner which knows how to instantiate and run test classes where each test case may + * be parameterized with its own unique set of test parameters (as opposed to {@link + * org.junit.runners.Parameterized} where each test case in a test class is invoked with the exact + * same set of parameters). + */ +public final class TestParameterInjector extends PluggableTestRunner { + + public TestParameterInjector(Class testClass) throws InitializationError { + super(testClass); + } + + @Override + protected List createTestMethodProcessorList() { + return TestMethodProcessors.createNewParameterizedProcessorsWithLegacyFeatures(getTestClass()); + } +} diff --git a/src/main/java/com/google/testing/junit/testparameterinjector/TestParameterProcessor.java b/src/main/java/com/google/testing/junit/testparameterinjector/TestParameterProcessor.java new file mode 100644 index 0000000..efa4951 --- /dev/null +++ b/src/main/java/com/google/testing/junit/testparameterinjector/TestParameterProcessor.java @@ -0,0 +1,31 @@ +/* + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.testing.junit.testparameterinjector; + +/** + * Interface which allows {@link TestParameterAnnotation} annotations to run arbitrary code before + * and after test execution. + * + *

When multiple TestParameterAnnotation processors exist for a single test, they are executed in + * declaration order, starting with annotations defined at the class, field, method, and finally + * parameter level. + */ +interface TestParameterProcessor { + /** Executes code in the context of a running test statement before the statement starts. */ + void before(Object testParameterValue); + + /** Executes code in the context of a running test statement after the statement completes. */ + void after(Object testParameterValue); +} diff --git a/src/main/java/com/google/testing/junit/testparameterinjector/TestParameterValidator.java b/src/main/java/com/google/testing/junit/testparameterinjector/TestParameterValidator.java new file mode 100644 index 0000000..3733833 --- /dev/null +++ b/src/main/java/com/google/testing/junit/testparameterinjector/TestParameterValidator.java @@ -0,0 +1,68 @@ +/* + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.testing.junit.testparameterinjector; + +import com.google.common.base.Optional; +import java.lang.annotation.Annotation; +import java.util.List; + +/** + * Validator interface which allows {@link TestParameterAnnotation} annotations to validate the set + * of annotation values for a given test instance, and to selectively skip the test. + */ +interface TestParameterValidator { + + /** + * This interface allows to access information on the current testwhen implementing {@link + * TestParameterValidator}. + */ + interface Context { + + /** Returns whether the current test has the {@link TestParameterAnnotation} value(s). */ + boolean has(Class testParameter, Object value); + + /** + * Returns whether the current test has the two {@link TestParameterAnnotation} values, granted + * that the value is an enum, and each enum corresponds to a unique annotation. + */ + , U extends Enum> boolean has(T value1, U value2); + + /** + * Returns all the current test value for a given {@link TestParameterAnnotation} annotated + * annotation. + */ + Optional getValue(Class testParameter); + + /** + * Returns all the values specified for a given {@link TestParameterAnnotation} annotated + * annotation in the test. + * + *

For example, if the test annotates '@Foo(a,b,c)', getSpecifiedValues(Foo.class) will + * return [a,b,c]. + */ + List getSpecifiedValues(Class testParameter); + } + + /** + * Returns whether the test should be skipped based on the annotations' values. + * + *

The {@code testParameterValues} list contains all {@link TestParameterAnnotation} + * annotations, including those specified at the class, field, method, method parameter, + * constructor, and constructor parameter for a given test. + * + *

This method is not invoked in the context of a running test statement. + */ + boolean shouldSkip(Context context); +} diff --git a/src/main/java/com/google/testing/junit/testparameterinjector/TestParameterValueProvider.java b/src/main/java/com/google/testing/junit/testparameterinjector/TestParameterValueProvider.java new file mode 100644 index 0000000..6c398aa --- /dev/null +++ b/src/main/java/com/google/testing/junit/testparameterinjector/TestParameterValueProvider.java @@ -0,0 +1,52 @@ +/* + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.testing.junit.testparameterinjector; + +import java.lang.annotation.Annotation; +import java.util.List; +import java.util.Optional; + +/** + * Interface which allows {@link TestParameterAnnotation} annotations to provide the values to test + * in a dynamic way. + */ +interface TestParameterValueProvider { + + /** + * Returns the parameter values for which the test should run. + * + * @param annotation The annotation instance that was encountered in the test class. The + * definition of this annotation is itself annotated with the {@link TestParameterAnnotation} + * annotation. + * @param parameterClass The class of the parameter or field that is being annotated. In case the + * annotation is annotating a method, constructor or class, {@code parameterClass} is an empty + * optional. + */ + List provideValues(Annotation annotation, Optional> parameterClass); + + /** + * Returns the class of the list elements returned by {@link #provideValues(Annotation, + * Optional)}. + * + * @param annotationType The type of the annotation that was encountered in the test class. The + * definition of this annotation is itself annotated with the {@link TestParameterAnnotation} + * annotation. + * @param parameterClass The class of the parameter or field that is being annotated. In case the + * annotation is annotating a method, constructor or class, {@code parameterClass} is an empty + * optional. + */ + Class getValueType( + Class annotationType, Optional> parameterClass); +} diff --git a/src/main/java/com/google/testing/junit/testparameterinjector/TestParameterValues.java b/src/main/java/com/google/testing/junit/testparameterinjector/TestParameterValues.java new file mode 100644 index 0000000..5207ec6 --- /dev/null +++ b/src/main/java/com/google/testing/junit/testparameterinjector/TestParameterValues.java @@ -0,0 +1,27 @@ +/* + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.testing.junit.testparameterinjector; + +import com.google.common.base.Optional; +import java.lang.annotation.Annotation; + +/** Interface to retrieve the {@link TestParameterAnnotation} values for a test. */ +interface TestParameterValues { + /** + * Returns a {@link TestParameterAnnotation} value for the current test as specified by {@code + * testInfo}, or {@link Optional#absent()} if the {@code annotationType} is not found. + */ + Optional getValue(Class annotationType); +} diff --git a/src/main/java/com/google/testing/junit/testparameterinjector/TestParameters.java b/src/main/java/com/google/testing/junit/testparameterinjector/TestParameters.java new file mode 100644 index 0000000..b7ee544 --- /dev/null +++ b/src/main/java/com/google/testing/junit/testparameterinjector/TestParameters.java @@ -0,0 +1,208 @@ +/* + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.testing.junit.testparameterinjector; + +import static com.google.common.base.Preconditions.checkState; +import static java.lang.annotation.ElementType.CONSTRUCTOR; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; +import static java.util.Collections.unmodifiableMap; + +import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableList; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; + +/** + * Annotation that can be placed on @Test-methods or a test constructor to indicate the sets of + * parameters that it should be invoked with. + * + *

For @Test-methods, the method will be invoked for every set of parameters that is specified. + * For constructors, all the tests in the test class will be invoked on a class instance that was + * constructed by each set of parameters. + * + *

Note: If this annotation is used in a test class, the other methods in that class can use + * other types of parameterization, such as {@linkplain TestParameter @TestParameter}. + * + *

See {@link #value()} for simple examples. + */ +@Retention(RUNTIME) +@Target({CONSTRUCTOR, METHOD}) +public @interface TestParameters { + + /** + * Array of stringified set of parameters in YAML format. Each element corresponds to a single + * invocation of a test method. + * + *

Each element in this array is a full parameter set, formatted as a YAML mapping. The mapping + * keys must match the parameter names and the mapping values will be converted to the parameter + * type if possible. See yaml.org for the YAML syntax. Parameter types that are supported: + * + *

    + *
  • YAML primitives: + *
      + *
    • String: Specified as YAML string + *
    • boolean: Specified as YAML boolean + *
    • long and int: Specified as YAML integer + *
    • float and double: Specified as YAML floating point or integer + *
    + *
  • + *
  • Parsed types: + *
      + *
    • Enum value: Specified as a String that can be parsed by {@code Enum.valueOf()} + *
    • Byte array or com.google.protobuf.ByteString: Specified as an UTF8 String or YAML + * bytes (example: "!!binary 'ZGF0YQ=='") + *
    + *
  • + *
+ * + *

For dynamic sets of parameters or parameter types that are not supported here, use {@link + * #valuesProvider()} and leave this field empty. + * + *

Examples + * + *

+   * {@literal @}Test
+   * {@literal @}TestParameters({
+   *   "{age: 17, expectIsAdult: false}",
+   *   "{age: 22, expectIsAdult: true}",
+   * })
+   * public void personIsAdult(int age, boolean expectIsAdult) { ... }
+   *
+   * {@literal @}Test
+   * {@literal @}TestParameters({
+   *   "{updateRequest: {name: 'Hermione'}, expectedResultType: SUCCESS}",
+   *   "{updateRequest: {name: '---'}, expectedResultType: FAILURE}",
+   * })
+   * public void update(UpdateRequest updateRequest, ResultType expectedResultType) { ... }
+   * 
+ */ + String[] value() default {}; + + /** + * Sets a provider that will return a list of parameter sets. Each element in the returned list + * corresponds to a single invocation of a test method. + * + *

If this field is set, {@link #value()} must be empty and vice versa. + * + *

Example + * + *

+   * {@literal @}Test
+   * {@literal @}TestParameters(valuesProvider = IsAdultValueProvider.class)
+   * public void personIsAdult(int age, boolean expectIsAdult) { ... }
+   *
+   * private static final class IsAdultValueProvider implements TestParametersValuesProvider {
+   *   {@literal @}Override public {@literal List} provideValues() {
+   *     return ImmutableList.of(
+   *       TestParametersValues.builder()
+   *         .name("teenager")
+   *         .addParameter("age", 17)
+   *         .addParameter("expectIsAdult", false)
+   *         .build(),
+   *       TestParametersValues.builder()
+   *         .name("young adult")
+   *         .addParameter("age", 22)
+   *         .addParameter("expectIsAdult", true)
+   *         .build()
+   *     );
+   *   }
+   * }
+   * 
+ */ + Class valuesProvider() default + DefaultTestParametersValuesProvider.class; + + /** Interface for custom providers of test parameter values. */ + interface TestParametersValuesProvider { + List provideValues(); + } + + /** A set of parameters for a single method invocation. */ + @AutoValue + abstract class TestParametersValues { + + /** + * A name for this set of parameters that will be used for describing this test. + * + *

Example: If a test method is called "personIsAdult" and this name is "teenager", the name + * of the resulting test will be "personIsAdult[teenager]". + */ + public abstract String name(); + + /** A map, mapping parameter names to their values. */ + @SuppressWarnings("AutoValueImmutableFields") // intentional to allow null values + public abstract Map parametersMap(); + + public static Builder builder() { + return new Builder(); + } + + // Avoid instantiations other than the AutoValue one. + TestParametersValues() {} + + /** Builder for {@link TestParametersValues}. */ + public static final class Builder { + private String name; + private final LinkedHashMap parametersMap = new LinkedHashMap<>(); + + /** + * Sets a name for this set of parameters that will be used for describing this test. + * + *

Example: If a test method is called "personIsAdult" and this name is "teenager", the + * name of the resulting test will be "personIsAdult[teenager]". + */ + public Builder name(String name) { + this.name = name.replaceAll("\\s+", " "); + return this; + } + + /** + * Adds a parameter by its name. + * + * @param parameterName The name of the parameter of the test method + * @param value A value of the same type as the method parameter + */ + public Builder addParameter(String parameterName, @Nullable Object value) { + this.parametersMap.put(parameterName, value); + return this; + } + + /** Adds parameters by thris names. */ + public Builder addParameters(Map parameterNameToValueMap) { + this.parametersMap.putAll(parameterNameToValueMap); + return this; + } + + public TestParametersValues build() { + checkState(name != null, "This set of parameters needs a name (%s)", parametersMap); + return new AutoValue_TestParameters_TestParametersValues( + name, unmodifiableMap(new LinkedHashMap<>(parametersMap))); + } + } + } + + /** Default {@link TestParametersValuesProvider} implementation that does nothing. */ + class DefaultTestParametersValuesProvider implements TestParametersValuesProvider { + @Override + public List provideValues() { + return ImmutableList.of(); + } + } +} diff --git a/src/main/java/com/google/testing/junit/testparameterinjector/TestParametersMethodProcessor.java b/src/main/java/com/google/testing/junit/testparameterinjector/TestParametersMethodProcessor.java new file mode 100644 index 0000000..7796db0 --- /dev/null +++ b/src/main/java/com/google/testing/junit/testparameterinjector/TestParametersMethodProcessor.java @@ -0,0 +1,426 @@ +/* + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ + +package com.google.testing.junit.testparameterinjector; + +import static com.google.common.base.Preconditions.checkState; +import static java.util.Arrays.stream; + +import com.google.auto.value.AutoAnnotation; +import com.google.common.base.Optional; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Maps; +import com.google.common.primitives.Primitives; +import com.google.common.reflect.TypeToken; +import com.google.testing.junit.testparameterinjector.TestInfo.TestInfoParameter; +import com.google.testing.junit.testparameterinjector.TestParameters.DefaultTestParametersValuesProvider; +import com.google.testing.junit.testparameterinjector.TestParameters.TestParametersValues; +import com.google.testing.junit.testparameterinjector.TestParameters.TestParametersValuesProvider; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.Parameter; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collector; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.runner.Description; +import org.junit.runners.model.FrameworkMethod; +import org.junit.runners.model.Statement; +import org.junit.runners.model.TestClass; + +/** {@code TestMethodProcessor} implementation for supporting {@link TestParameters}. */ +@SuppressWarnings("AndroidJdkLibsChecker") // Parameter is not available on old Android SDKs. +class TestParametersMethodProcessor implements TestMethodProcessor { + + private final TestClass testClass; + + private final LoadingCache> + parameterValuesByConstructorOrMethodCache = + CacheBuilder.newBuilder() + .maximumSize(1000) + .build( + CacheLoader.from( + methodOrConstructor -> + (methodOrConstructor instanceof Constructor) + ? toParameterValuesList( + methodOrConstructor, + ((Constructor) methodOrConstructor) + .getAnnotation(TestParameters.class), + ((Constructor) methodOrConstructor).getParameters()) + : toParameterValuesList( + methodOrConstructor, + ((Method) methodOrConstructor) + .getAnnotation(TestParameters.class), + ((Method) methodOrConstructor).getParameters()))); + + public TestParametersMethodProcessor(TestClass testClass) { + this.testClass = testClass; + } + + @Override + public ValidationResult validateConstructor(TestClass testClass, List exceptions) { + if (testClass.getOnlyConstructor().isAnnotationPresent(TestParameters.class)) { + try { + // This method throws an exception if there is a validation error + getConstructorParameters(); + } catch (Throwable t) { + exceptions.add(t); + } + return ValidationResult.HANDLED; + } else { + return ValidationResult.NOT_HANDLED; + } + } + + @Override + public ValidationResult validateTestMethod( + TestClass testClass, FrameworkMethod testMethod, List exceptions) { + if (testMethod.getMethod().isAnnotationPresent(TestParameters.class)) { + try { + // This method throws an exception if there is a validation error + getMethodParameters(testMethod.getMethod()); + } catch (Throwable t) { + exceptions.add(t); + } + return ValidationResult.HANDLED; + } else { + return ValidationResult.NOT_HANDLED; + } + } + + @Override + public List processTest(Class clazz, TestInfo originalTest) { + boolean constructorIsParameterized = + testClass.getOnlyConstructor().isAnnotationPresent(TestParameters.class); + boolean methodIsParameterized = + originalTest.getMethod().isAnnotationPresent(TestParameters.class); + + if (!constructorIsParameterized && !methodIsParameterized) { + return ImmutableList.of(originalTest); + } + + ImmutableList.Builder testInfos = ImmutableList.builder(); + + ImmutableList> constructorParametersList = + getConstructorParametersOrSingleAbsentElement(); + ImmutableList> methodParametersList = + getMethodParametersOrSingleAbsentElement(originalTest.getMethod()); + for (int constructorParametersIndex = 0; + constructorParametersIndex < constructorParametersList.size(); + ++constructorParametersIndex) { + Optional constructorParameters = + constructorParametersList.get(constructorParametersIndex); + + for (int methodParametersIndex = 0; + methodParametersIndex < methodParametersList.size(); + ++methodParametersIndex) { + Optional methodParameters = + methodParametersList.get(methodParametersIndex); + + // Making final copies of non-final integers for use in lambda + int constructorParametersIndexCopy = constructorParametersIndex; + int methodParametersIndexCopy = methodParametersIndex; + + testInfos.add( + originalTest + .withExtraParameters( + Stream.of( + constructorParameters + .transform( + param -> + TestInfoParameter.create( + param.name(), + param.parametersMap(), + constructorParametersIndexCopy)) + .orNull(), + methodParameters + .transform( + param -> + TestInfoParameter.create( + param.name(), + param.parametersMap(), + methodParametersIndexCopy)) + .orNull()) + .filter(Objects::nonNull) + .collect(toImmutableList())) + .withExtraAnnotation( + TestIndexHolderFactory.create( + constructorParametersIndex, methodParametersIndex))); + } + } + return testInfos.build(); + } + + private ImmutableList> + getConstructorParametersOrSingleAbsentElement() { + return testClass.getOnlyConstructor().isAnnotationPresent(TestParameters.class) + ? getConstructorParameters().stream().map(Optional::of).collect(toImmutableList()) + : ImmutableList.of(Optional.absent()); + } + + private ImmutableList> getMethodParametersOrSingleAbsentElement( + Method method) { + return method.isAnnotationPresent(TestParameters.class) + ? getMethodParameters(method).stream().map(Optional::of).collect(toImmutableList()) + : ImmutableList.of(Optional.absent()); + } + + @Override + public Statement processStatement(Statement originalStatement, Description finalTestDescription) { + return originalStatement; + } + + @Override + public Optional createTest( + TestClass testClass, FrameworkMethod method, Optional test) { + if (testClass.getOnlyConstructor().isAnnotationPresent(TestParameters.class)) { + ImmutableList parameterValuesList = getConstructorParameters(); + TestParametersValues parametersValues = + parameterValuesList.get( + method.getAnnotation(TestIndexHolder.class).constructorParametersIndex()); + + try { + Constructor constructor = testClass.getOnlyConstructor(); + return Optional.of( + constructor.newInstance( + toParameterArray( + parametersValues, testClass.getOnlyConstructor().getParameters()))); + } catch (Exception e) { + throw new RuntimeException(e); + } + } else { + return test; + } + } + + @Override + public Optional createStatement( + TestClass testClass, + FrameworkMethod method, + Object testObject, + Optional statement) { + if (method.getMethod().isAnnotationPresent(TestParameters.class)) { + ImmutableList parameterValuesList = + getMethodParameters(method.getMethod()); + TestParametersValues parametersValues = + parameterValuesList.get( + method.getAnnotation(TestIndexHolder.class).methodParametersIndex()); + + return Optional.of( + new Statement() { + @Override + public void evaluate() throws Throwable { + method.invokeExplosively( + testObject, + toParameterArray(parametersValues, method.getMethod().getParameters())); + } + }); + } else { + return statement; + } + } + + private ImmutableList getConstructorParameters() { + return parameterValuesByConstructorOrMethodCache.getUnchecked(testClass.getOnlyConstructor()); + } + + private ImmutableList getMethodParameters(Method method) { + return parameterValuesByConstructorOrMethodCache.getUnchecked(method); + } + + private static ImmutableList toParameterValuesList( + Object methodOrConstructor, TestParameters annotation, Parameter[] invokableParameters) { + boolean valueIsSet = annotation.value().length > 0; + boolean valuesProviderIsSet = + !annotation.valuesProvider().equals(DefaultTestParametersValuesProvider.class); + + checkState( + !(valueIsSet && valuesProviderIsSet), + "It is not allowed to specify both value and valuesProvider on annotation %s", + annotation); + checkState( + valueIsSet || valuesProviderIsSet, + "Either value or valuesProvider must be set on annotation %s", + annotation); + + ImmutableList parametersList = ImmutableList.copyOf(invokableParameters); + checkState( + parametersList.stream().allMatch(Parameter::isNamePresent), + "" + + "No parameter name could be found for %s, which likely means that parameter names" + + " aren't available at runtime. Please ensure that the this test was built with the" + + " -parameters compiler option.\n" + + "\n" + + "In Maven, you do this by adding true to the" + + " maven-compiler-plugin's configuration. For example:\n" + + "\n" + + "\n" + + " \n" + + " \n" + + " org.apache.maven.plugins\n" + + " maven-compiler-plugin\n" + + " 3.8.1\n" + + " \n" + + " \n" + + " -parameters\n" + + " \n" + + " \n" + + " \n" + + " \n" + + "\n" + + "\n" + + "Don't forget to run `mvn clean` after making this change.", + methodOrConstructor); + if (valueIsSet) { + return stream(annotation.value()) + .map(yamlMap -> toParameterValues(yamlMap, parametersList)) + .collect(toImmutableList()); + } else { + return toParameterValuesList(annotation.valuesProvider(), parametersList); + } + } + + private static ImmutableList toParameterValuesList( + Class valuesProvider, List parameters) { + try { + Constructor constructor = + valuesProvider.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance().provideValues().stream() + .peek(values -> validateThatValuesMatchParameters(values, parameters)) + .collect(toImmutableList()); + } catch (NoSuchMethodException e) { + if (!Modifier.isStatic(valuesProvider.getModifiers()) && valuesProvider.isMemberClass()) { + throw new IllegalStateException( + String.format( + "Could not find a no-arg constructor for %s, probably because it is a not-static" + + " inner class. You can fix this by making %s static.", + valuesProvider.getSimpleName(), valuesProvider.getSimpleName()), + e); + } else { + throw new IllegalStateException( + String.format( + "Could not find a no-arg constructor for %s.", valuesProvider.getSimpleName()), + e); + } + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + + private static void validateThatValuesMatchParameters( + TestParametersValues testParametersValues, List parameters) { + ImmutableMap parametersByName = + Maps.uniqueIndex(parameters, Parameter::getName); + + checkState( + testParametersValues.parametersMap().keySet().equals(parametersByName.keySet()), + "Cannot map the given TestParametersValues to parameters %s (Given TestParametersValues" + + " are %s)", + parametersByName.keySet(), + testParametersValues); + + testParametersValues + .parametersMap() + .forEach( + (paramName, paramValue) -> { + Class expectedClass = Primitives.wrap(parametersByName.get(paramName).getType()); + if (paramValue != null) { + checkState( + expectedClass.isInstance(paramValue), + "Cannot map value '%s' (class = %s) to parameter %s (class = %s) (for" + + " TestParametersValues %s)", + paramValue, + paramValue.getClass(), + paramName, + expectedClass, + testParametersValues); + } + }); + } + + private static TestParametersValues toParameterValues( + String yamlString, List parameters) { + Object yamlMapObject = ParameterValueParsing.parseYamlStringToObject(yamlString); + checkState( + yamlMapObject instanceof Map, + "Cannot map YAML string '%s' to parameters because it is not a mapping", + yamlString); + @SuppressWarnings("unchecked") + Map yamlMap = (Map) yamlMapObject; + + ImmutableMap parametersByName = + Maps.uniqueIndex(parameters, Parameter::getName); + checkState( + yamlMap.keySet().equals(parametersByName.keySet()), + "Cannot map YAML string '%s' to parameters %s", + yamlString, + parametersByName.keySet()); + + return TestParametersValues.builder() + .name(yamlString) + .addParameters( + Maps.transformEntries( + yamlMap, + (parameterName, parsedYaml) -> + ParameterValueParsing.parseYamlObjectToJavaType( + parsedYaml, + TypeToken.of(parametersByName.get(parameterName).getParameterizedType())))) + .build(); + } + + private static Object[] toParameterArray( + TestParametersValues parametersValues, Parameter[] parameters) { + return stream(parameters) + .map(parameter -> parametersValues.parametersMap().get(parameter.getName())) + .toArray(); + } + + // Immutable collectors are re-implemented here because they are missing from the Android + // collection library. + private static Collector> toImmutableList() { + return Collectors.collectingAndThen(Collectors.toList(), ImmutableList::copyOf); + } + + /** + * This mechanism is a workaround to be able to store the test index in the annotation list of the + * {@link TestInfo}, since we cannot carry other information through the test runner. + */ + @Retention(RetentionPolicy.RUNTIME) + @interface TestIndexHolder { + int constructorParametersIndex(); + + int methodParametersIndex(); + } + + /** Factory for {@link TestIndexHolder}. */ + static class TestIndexHolderFactory { + @AutoAnnotation + static TestIndexHolder create(int constructorParametersIndex, int methodParametersIndex) { + return new AutoAnnotation_TestParametersMethodProcessor_TestIndexHolderFactory_create( + constructorParametersIndex, methodParametersIndex); + } + + private TestIndexHolderFactory() {} + } +} -- cgit v1.2.3