вторник, 29 мая 2012 г.

Junit и переход на java 7.


Недавно наш проект стал переходить с 6 явы на 7, и в процессе возникало множество различных проблем.
Одна из них - беспорядочный порядок выполнения тестов четвёртым junit'ом.
Вообще говоря, порядок выполнения тестов может быть любым.
Каждый тест должен быть независимым от всех остальных. Так же вы можете это прочесть в документации junit'a. Но если у вас уже есть тысячи тестов и они "падают" в случае если их запускать в неопределённом порядке - это большая проблема и вы потратите кучу времени на рефакторинг всех тестов, делая их независимыми друг от друга. Я хочу предложить вам своё временное решение данной проблемы.

Junit использует reflection для получения и запуска тестовых методов. Для получения списка методов которые содеражтся в тестовом классе используется метод "Method[] getDeclaredMethods()" содержащийся в классе java.lang.Class.
Вы можете прочесть в документации к этому методу, либо в документации самого junit'a что:
"The elements in the array returned are not sorted and are not in any particular order." (Элементы в возвращаемом массиве никак не отсоритрованны и никак не упорядоченны), но в предыдущих реализациях ява машины возвращаемый список методов, был отсортирован в том порядке, в котором эти методы были в коде. И многие этим пользовались игнорируя документацию. В результате часть написанных тестов зависят друг от друга. Изначально было неправильно полагаться на порядок тестов, но что сделано, то сделано, и теперь у нас есть проблема требующая быстрого (временного) решения. 


Мы будем сортировать список тестов прямо перед тем как junit начнёт их запускать. Мы изменим некоторые классы junit'a, скомпилируем их и поместим перед junit.jar в наш classpath, тем самым мы подменим оригинальные классы, без нужды пересобирать сам junit. Это может быть полезно, если вам проще добавить новую зависимость к проекту, чем изменять стороннюю библиотеку.


Junit 4 имеет два разных базовых класса отвечающих за запуск тестов: JUnit3Builder и JUnit4Builder из пакета org.junit.internal.builders. JUnit3Builder используется для запуска тестов старого формата (без использования аннотаций и прочего).
Мы переопределим их следующим образом:


package org.junit.internal.builders;

import custom.junit.runners.OrderedJUnit3ClassRunner;
import org.junit.internal.runners.JUnit38ClassRunner;
import org.junit.runner.Runner;
import org.junit.runners.model.RunnerBuilder;

public class JUnit3Builder extends RunnerBuilder {

    public Runner runnerForClass(Class testClass) throws Throwable {
        if (isPre4Test(testClass)) {
//            return new JUnit38ClassRunner(testClass);
            return new OrderedJUnit3ClassRunner(testClass);
        } else {
            return null;
        }
    }

    boolean isPre4Test(Class testClass) {
        return junit.framework.TestCase.class.isAssignableFrom(testClass);
    }
}

package org.junit.internal.builders;

import custom.junit.runners.OrderedJUnit4ClassRunner;
import org.junit.runner.Runner;
import org.junit.runners.BlockJUnit4ClassRunner;
import org.junit.runners.model.RunnerBuilder;

/**
 * This must come first on the classpath before JUnit 4's jar so it
 * is instantiated instead of the default JUnit 4 builder.
 */

public class JUnit4Builder extends RunnerBuilder {
    @Override
    public Runner runnerForClass(Class<?> testClass) throws Throwable {

        // Using Class Runner with sorting of test methods:
        // sorting in order as they are in java code.
        return new OrderedJUnit4ClassRunner(testClass);

        // JUnit Original Class Runner
        // return new BlockJUnit4ClassRunner(testClass);
    }
}


Для junit 3 нам потребуется так же собственная реализация класса TestSuite. Когда где-либо в ядре junit coreбудет добавлен новый тестовый метод в список тестов, он попадёт в наш отсортированный список - OrderedTestSuite.


package custom.junit.runners;

import custom.junit.framework.OrderedTestSuite;
import junit.framework.Test;
import junit.framework.TestCase;
import org.apache.log4j.Logger;
import org.junit.internal.runners.JUnit38ClassRunner;

public class OrderedJUnit3ClassRunner extends JUnit38ClassRunner {

    private static final Logger logger = Logger.getLogger(OrderedJUnit3ClassRunner.class.getName());

    public OrderedJUnit3ClassRunner(Class<?> aClass) {
        this(new OrderedTestSuite(aClass.asSubclass(TestCase.class)));
    }

    public OrderedJUnit3ClassRunner(Test test) {
        super(test);
        logger.info("Using custom JUNIT CLASS RUNNER: " + this.getClass().getCanonicalName());
    }
} 

package custom.junit.framework;

import custom.junit.runners.MethodComparator;
import junit.framework.Test;
import junit.framework.TestCase;
import junit.framework.TestSuite;
import org.apache.log4j.Logger;

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

public class OrderedTestSuite extends TestSuite {

    private static final Logger logger = Logger.getLogger(OrderedTestSuite.class.getName());

    public OrderedTestSuite(final Class<?> theClass) {
        addTestsFromTestCase(theClass);
    }

    /**
     * Adds the tests from the given class to the suite
     */

    @Override
    public void addTestSuite(Class<? extends TestCase> testClass) {
        addTest(new OrderedTestSuite(testClass));
    }

    private void addTestsFromTestCase(final Class<?> theClass) {
        this.setName(theClass.getName());
        try {
            getTestConstructor(theClass); // Avoid generating multiple error messages
        } catch (NoSuchMethodException e) {
            addTest(warning("Class " + theClass.getName() + " has no public constructor TestCase(String name) or TestCase()"));
            return;
        }

        if (!Modifier.isPublic(theClass.getModifiers())) {
            addTest(warning("Class " + theClass.getName() + " is not public"));
            return;
        }

        Class<?> superClass = theClass;
        List<String> names = new ArrayList<String>();
        while (Test.class.isAssignableFrom(superClass)) {
            Method[] methods = superClass.getDeclaredMethods();

            // Sorting methods.
            final List<Method> methodList = new ArrayList<Method>(Arrays.asList(methods));
            try {
                Collections.sort(methodList, MethodComparator.getMethodComparatorForJUnit3());
                methods = methodList.toArray(new Method[methodList.size()]);
            } catch (Throwable throwable) {
                logger.fatal("addTestsFromTestCase(): Error while sorting test cases! Using default order (random).", throwable);
            }

            for (Method each : methods) {
                addTestMethod(each, names, theClass);
            }
            superClass = superClass.getSuperclass();
        }
        if (this.testCount() == 0)
            addTest(warning("No tests found in " + theClass.getName()));
    }

    private void addTestMethod(Method m, List<String> names, Class<?> theClass) {
        String name = m.getName();
        if (names.contains(name))
            return;
        if (!isPublicTestMethod(m)) {
            if (isTestMethod(m))
                addTest(warning("Test method isn't public: " + m.getName() + "(" + theClass.getCanonicalName() + ")"));
            return;
        }
        names.add(name);
        addTest(createTest(theClass, name));
    }

    private boolean isPublicTestMethod(Method m) {
        return isTestMethod(m) && Modifier.isPublic(m.getModifiers());
    }

    private boolean isTestMethod(Method m) {
        return m.getParameterTypes().length == 0 && m.getName().startsWith("test") && m.getReturnType().equals(Void.TYPE);
    }
}

Для junit 4, всё намного проще, в нём имеется специальный метод отвечающий за получения списка тестов, поэтому нам достаточно перекрыть лишь его we can override just one special method - "protected List<FrameworkMethod> computeTestMethods()".


package custom.junit.runners;

import org.apache.log4j.Logger;
import org.junit.runners.BlockJUnit4ClassRunner;
import org.junit.runners.model.FrameworkMethod;
import org.junit.runners.model.InitializationError;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class OrderedJUnit4ClassRunner extends BlockJUnit4ClassRunner {

    private static final Logger logger = Logger.getLogger(OrderedJUnit4ClassRunner.class.getName());

    public OrderedJUnit4ClassRunner(Class aClass) throws InitializationError {
        super(aClass);
        logger.info("Using custom JUNIT CLASS RUNNER: " + this.getClass().getCanonicalName());
    }

    @Override
    protected List<FrameworkMethod> computeTestMethods() {
        final List<FrameworkMethod> list = super.computeTestMethods();
        try {
            final List<FrameworkMethod> copy = new ArrayList<FrameworkMethod>(list);
            Collections.sort(copy, MethodComparator.getFrameworkMethodComparatorForJUnit4());
            return copy;
        } catch (Throwable throwable) {
            logger.fatal("computeTestMethods(): Error while sorting test cases! Using default order (random).", throwable);
            return list;
        }
    }
}

И последнее - нам нужен сам механизм сравнения с помощью которого мы сможем упорядочить тесты так, как они находятся в исходном коде. Для этого будем использовать специальный класс типа компаратор. Конечно же вы можете реализовать свой собственный компаратор, который бы сортировал тесты в любом другом, нужном вам порядке. Мой компаратор сортирует тесты в том порядке, в котором они находятся в исходном коде, или точнее в байт-коде.


package custom.junit.runners;

import org.apache.log4j.Logger;
import org.junit.Ignore;
import org.junit.runners.model.FrameworkMethod;

import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.LineNumberReader;
import java.lang.reflect.Method;
import java.util.Comparator;

public class MethodComparator<T> implements Comparator<T> {

    private static final Logger logger = Logger.getLogger(MethodComparator.class.getName());

    private static final char[] METHOD_SEPARATORS = {17};

    private MethodComparator() {
    }

    public static MethodComparator<FrameworkMethod> getFrameworkMethodComparatorForJUnit4() {
        return new MethodComparator<FrameworkMethod>();
    }

    public static MethodComparator<Method> getMethodComparatorForJUnit3() {
        return new MethodComparator<Method>();
    }

    @Override
    public int compare(T o1, T o2) {
        final MethodPosition methodPosition1 = this.getIndexOfMethodPosition(o1);
        final MethodPosition methodPosition2 = this.getIndexOfMethodPosition(o2);
        return methodPosition1.compareTo(methodPosition2);
    }

    private MethodPosition getIndexOfMethodPosition(final Object method) {
        if (method instanceof FrameworkMethod) {
            return this.getIndexOfMethodPosition((FrameworkMethod) method);
        } else if (method instanceof Method) {
            return this.getIndexOfMethodPosition((Method) method);
        } else {
            logger.error("getIndexOfMethodPosition(): Given object is not a method! Object class is "
                    + method.getClass().getCanonicalName());
            return new NullMethodPosition();
        }
    }

    private MethodPosition getIndexOfMethodPosition(final FrameworkMethod frameworkMethod) {
        return getIndexOfMethodPosition(frameworkMethod.getMethod());
    }

    private MethodPosition getIndexOfMethodPosition(final Method method) {
        final Class aClass = method.getDeclaringClass();
        if (method.getAnnotation(Ignore.class) == null) {
            return getIndexOfMethodPosition(aClass, method.getName());
        } else {
            logger.debug("getIndexOfMethodPosition(): Method is annotated as Ignored: " + method.getName()
                    + " in class: " + aClass.getCanonicalName());
            return new NullMethodPosition();
        }
    }

    private MethodPosition getIndexOfMethodPosition(final Class aClass, final String methodName) {
        MethodPosition methodPosition;
        for (final char methodSeparator : METHOD_SEPARATORS) {
            methodPosition = getIndexOfMethodPosition(aClass, methodName, methodSeparator);
            if (methodPosition instanceof NullMethodPosition) {
                logger.debug("getIndexOfMethodPosition(): Trying to use another method separator for method: " + methodName);
            } else {
                return methodPosition;
            }
        }
        return new NullMethodPosition();
    }

    private MethodPosition getIndexOfMethodPosition(final Class aClass, final String methodName, final char methodSeparator) {
        final InputStream inputStream = aClass.getResourceAsStream(aClass.getSimpleName() + ".class");
        final LineNumberReader lineNumberReader = new LineNumberReader(new InputStreamReader(inputStream));
        final String methodNameWithSeparator = methodName + methodSeparator;
        try {
            try {
                String line;
                while ((line = lineNumberReader.readLine()) != null) {
                    if (line.contains(methodNameWithSeparator)) {
                        return new MethodPosition(lineNumberReader.getLineNumber(), line.indexOf(methodNameWithSeparator));
                    }
                }
            } finally {
                lineNumberReader.close();
            }
        } catch (IOException e) {
            logger.error("getIndexOfMethodPosition(): Error while reading byte code of class " + aClass.getCanonicalName(), e);
            return new NullMethodPosition();
        }
        logger.warn("getIndexOfMethodPosition(): Can't find method " + methodName + " in byte code of class " + aClass.getCanonicalName());
        return new NullMethodPosition();
    }

    private static class MethodPosition implements Comparable<MethodPosition> {
        private final Integer lineNumber;
        private final Integer indexInLine;

        public MethodPosition(int lineNumber, int indexInLine) {
            this.lineNumber = lineNumber;
            this.indexInLine = indexInLine;
        }

        @Override
        public int compareTo(MethodPosition o) {

            // If line numbers are equal, then compare by indexes in this line.
            if (this.lineNumber.equals(o.lineNumber)) {
                return this.indexInLine.compareTo(o.indexInLine);
            } else {
                return this.lineNumber.compareTo(o.lineNumber);
            }
        }
    }

    private static class NullMethodPosition extends MethodPosition {
        public NullMethodPosition() {
            super(-1-1);
        }
    }
}

Вы можете использовать различные продвинутые библиотеки для декомпиляции байт-кода, например  ASM, а я просто ищу в байт коде символы-разделители, которые идут сразу за именем метода.


Надеюсь этот пост будет вам полезен.
Любые комментарии приветствуются.

2 комментария: