Статьи

Библиотекарь: введение в разработку через тестирование

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

Код, связанный с этой статьей, можно найти на GitHub . Будущие и прошедшие платежи можно найти в Библиотечном архиве .

Я постараюсь реализовать несколько требований для модуля Library с книгами и членством, расширяя любой код, который мы имеем, в стиле, основанном на тестировании («TDD»), по мере продвижения вперед. Я поделюсь несколькими мыслями о процессе, покажу некоторые рефакторинги и дам несколько советов по использованию IDE.

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

TDD

Существует много информации, описывающей, что такое TDD или разработка через тестирование, цикл «красный-зеленый-рефактор» и т. Д., Поэтому я не буду вдаваться в подробности здесь. См. Ссылки в конце для получения дополнительной справочной информации.

Вместо этого, просто начните!

Мы начинаем с проекта Gradle со следующим файлом build.gradle :

1
2
3
4
5
6
7
8
9
apply plugin: 'java'
 
repositories {
    jcenter()
}
 
dependencies {
    testCompile 'junit:junit:4.12'
}

Это говорит о том, что мы находимся в чистом проекте Java, можем завершить наши тесты в src/test/java а исходники — в src/main/java и у нас есть зависимость от JUnit — нашей предпочтительной среды тестирования. Я мог бы выбрать TestNG или Spock, но это тема другого поста.

Первая история в микро-шагах

Мы ведем код по тестам и тестируем по требованиям . Это цикл, который мы повторяем.

Если бы мы были в гибком проекте, требования могли бы прийти в форме пользовательской истории, такой как:

Как библиотекарь,
Я хочу, чтобы любящие книги люди стали членами библиотеки
Так что я могу дать им книги в будущем

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

Таким образом, у вас может возникнуть желание погрузиться и создать, например, Library класс и код, но мы не будем этого делать!

Провести неудачный тест

Начнем с неудачного теста.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
package example;
 
import static org.junit.Assert.*;
 
import org.junit.Test;
 
public class LibraryTest {
 
    @Test
    public void test() {
        fail("Not yet implemented");
    }
 
}

Открытый метод test() , аннотированный аннотацией @Test JUnit. Здесь вы можете увидеть статически импортированный метод fail() дает нам твердую ошибку, когда мы запускаем класс LibraryTest — либо в нашей IDE, либо через Gradle.

1
2
3
4
java.lang.AssertionError: Not yet implemented
    at org.junit.Assert.fail(Assert.java:88)
    at example.LibraryTest.test(LibraryTest.java:11)
    <snip>

Намеренное имя

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

Давайте назовем это что-то вроде shouldRegisterMembers() .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
package example;
 
import static org.junit.Assert.*;
 
import org.junit.Test;
 
public class LibraryTest {
 
    @Test
    public void shouldRegisterMembers() {
        fail("Not yet implemented");
    }
 
}

(Да, вы можете запустить его, но все равно не получится)

Хорошо, но что теперь? У меня нет кода для вызова

Ну, да. Мы собираемся изменить это.

С помощью серии хорошо выполненных рефакторингов, которые , как мы надеемся, выполнит ваша IDE, мы создадим достаточно кода для прохождения теста . Тем самым мы создадим наш производственный код; код для класса Library который еще не существует . Правило на данный момент таково: если нет теста, требующего кусок производственного кода, мы не будем его писать .

Нам нужен класс Library для регистрации членов. Поэтому я записываю единственный код, который мне нужен в данный момент.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
package example;
 
import static org.junit.Assert.*;
 
import org.junit.Test;
 
public class LibraryTest {
 
    @Test
    public void shouldRegisterMembers() {
 
        // given
        Library library = new Library();
    }
 
}

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

Например, в Eclipse вы можете выбрать Quick Fix , который называется «Создать библиотеку классов».

Используйте IDE, Люк!

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

Наша недавно созданная Library выглядит так

1
2
3
4
package example;
 
public class Library {
}

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

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

Что может быть лучше для выражения этого вызова метода с именем: registerMember ? Мы пока мало знаем о члене, но сейчас я даю ему или ей имя — простую String нужно передать, чтобы идентифицировать участника.

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

Итак, мы регистрируем участника, предоставив пример значения «Тед». Код теперь будет выглядеть так:

01
02
03
04
05
06
07
08
09
10
11
12
13
public class LibraryTest {
 
    @Test
    public void shouldRegisterMembers() {
 
        // given
        Library library = new Library();
 
        // when
        library.registerMember("Ted");
    }
 
}

LibraryTest снова не компилируется больше. Компилятор будет жаловаться:

The method registerMember(String) is undefined for the type Library

Создать недостающий метод

Используйте IDE для создания этого метода в классе Library . Не так много на что смотреть? Теперь мы собираемся сделать что-то новое: добавить к нему Javadoc, описав, что он делает .

Еще немного, но LibraryTest снова компилируется .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
package example;
 
public class Library {
 
    /**
     * Registers a new member using provided name.
     *
     * @param name
     *            The name of the member
     */
    public void registerMember(String name) {
    }
 
}

Тест тоже проходит .

Так как мы до сих пор не уверены, что реализовали нашу логику (поскольку мы еще ничего не реализовали на самом деле), давайте спроектируем вещи таким образом, чтобы, если регистрация члена прошла успешно, библиотека даст нам новый, полный Member обратно.

Мы настраиваем тест следующим образом: метод registerMember возвращает успешно созданный элемент, поэтому мы можем проверить, совпадает ли имя элемента с тем, которое мы указали.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
public class LibraryTest {
 
    @Test
    public void shouldRegisterMembers() {
 
        // given
        Library library = new Library();
 
        // when
        Member newMember = library.registerMember("Ted");
 
        // then check for member's name to be same
    }
 
}

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

1
2
3
public class Member {
 
}
01
02
03
04
05
06
07
08
09
10
11
12
13
public class Library {
 
    /**
     * Registers a new member using provided name.
     *
     * @param name
     *            The name of the member
     * @return
     */
    public Member registerMember(String name) {
    }
 
}

Можем ли мы на самом деле продолжать изменять и создавать подобные вещи, управляемые тестами?

Да.

Почти готово. Если мы хотим проверить имя участника, мы можем использовать совпадения Hamcrest equalTo и с getName() еще предстоит сделать, — чтобы сравнить имя «Ted» из входных данных с именем в Member .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package example;
 
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.*;
 
import org.junit.Test;
 
public class LibraryTest {
 
    @Test
    public void shouldRegisterMembers() {
 
        // given
        Library library = new Library();
 
        // when
        Member newMember = library.registerMember("Ted");
 
        // then
        assertThat(newMember.getName(), is(equalTo("Ted")));
    }
 
}

Теперь, что нам нужно, чтобы пройти тест?

  • Для компиляции классу Member понадобится getName() именем getName() . По соглашению, но также необходимый для кода, который мы поместим в registerMember , сделайте name конечным свойством (неизменяемым, без установщика), которое мы инициализируем конструктором. Выполните двойной удар и сделайте так:
01
02
03
04
05
06
07
08
09
10
11
12
13
public class Member {
 
    private final String name;
 
    public Member(String name) {
        this.name = name;
    }
 
    public String getName() {
        return name;
    }
 
}
  • Создайте пользователя и верните его.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
public class Library {
 
    /**
     * Registers a new member using provided name.
     *
     * @param name
     *            The name of the member
     * @return registered member
     */
    public Member registerMember(String name) {
        return new Member(name);
    }
 
}

Уф! Тест проходит.

Похоже, что мы выполнили наше требование согласно пользовательской истории, которая была

Как библиотекарь,
Я хочу, чтобы любящие книги люди стали членами библиотеки
Так что я могу дать им книги в будущем

Выполнено ли требование?

В качестве небольшого дополнения: я смотрю на историю и вижу «членов» во множественном числе. Так как мой тест проверяет библиотеку только для одного члена , я беру на себя смелость и проверяю наличие большего количества членов.

Минимальное количество проверенных регистраций участников — мне нужно быть достаточно уверенным в том, что библиотека поддерживает «членов» (во множественном числе) — это два , верно?

Немного изменил тест, чтобы включить Теда и Боба.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package example;
 
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.*;
 
import org.junit.Test;
 
public class LibraryTest {
 
    @Test
    public void shouldRegisterMembers() {
 
        // given
        Library library = new Library();
 
        // when
        Member newMember1 = library.registerMember("Ted");
        Member newMember2 = library.registerMember("Bob");       
 
        // then
        assertThat(newMember1.getName(), is(equalTo("Ted")));
        assertThat(newMember2.getName(), is(equalTo("Bob")));
    }
 
}

Тест проходит.

2-я история — несколько быстрее

Давайте реализуем второй пользовательский рассказ. Это выглядит так:

Как бухгалтер,
Я хочу, чтобы человек мог зарегистрироваться только один раз
Чтобы у меня не было нескольких участников для одного человека

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

Немного дизайна

Давайте создадим новый тест с именем shouldNotRegisterAgainWhenAlreadyMember — умный, да?

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package example;
 
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.*;
 
import org.junit.Test;
 
public class LibraryTest {
 
    @Test
    public void shouldRegisterMembers() { ... }
 
    @Test
    public void shouldNotRegisterAgainWhenAlreadyMember() {
 
        // given
        Library library = new Library();
        library.registerMember("Ted");
 
        // when we register with same name
        library.registerMember("Ted");
 
        // then we should see it fail somehow
    }
}

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

Мы создадим AlreadyMemberException — который метод registerMember() может AlreadyMemberException чтобы указать это исключительное событие.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
public class LibraryTest {
 
    @Test
    public void shouldRegisterMembers() { ... }
 
    @Test
    public void shouldNotRegisterAgainWhenAlreadyMember() {
 
        // given
        Library library = new Library();
        library.registerMember("Ted");
 
        // when we register with same name
        try {
            library.registerMember("Ted");
            fail("should not have registered Ted twice");
        } catch (AlreadyMemberException e) {
            // success!
        }
 
    }
}

Что случается?

  • В рамках настройки теста (под //given ) библиотека начинается с существующего члена с именем «Ted»
  • Когда библиотеке говорят («не спрашивайте») зарегистрировать другого члена с тем же именем «Ted», мы ожидаем, что будет AlreadyMemberException : Library не должна разрешать нескольким членам с одним именем
  • Если registerMember("Ted") не выдает исключение, мы провалим тест на fail()
  • Возможно, существует более элегантный способ ожидать появления определенного исключения, но мы не хотим забегать вперед

В данный момент тест не пройден — исключение не выдается, поскольку мы еще не обновили Library .

Давайте сделаем это сейчас.

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

Самое простое решение (KISS) — использовать для этого структуру данных из Collections Framework. Любая Collection , такая как ArrayList , имеет такие методы, как contains (для проверки наличия) и add — для удовлетворения всех наших потребностей:

  • проверить для члена
  • добавить участника

Наше решение будет выглядеть так:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package example;
 
import java.util.ArrayList;
import java.util.Collection;
 
public class Library {
 
    private final Collection<Member> members = new ArrayList<>();
 
    /**
     * Registers a new member using provided name.
     *
     * @param name
     *            The name of the member
     * @return registered member
     */
    public Member registerMember(String name) {
 
        Member newMember = new Member(name);
 
        if (members.contains(newMember)) {
            throw new AlreadyMemberException();
        }
 
        members.add(newMember);
        return newMember;
    }
 
}

Запустите наш метод тестирования и … увидеть, что он все еще не работает . WAT?

Знай свои рамки

Никто не выдает исключение , но код достаточно прост, чтобы ожидать, что он contains и add Just Just Work. Это недостаток самого теста?

Нет, нужно знать, чтобы при размещении таких объектов, как Member в Collection и мы хотим, чтобы проверка Collection на равенство (а не идентичность ) нам была нужна для реализации equals() и hashCode() в Member .

Вы можете сгенерировать эти методы с помощью любой приличной среды IDE (или вызвать для этого вспомогательные методы из вспомогательных сред), но приведенного ниже кода (сгенерированного Eclipse) будет достаточно:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package example;
 
public class Member {
 
    private final String name;
 
    public Member(String name) {
        this.name = name;
    }
 
    public String getName() {
        return name;
    }
 
    @Override
    public int hashCode() {
        final int prime = 31;
        int result = 1;
        result = prime * result + ((name == null) ? 0 : name.hashCode());
        return result;
    }
 
    @Override
    public boolean equals(Object obj) {
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if (getClass() != obj.getClass())
            return false;
        Member other = (Member) obj;
        if (name == null) {
            if (other.name != null)
                return false;
        } else if (!name.equals(other.name))
            return false;
        return true;
    }
 
}

Тест проходит!

Refactor

Теперь, когда у нас есть тестовое покрытие, мы можем сделать что-то, что мы упустили до сих пор: рефакторинг !

Правила таковы:

  1. Сделай так, чтоб это работало
  2. Сделать это лучше (быстрее и т. Д.)
  3. Сделайте его читаемым (СУХОЙ, ремонтопригодным и т. Д.)

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

Это всего лишь примеры, но мы могли бы …

Упростить реализацию

Вероятно, мы можем упростить нашу проверку существования, избавиться от contains и использовать возвращаемое значение add напрямую — если мы конвертируем в тип Collection, который сам по себе предотвращает дублирование .

Вероятно, мы можем заменить ArrayList на HashSet и add вернет false если коллекция членов не позволит добавить члена, который у нее уже есть. Это будет выглядеть так:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package example;
 
import java.util.Collection;
import java.util.HashSet;
 
public class Library {
 
    private final Collection<Member> members = new HashSet<>();
 
    /**
     * Registers a new member using provided name. [...]
     */
    public Member registerMember(String name) {
 
        Member newMember = new Member(name);
 
        if (!members.add(newMember)) {
            throw new AlreadyMemberException();
        }
 
        return newMember;
    }
 
}

Упростить тесты

Ха, вы думали, что только реализация потребует работы? Нет, также тесты — это каждая итерация test-fix-refactor, подходящая для доработки.

Например, если мы посмотрим на часть, которая одинакова в каждом методе тестирования в LibraryTest то это создание new Library() .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
public class LibraryTest {
 
    @Test
    public void shouldRegisterMembers() {
 
        // given
        Library library = new Library();
        ...
    }
 
    @Test
    public void shouldNotRegisterAgainWhenAlreadyMember() {
 
        // given
        Library library = new Library();
        ...
 
    }
}

Мы можем применить рефакторинг под названием Convert Local Variable to Field и инициализировать наше поле перед каждым тестом, используя аннотацию @Before JUnit.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class LibraryTest {
 
    private Library library;
 
    @Before
    public void setUp() {
        library = new Library();
    }
 
    @Test
    public void shouldRegisterMembers() {
 
        // when
        Member newMember1 = library.registerMember("Ted");
        Member newMember2 = library.registerMember("Bob");       
 
        ...
    }
 
    @Test
    public void shouldNotRegisterAgainWhenAlreadyMember() {
 
        // given
        library.registerMember("Ted");
 
        ...
 
    }
}

Тесты проходят!

(Тебе, наверное, интересно, когда я к этому подойду :-))
И последнее, но не менее важное: есть способ упростить ожидание определенного исключения с помощью JUnit : правило ExpectedException которое действительно избавляет от беспорядка в нашем последнем тесте.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package example;
 
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertThat;
 
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
 
public class LibraryTest {
 
    @Rule
    public ExpectedException thrown = ExpectedException.none();
 
    private Library library;
 
    @Before
    public void setUp() {
        library = new Library();
    }
 
    @Test
    public void shouldRegisterMembers() { ... }
 
    @Test
    public void shouldNotRegisterAgainWhenAlreadyMember() {
 
        // given
        library.registerMember("Ted");
 
        // fail when we register with same name
        thrown.expect(AlreadyMemberException.class);
        library.registerMember("Ted");
 
    }
}

Тесты проходят!

В заключении

Я согласен с некоторыми выводами, сделанными Джеймсом Шором в статье («Как TDD влияет на дизайн») много-много лет назад: TDD может привести к улучшению дизайна, TDD может привести к ухудшению дизайна. Перспектива TDD — только одна из многих в области тестирования и будет долгожданным дополнением в любом поясе инструмента. Я хотел бы верить, что мы не создали никакого производственного кода, который не был протестирован, но я еще не запускал никакого инструмента покрытия кода, чтобы проверить, охватил ли я все пути — но это не было целью на данный момент так или иначе

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

использованная литература