Предположения и теории

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

Подготовка

Как обычно, создаём пустой maven проект и добавляем к нему JUnit:

>mvn archetype:generate -DgroupId=ru.easyjava.junit -DartifactId=theory -Dversion=1  -DinteractiveMode=false
[INFO] Scanning for projects...
[...skipped...]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <junit.version>4.12</junit.version>
        <hamcrest.version>1.3</hamcrest.version>
    </properties>




    <dependencies>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>${junit.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.hamcrest</groupId>
            <artifactId>hamcrest-library</artifactId>
            <version>${hamcrest.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

 

Помимо пустого проекта, нам ещё понадобится класс  GreatCircle из примера использования параметров.

Предусловия

Представим, что Вы разработчик многонациональной компании, которая разрабатывает многоязычное приложение. В этом приложении есть вывод дат с учётом текущей локали. И Вам необходимо проверить, насколько правильно даты форматируются. Очевидно, что для Вас интересен только результат родного вам языка и, скажем, оценить корректность даты на «Туюка» россиянину будет затруднительно. Поэтому каждый носитель языка пишет свой собственный тест, проверяющий корректность:

   @Test
    public void testFormatDate() throws Exception {
        Calendar date = Calendar.getInstance();
        date.set(1961, 4, 21, 9, 7, 0);
        assertThat(testedObject.formatDate(date.getTime()), is("Вс, май 21, '61"));
    }

Очевидно (а мы этого и добивались), что поведение метода LocalizedDateService.formatDate() зависит от текущей локали и, когда этот тест выполнит, допустим, албанец, тест провалится. Но нам и надо проверить только поведение для русского языка! Поэтому добавим к тесту предположение:

    @Test
    public void testFormatDate() throws Exception {
        assumeThat(System.getProperty("user.language"), is("ru"));
        Calendar date = Calendar.getInstance();
        date.set(1961, 4, 21, 9, 7, 0);
        assertThat(testedObject.formatDate(date.getTime()), is("Вс, май 21, '61"));
    }

assumeThat проверяет некоторое условие и, если оно ложно, прекращает выполнение теста со следующей строки. Тест при этом считается проигнорированным. С assumeThat можно использовать матчеры и строить с их помощью выражения любой сложности

JUnit поддерживает assumeThat и assumeTrue. Остальные функции, аналогичные assert*, не поддерживаются.

Теории

Теории объединяют предусловия и параметризацию, позволяя писать обобщённые тесты.

Классическое юнит-тестирование основывается на примерах и частных случаях. Обычно тесты выражаются в форме «примера использования»: «если входной параметр — строка с числом n, то результат число равное n», «если входная строка null, то результат — выбрасывание исключения». Поскольку поведение обычно разное, то и на каждый проверяемый вариант поведения пишется отдельный тест.

С другой стороны, часто встречается необходимость проверять одно и тоже поведение, с разными данными. Такие тесты характерны для вычислительного кода или для проверки работы в граничных условиях. В этом случае пишется один тест, который параметризуется каким-либо набором данных. Но это всё ещё  фиксированный набор данных с фиксированными результатами.

Теории предлагают еще более общие тесты, которые можно выразить как «При определённых предположениях код всегда ведёт себя определённым образом». «Всегда» означает: «с любыми данными». Пример такого утверждения: «При возведении числа в целую чётную степень результат всегда будет положительным». «Возведение числа» — код, «Целая чётная степень» — предположение, «Всегда положительный» — проверяемый результат. В общем виде теории позволяют выразить предназначение и поведение кода.

Давайте немедленно проверим утверждение о чётных степенях:

    @RunWith(Theories.class)
    public class PowTheoryTest {
        @DataPoint private int A=4;
        @DataPoint private int B=-5;
        @DataPoint private int C=17;
        @DataPoint private int D=22;
        @DataPoint private int E=-8;
        @DataPoint private int F=1;
        @DataPoint private int G=-1;
        @DataPoint private int H=0;


        @Theory
        public void isPositive(int base, int exponent) {
            assumeTrue(exponent%2 == 0);
            assertThat((int)Math.pow(base, exponent), anyOf(greaterThan(0), is(0)));
        }
    }

В первую очередь, все классы, содержащие теории, должны исполняться с поддержкой теорий: @RunWith(Theories.class) . Во вторых, вместо объявления теста @Test , объявляется теория @Theory , содержащая предположения и собственно тест. Предположения ведут себя предсказуемо — если предположение проваливается, то для данного набора данных игнорируется.

И, наконец, самое главное — данные. Данные определяются с помощью аннотации @Datapoint и явно в тест не передаются. Вместо этого JUnit запускает тест на всех возможных комбинациях данных. Подстановка данных осуществляется по их типам, поэтому количество параметров может не совпадать с количеством данных.

Например, можно расширить наш тест, переписав его с double и int:

@RunWith(Theories.class)
public class PowTheoryTest {
    @DataPoint public static double A=4;
    @DataPoint public static int B=-5;
    @DataPoint public static double C=17;
    @DataPoint public static int D=22;
    @DataPoint public static double E=-8;
    @DataPoint public static int F=1;
    @DataPoint public static double G=-1;
    @DataPoint public static int H=0;


    @Theory
    public void isPositive(int base, int exponent) {
        assumeTrue(exponent%2 == 0);
        assertThat((int)Math.pow(base, exponent), anyOf(greaterThan(0), is(0)));
    }


    @Theory
    public void isPositive(double base, int exponent) {
        assumeTrue(exponent%2 == 0);
        assertThat((int)Math.pow(base, exponent), anyOf(greaterThan(0), is(0)));
    }
}

Обратите внимание, что теория не предполагает конкретных результатов, только общее утверждение.

Один тип, разные значения

В примере с параметризованными тестами я рассматривал класс, вычисляющий длину дуги большого круга:

    public static double distance(
                final double latDeparture, final double lonDeparture,
                final double latDestination, final double lonDestination
    )

Это вычисление имеет два утверждения: для всех возможных значений координат, длина большого круга не может быть отрицательной и не может превышать 20001 километров.

Когда мы начнём писать эту теорию, обнаружится, что написать её нельзя. Дело в том, что в функцию вычисления длины передаётся 4 числа одного типа, но имеющих два различных смысла и области определения(широта и долгота). JUnit о значениях не знает и будет подставлять все комбинации, в том числе некорректные.

Данные, одинаковые по типу, но разные по смыслу, можно разделить вручную:

    @Theory
    public void constrainGreatCircleLength(
            @Latitudes double latDeparture, @Longitudes double lonDeparture,
            @Latitudes double latDestination, @Longitudes double lonDestination) {
            assertThat(
                    (int)GreatCircle.distance(latDeparture, lonDeparture, latDestination, lonDestination),
                    allOf(greaterThanOrEqualTo(0), lessThanOrEqualTo(20001)));
    }

Аннотации @Latitudes и @Longitudes связывают значения для теста с их источниками:

    @ParametersSuppliedBy(LongitudesSupplier.class)
    public @interface Longitudes {}


    @ParametersSuppliedBy(LatitudesSupplier.class)
    public @interface Latitudes {}


    public class LatitudesSupplier extends ParameterSupplier {
        @Override
        public List<PotentialAssignment> getValueSources(ParameterSignature parameterSignature) {
            List<PotentialAssignment> result = new ArrayList<PotentialAssignment>();


            result.add(PotentialAssignment.forValue("55.596111", 55.596111));
            result.add(PotentialAssignment.forValue("0", 0));
            result.add(PotentialAssignment.forValue("-90", -90));
            result.add(PotentialAssignment.forValue("59.8002778", 59.8002778));
            result.add(PotentialAssignment.forValue("90", 90));


            return result;
        }
    }


    public class LongitudesSupplier extends ParameterSupplier {
        @Override
       public List<PotentialAssignment> getValueSources(ParameterSignature parameterSignature) {
            List<PotentialAssignment> result = new ArrayList<PotentialAssignment>();


            result.add(PotentialAssignment.forValue("37.2675", 37.2675));
            result.add(PotentialAssignment.forValue("0", 0));
            result.add(PotentialAssignment.forValue("-180", -180));
            result.add(PotentialAssignment.forValue("30.2625", 30.2625));
            result.add(PotentialAssignment.forValue("180", 180));


            return result;
        }
    }

Сами источники значений весьма просты — они возвращают массив значений, завёрнутых в PotentinalAssignment. Для создания PotentionalAssignment надо передать ему имя значения и собственно значение.

Этот же подход можно использовать и для того, чтобы не засорять класс теста наборами данных.

Скачать код примера

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *