Pull to refresh

Использование junit-quickcheck на простом примере в практике TDD

Reading time 8 min
Views 7.5K
В очередной раз упражняясь в практике TDD на Java я создал небольшой учебный проект, в котором показал, что если тесты проходят, то это еще не значит, что код выполняется правильно. На самом деле, разработка модульных тестов, в которых учитываются различные варианты входных данных это кропотливая работа. Для решения возникшей проблемы нашел для себя библиотеку junit-quickcheck, опытом использования которой спешу поделиться.

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

Первые шаги


Итак, я создал новый пустой проект на Java и сделал сборочный скрипт. У меня сборка данного проекта происходит при помощи gradle. Вы можете посмотреть мой учебный проект здесь. Начиная вот с этого коммита: d9bd97eaec20427e725f9a4c3ff0c0d36cc27ea3 проект уже готов для сборки и реализации нашего тестового примера.

Теперь я должен начать писать код следуя практике TDD. Некоторые апологеты TDD считают, следует начинать разработку класса с написания теста для этого класса. Но здесь я бы с ними поспорил. Моя IDE (IntelliJ IDEA) умеет генерировать тесты при наличии готового класса, поэтому я начну разработку с создания пустого класса:

public class HelloSayer {
}

Здесь и далее код иллюстрируется ссылками на коммит на github.

А вот теперь я попрошу свою IDE создать мне тест и далее начну работать по схеме test-first. Первый тест, который я сделаю, это очень простой тест, при помощи которого я просто создаю новый объект. Я хочу указывать с кем нужно поздороваться в конструкторе. Более того, я считаю, что объект выдающий приветствие может выдавать это приветствие, только одному. Поэтому, конструктора без параметра у этого класса не будет.

Вот созданный мною первый тест:

    @Test
    public void testCreating() throws Exception {
        new HelloSayer("World");
    }

Обратите внимание, что в этой точке проект не компилируется, то есть, тест красный.

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

public class HelloSayer {
   public HelloSayer(String whom) {
   }
}

Обратите внимание, что тест, конечно, проходит, но фактически, мой класс не делает того, что я от него хотел. Работаем дальше.

Создаю тест для метода getWhom — это геттер, при помощи которого я смогу узнать кому этот объект будет выдавать приветствие:

    @Test
    public void testWhomGetter() throws Exception {
        HelloSayer sayer = new HelloSayer("World");
        assertEquals("World", sayer.getWhom());
    }

Проект опять не компилируется, то есть, тест опять красный.

Продолжим.
Реализую требуемый геттер:

public class HelloSayer {
    private String whom;

    public HelloSayer(String whom) {
    }

    public String getWhom() {
        return whom;
    }
 }

Проект уже компилируется, но тест не проходит. Для того, чтобы тест проходил, нам теперь придется и конструктор реализовать по-настоящему, а не так, как это я сделал раньше:

     public HelloSayer(String whom) {
        this.whom = whom;
     }

Тест опять зеленый.

Маленькое, но важное пояснение. В этом учебном примере я делаю коммиты в репозитарий очень часто. Это я делаю для того, чтобы показать читателю последовательность моих шагов. Делать коммиты настолько часто в реальном проекте не обязательно.

Хотя я сторонник того, чтобы коммит был «атомарным». Что такое атомарный коммит, можно почитать, например, здесь: seesparkbox.com/foundry/atomic_commits_with_git. Крато говоря: до тех пор, пока Вы можете сформулировать, что именно Вы сделали, одним предложением это атомарный коммит.

Зеленый тест с неправильной реализацией


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

    @Test
    public void testGreetingString() throws Exception {
        HelloSayer sayer = new HelloSayer("World");
        assertEquals("Hello \"World\"", sayer.getGreetingString());
    }

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

Реализуем требуемый метод:

    public String getGreetingString() {
        return "Hello \"World\"";
    }

Тест зеленый. Я добился прохождения теста очень просто! Кстати, при реализации метода getWhom я мог сделать также, но в тот раз я сделал все по честному, а здесь поленился.

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

Но, во вторых, есть случаи, когда тесты и функциональность пишут все же разные люди. А во первых, бывают гораздо более сложные случаи, когда разработчик может просто не предусмотреть все возможные варианты входных данных в тестах.

На самом деле, для того, что бы избежать такой некорректной реализации достаточно было бы сделать второй тест с другой строкой whom. Но я попытаюсь решить этот вопрос для максимально общего случая. Решение задачи в общем виде требует использовать множество различных строк. То есть, надо проводить тест не на одной строке «World», как я это сделал, а на разных строках.

Для решения проблемы в общем виде я использовал стороннюю библиотеку junit-quickcheck: github.com/pholser/junit-quickcheck, которая построена на базе Theories из JUnit. Подключаю ее к моему проекту.

     dependencies {
         testCompile 'junit:junit:4.+'
+        testCompile 'com.pholser:junit-quickcheck-core:0.5+'
+        testCompile 'com.pholser:junit-quickcheck-generators:0.5+'
     }


На момент написания статьи версия 0.5, которую я здесь использую, имеет статус alpha, но в ней заявлена поддержка Java 8, которую я использую для этого проекта.

Используя эту библиотеку я реализовал заново тестовой метод для проверки метода greetingString:

    @Theory
    public void greetingString(
            @ForAll String whom
    ) {
        HelloSayer sayer = new HelloSayer(whom);
        assertEquals(String.format("Hello \"%s\"", whom), sayer.getGreetingString());
    }

Разберемся что делает этот код. Аннотация Theory при методе указывает, что это параметризованный тестовый метод, построенный на базе therories. Аннотация ForAll показывает, что данный параметр будет генерируемый. junit-quickcheck из коробки умеет генерировать значения для многих типов данных, в том числе и для строк.

Запускаем тест и теперь он красный, как нам и надо. Теперь я исправлю мою реализацию метода getGreetingString:

    public String getGreetingString() {
        return String.format("Hello \"%s\"", whom);
    }

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

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

Маленький завершающий штришек этого раздела, я добавил к своему проекту контроль покрытия кода. Использую для этого Jacoco. Прогоняю, смотрю отчет и радуюсь 100% покрытию кода.

Тестирование разных реализаций


Теперь начинаем развивать наш «очень важный» класс HelloSayer. Мне не очень нравится его реализация.
А конкретно, мне не нравится String.format при каждом вызове метода getGreetingString. Однако, есть ситуации, когда это будет ОК, а есть ситуации когда меня это не будет устраивать.

Поэтому, я вынесу интерфейс HelloSayer и сделаю несколько его реализаций. Здесь я опять воспользовался возможностями IntelliJ IDEA по рефакторингу. Вот что у меня получилось в результате. Теперь HelloSayer стал интерфейсом, а реализация, которая там была ушла в класс HelloSayerInplace.

Интерфейс и реализация
Интерфейс:
package info.risik.books.tdd.HelloWorld;

public interface HelloSayer {
    String getWhom();
 
    String getGreetingString();
}

Реализация:

package info.risik.books.tdd.HelloWorld;

public class HelloSayerInplace implements HelloSayer {
    private String whom;

    public HelloSayerInplace(String whom) {
        this.whom = whom;
    }

    @Override
    public String getWhom() {
        return whom;
    }

    @Override
    public String getGreetingString() {
        return String.format("Hello \"%s\"", whom);
    }
}


А теперь сделаю еще одну реализацию этого интерфейса, которая создает строку приветствия прямо в конструкторе и хранит ее в объекте. Тест я тоже реализовал, спасибо копировать-и-вставить и поиск-и-замена:

Еще одна реализация итерфейса
Реализация класса HelloSayerAtOnce.

package info.risik.books.tdd.HelloWorld;

public class HelloSayerAtOnce implements HelloSayer {
    private String whom;
    private String message;

    public HelloSayerAtOnce(String whom) {
        this.whom = whom;
        this.message = String.format("Hello \"%s\"", whom);
    }

    @Override
    public String getWhom() {
        return whom;
    }

    @Override
    public String getGreetingString() {
        return message;
    }
}

Модульный тест для него:

package info.risik.books.tdd.HelloWorld;

import com.pholser.junit.quickcheck.ForAll;
import junit.framework.TestCase;
import org.junit.contrib.theories.Theories;
import org.junit.contrib.theories.Theory;
import org.junit.runner.RunWith;

@RunWith(Theories.class)
public class HelloSayerAtOnceTest extends TestCase {

    @Theory
    public void testCreating(
            @ForAll String whom
    ) {
        new HelloSayerAtOnce(whom);
    }

    @Theory
    public void testWhomGetter(
            @ForAll String whom
    ) {
        HelloSayer sayer = new HelloSayerAtOnce(whom);
        assertEquals(whom, sayer.getWhom());
    }

    @Theory
    public void greetingString(
            @ForAll String whom
    ) {
        HelloSayer sayer = new HelloSayerAtOnce(whom);
        assertEquals(String.format("Hello \"%s\"", whom), sayer.getGreetingString());
    }
}


Здесь есть проблема. Я хочу еще третью реализацию этого же интерфейса, и соответственно, мне придется сделать третий тестовый класс, опять через тот же механизм копировать-и-вставить и поиск-и-замена. Это плохо. Все классы, реализующие этот интерфейс, независимо от того, как именно они реализованы внутри, должны выполнять одно и то же соглашение, которое я уже описал в тесте. Я хочу соблюдать принцип DRY (don't repeat youself). То есть, я хочу применить один и тот же тест ко всем возможным реализациям этого интерфейса.

Здесь мне на помощь опять пришел junit-quickchecker. Вот модифицированный тест:

@RunWith(Theories.class)
public class HelloSayerTest {

    enum HelloSayerType {
        InPlace,
        AtOnce,
    }

//...
    @Theory
    public void testWhomGetter(
            @ForAll String whom,
            @ForAll @ValuesOf HelloSayerType sayerType
    ) throws Exception {
        HelloSayer sayer = getFactory(sayerType, whom);
        assertEquals(whom, sayer.getWhom());
    }
//...
    private HelloSayer getFactory(HelloSayerType type, String whom) throws Exception {
        switch (type) {
            case InPlace:
                return new HelloSayerInplace(whom);
            case AtOnce:
                return new HelloSayerAtOnce(whom);
        }
        throw new Exception("Unknown HelloSayerType");
    }
}

Я добавил один генерируемый параметр в тестовый метод: тип класса (в виде перечисления). Перечисления в junit-checker поддерживаются из коробки. Для этого мне нужно всего лишь добавить аннотацию @ValuesOf. И, фактически, все что мне нужно было сделать, это метод для создания экземпляра одного из классов.

Перереализую все тестовые методы с помощью такого параметра. Отдельный тестовый класс для нового класса мне теперь больше не нужен, удаляю его.

И на закуску, реализую третий вариант HelloSayer:

Реализация HelloSayer с ленивой инициализацией строки
package info.risik.books.tdd.HelloWorld;

public class HelloSayerLazy implements HelloSayer {
    private String whom;
    private String message;

    public HelloSayerLazy(String whom) {
        this.whom = whom;
        this.message = null;
    }

    @Override
    public String getWhom() {
        return whom;
    }

    @Override
    public String getGreetingString() {
        if (message == null) {
            makeMessage();
        }
        return message;
    }

    private void makeMessage() {
        message = String.format("Hello \"%s\"", whom);
    }
}


Теперь для реализации всех тестов для этого класса мне нужно добавить только 3 строки:

     enum HelloSayerType {
         InPlace,
         AtOnce,
+        Lazy,
     }
 
     @Theory
 @@ -53,6 +54,8 @@ private HelloSayer getFactory(HelloSayerType type, String whom) throws Exception
                 return new HelloSayerInplace(whom);
             case AtOnce:
                 return new HelloSayerAtOnce(whom);
+            case Lazy:
+                return new HelloSayerLazy(whom);
         }
         throw new Exception("Unknown HelloSayerType");
     }

Проверю, что покрытие кода все еще 100%. Все отлично!

Ссылки и прочая необходимая информация


Тестовые примера на Java: github.com/risik/tdd-book-java
Текст оригинальной статьи: github.com/risik/tdd-book/blob/master/helloworld/simple_test.ru.md
Текст статьи, подготовленной для habrahabr.ru: github.com/risik/tdd-book/blob/master/helloworld/simple_test.ru.habr.txt
Лицензия: CC-BY-NC-SA
Tags:
Hubs:
+4
Comments 2
Comments Comments 2

Articles