chromium/base/test/android/javatests/src/org/chromium/base/test/params/ParameterizedRunner.java

// Copyright 2017 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.base.test.params;

import org.junit.Test;
import org.junit.runner.Runner;
import org.junit.runner.notification.RunNotifier;
import org.junit.runners.BlockJUnit4ClassRunner;
import org.junit.runners.Suite;
import org.junit.runners.model.FrameworkField;
import org.junit.runners.model.Statement;
import org.junit.runners.model.TestClass;

import org.chromium.base.test.params.ParameterAnnotations.ClassParameter;
import org.chromium.base.test.params.ParameterAnnotations.UseMethodParameter;
import org.chromium.base.test.params.ParameterAnnotations.UseRunnerDelegate;
import org.chromium.base.test.params.ParameterizedRunnerDelegateFactory.ParameterizedRunnerDelegateInstantiationException;

import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;

/**
 * ParameterizedRunner generates a list of runners for each of class parameter set in a test class.
 *
 * ParameterizedRunner looks for {@code @ClassParameter} annotation in test class and
 * generates a list of ParameterizedRunnerDelegate runners for each ParameterSet.
 */
public final class ParameterizedRunner extends Suite {
    private final List<Runner> mRunners;

    /**
     * Create a ParameterizedRunner to run test class
     *
     * @param klass the Class of the test class, test class should be atomic
     *              (extends only Object)
     */
    public ParameterizedRunner(Class<?> klass) throws Throwable {
        super(klass, Collections.emptyList()); // pass in empty list of runners
        validate();
        mRunners = createRunners(getTestClass());
    }

    @Override
    protected List<Runner> getChildren() {
        return mRunners;
    }

    /**
     * ParentRunner calls collectInitializationErrors() to check for errors in Test class.
     * Parameterized tests are written in unconventional ways, therefore, this method is
     * overridden and validation is done seperately.
     */
    @Override
    protected void collectInitializationErrors(List<Throwable> errors) {
        // Do not call super collectInitializationErrors
    }

    private void validate() throws Throwable {
        validateNoNonStaticInnerClass();
        validateOnlyOneConstructor();
        validateInstanceMethods();
        validateOnlyOneClassParameterField();
        validateAtLeastOneParameterSetField();
    }

    private void validateNoNonStaticInnerClass() throws Exception {
        if (getTestClass().isANonStaticInnerClass()) {
            throw new Exception("The inner class " + getTestClass().getName() + " is not static.");
        }
    }

    private void validateOnlyOneConstructor() throws Exception {
        if (!hasOneConstructor()) {
            throw new Exception("Test class should have exactly one public constructor");
        }
    }

    private boolean hasOneConstructor() {
        return getTestClass().getJavaClass().getConstructors().length == 1;
    }

    private void validateOnlyOneClassParameterField() {
        if (getTestClass().getAnnotatedFields(ClassParameter.class).size() > 1) {
            throw new IllegalParameterArgumentException(
                    String.format(
                            Locale.getDefault(),
                            "%s class has more than one @ClassParameter, only one is allowed",
                            getTestClass().getName()));
        }
    }

    private void validateAtLeastOneParameterSetField() {
        if (getTestClass().getAnnotatedFields(ClassParameter.class).isEmpty()
                && getTestClass().getAnnotatedMethods(UseMethodParameter.class).isEmpty()) {
            throw new IllegalArgumentException(
                    String.format(
                            Locale.getDefault(),
                            "%s has no field annotated with @ClassParameter or method annotated"
                                + " with@UseMethodParameter; it should not use ParameterizedRunner",
                            getTestClass().getName()));
        }
    }

    private void validateInstanceMethods() throws Exception {
        if (getTestClass().getAnnotatedMethods(Test.class).size() == 0) {
            throw new Exception("No runnable methods");
        }
    }

    /**
     * Return a list of runner delegates through ParameterizedRunnerDelegateFactory.
     *
     * For class parameter set: each class can only have one list of class parameter sets.
     * Each parameter set will be used to create one runner.
     *
     * For method parameter set: a single list method parameter sets is associated with
     * a string tag, an immutable map of string to parameter set list will be created and
     * passed into factory for each runner delegate to create multiple tests. Only one
     * Runner will be created for a method that uses @UseMethodParameter, regardless of the
     * number of ParameterSets in the associated list.
     *
     * @return a list of runners
     * @throws ParameterizedRunnerDelegateInstantiationException if runner delegate can not
     *         be instantiated with constructor reflectively
     * @throws IllegalAccessError if the field in tests are not accessible
     */
    static List<Runner> createRunners(TestClass testClass)
            throws IllegalAccessException, ParameterizedRunnerDelegateInstantiationException {
        List<ParameterSet> classParameterSetList;
        if (testClass.getAnnotatedFields(ClassParameter.class).isEmpty()) {
            classParameterSetList = new ArrayList<>();
            classParameterSetList.add(null);
        } else {
            classParameterSetList =
                    getParameterSetList(
                            testClass.getAnnotatedFields(ClassParameter.class).get(0), testClass);
            validateWidth(classParameterSetList);
        }

        Class<? extends ParameterizedRunnerDelegate> runnerDelegateClass =
                getRunnerDelegateClass(testClass);
        ParameterizedRunnerDelegateFactory factory = new ParameterizedRunnerDelegateFactory();
        List<Runner> runnersForTestClass = new ArrayList<>();
        for (ParameterSet classParameterSet : classParameterSetList) {
            BlockJUnit4ClassRunner runner =
                    (BlockJUnit4ClassRunner)
                            factory.createRunner(testClass, classParameterSet, runnerDelegateClass);
            runnersForTestClass.add(runner);
        }
        return runnersForTestClass;
    }

    /** Return an unmodifiable list of ParameterSet through a FrameworkField */
    private static List<ParameterSet> getParameterSetList(FrameworkField field, TestClass testClass)
            throws IllegalAccessException {
        field.getField().setAccessible(true);
        if (!Modifier.isStatic(field.getField().getModifiers())) {
            throw new IllegalParameterArgumentException(
                    String.format(
                            Locale.getDefault(),
                            "ParameterSetList fields must be static, this field %s in %s is not",
                            field.getName(),
                            testClass.getName()));
        }
        if (!(field.get(testClass.getJavaClass()) instanceof List)) {
            throw new IllegalArgumentException(
                    String.format(
                            Locale.getDefault(),
                            "Fields with @ClassParameter annotations must be an instance of List, "
                                    + "this field %s in %s is not list",
                            field.getName(),
                            testClass.getName()));
        }
        @SuppressWarnings("unchecked") // checked above
        List<ParameterSet> result = (List<ParameterSet>) field.get(testClass.getJavaClass());
        return Collections.unmodifiableList(result);
    }

    static void validateWidth(Iterable<ParameterSet> parameterSetList) {
        int lastSize = -1;
        for (ParameterSet set : parameterSetList) {
            if (set.size() == 0) {
                throw new IllegalParameterArgumentException(
                        "No parameter is added to method ParameterSet");
            }
            if (lastSize == -1 || set.size() == lastSize) {
                lastSize = set.size();
            } else {
                throw new IllegalParameterArgumentException(
                        String.format(
                                Locale.getDefault(),
                                "All ParameterSets in a list of ParameterSet must have equal"
                                        + " length. The current ParameterSet (%s) contains %d"
                                        + " parameters, while previous ParameterSet contains %d"
                                        + " parameters",
                                Arrays.toString(set.getValues().toArray()),
                                set.size(),
                                lastSize));
            }
        }
    }

    /**
     * Get the runner delegate class for the test class if {@code @UseRunnerDelegate} is used.
     * The default runner delegate is BaseJUnit4RunnerDelegate.class
     */
    private static Class<? extends ParameterizedRunnerDelegate> getRunnerDelegateClass(
            TestClass testClass) {
        if (testClass.getAnnotation(UseRunnerDelegate.class) != null) {
            return testClass.getAnnotation(UseRunnerDelegate.class).value();
        }
        return BaseJUnit4RunnerDelegate.class;
    }

    static class IllegalParameterArgumentException extends IllegalArgumentException {
        IllegalParameterArgumentException(String msg) {
            super(msg);
        }
    }

    public static class ParameterizedTestInstantiationException extends Exception {
        ParameterizedTestInstantiationException(
                TestClass testClass, String parameterSetString, Exception e) {
            super(
                    String.format(
                            "Test class %s can not be initiated, the provided parameters are %s,"
                                    + " the required parameter types are %s",
                            testClass.getJavaClass().toString(),
                            parameterSetString,
                            Arrays.toString(testClass.getOnlyConstructor().getParameterTypes())),
                    e);
        }
    }

    /**
     * We need to prevent the ParentRunner from running ClassRules or Before/AfterClass annotations,
     * or they'll run a second time (re-entrantly) when the child runners that actually run the
     * tests run.
     *
     * Do not call super.classBlock().
     */
    @Override
    protected Statement classBlock(final RunNotifier notifier) {
        Statement statement = childrenInvoker(notifier);
        return statement;
    }
}