Статьи

Превращение утверждений в предметно-ориентированный язык

Утверждения являются неотъемлемой частью наших модульных тестов. И все же, ими так легко пренебречь. Это позор, потому что если мы упустим из виду важность утверждений, раздел assert наших тестов станет длинным и грязным. К сожалению, большинство тестов, которые я видел (и писал), страдают от этой проблемы.

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

Что проверено?

Начнем с краткого обзора тестируемого класса.

Класс Person — это класс, который содержит информацию об одном человеке. Он имеет четыре поля ( id , email , firstName и lastName ), и мы можем создавать новые объекты Person с помощью шаблона построителя .

Исходный код класса Person выглядит следующим образом:

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
41
42
43
44
45
public class Person {
 
    private Long id;
 
    private String email;
 
    private String firstName;
 
    private String lastName;
 
    private Person() {
 
    }
 
    public static PersonBuilder getBuilder(String firstName, String lastName) {
        return new PersonBuilder(firstName, lastName);
    }
 
    //Getters are omitted for the sake of clarity
    
    public static class PersonBuilder {
 
        Person build;
 
        private PersonBuilder(String firstName, String lastName) {
            build = new Person();
            build.firstName = firstName;
            build.lastName = lastName;
        }
 
        public PersonBuilder email(String email) {
            build.email = email;
            return this;
        }
 
        public PersonBuilder id(Long id) {
            build.id = id;
            return this;
        }
 
        public Person build() {
            return build;
        }
    }
}

Зачем беспокоиться?

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

  1. Создайте новый объект Person с помощью класса builder.
  2. Напишите утверждения, используя метод assertEquals () класса Assert .

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
import org.junit.Test;
 
import static org.junit.Assert.assertEquals;
 
public class PersonTest {
 
    @Test
    public void build_JUnitAssertions() {
        Person person = Person.getBuilder("Foo", "Bar")
                .email("[email protected]")
                .id(1L)
                .build();
 
        assertEquals(1L, person.getId().longValue());
        assertEquals("Foo", person.getFirstName());
        assertEquals("Bar", person.getLastName());
        assertEquals("[email protected]", person.getEmail());
    }
}

Этот модульный тест короткий и довольно чистый, но у стандартных утверждений JUnit есть две большие проблемы:

  • Когда количество утверждений растет, увеличивается и длина метода теста. Это может показаться очевидным, но большой раздел утверждения затрудняет понимание теста. Становится трудно понять, чего мы хотим достичь с помощью этого теста.
  • Стандартные утверждения JUnit говорят не на том языке. Стандартные утверждения JUnit говорят на «техническом» языке. Это означает, что эксперты домена не могут понять наши тесты (и мы не можем).

Мы можем сделать лучше. Намного лучше.

FEST-Assert на помощь

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

  1. Вызовите статический метод assertThat () класса Assertions и передайте фактическое значение в качестве параметра метода. Этот метод возвращает объект подтверждения. Объект утверждения является экземпляром класса, который расширяет класс GenericAssert .
  2. Укажите утверждение, используя методы объекта подтверждения. Доступные нам методы зависят от типа возвращаемого объекта (метод assertThat () класса Assertions является перегруженным методом, а тип возвращаемого объекта зависит от типа параметра метода).

Когда мы переписываем наш модульный тест с использованием FEST-Assert, его исходный код выглядит следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
import org.junit.Test;
 
import static org.fest.assertions.Assertions.assertThat;
 
public class PersonTest {
 
    @Test
    public void build_FESTAssert()  {
        Person person = Person.getBuilder("Foo", "Bar")
                .email("[email protected]")
                .id(1L)
                .build();
 
        assertThat(person.getId()).isEqualTo(1L);
        assertThat(person.getFirstName()).isEqualTo("Foo");
        assertThat(person.getLastName()).isEqualTo("Bar");
        assertThat(person.getEmail()).isEqualTo("[email protected]");
    }
}

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

Другая проблема заключается в том, что сообщение по умолчанию, которое отображается в случае сбоя утверждения, не очень читабельно. Например, если имя пользователя «Бар», отображается следующее сообщение:

1
expected:<'[Foo]'> but was:<'[Bar]'>

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

Задание пользовательских сообщений для утверждений FEST-Assert

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

  1. Создайте сообщение об ошибке с помощью метода format () класса String .
  2. Вызовите статический метод assertThat () класса Assertions и передайте фактическое значение в качестве параметра метода. Этот метод возвращает объект подтверждения. Объект утверждения является экземпляром класса, который расширяет класс GenericAssert .
  3. Вызовите метод overridingErrorMessage () класса GenericAssert и передайте созданное сообщение об ошибке в качестве параметра метода.
  4. Укажите утверждение, используя методы объекта подтверждения. Доступные нам методы зависят от типа возвращаемого объекта (метод assertThat () класса Assertions является перегруженным методом, а тип возвращаемого объекта зависит от типа параметра метода).

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

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
import org.junit.Test;
 
import static org.fest.assertions.Assertions.assertThat;
 
public class PersonTest {
 
    @Test
    public void build_FESTAssert_CustomMessages() {
        Person person = Person.getBuilder("Foo", "Bar")
                .email("[email protected]")
                .id(1L)
                .build();
 
        String idMessage = String.format("Expected id to be <%d> but was <%d>", 1L, person.getId());
        assertThat(person.getId())
                .overridingErrorMessage(idMessage)
                .isEqualTo(1L);
 
        String firstNameMessage = String.format("Expected firstName to be <%s> but was <%s>", "Foo", person.getFirstName());
        assertThat(person.getFirstName())
                .overridingErrorMessage(firstNameMessage)
                .isEqualTo("Foo");
 
        String lastNameMessage = String.format("Expected lastName to be <%s> but was <%s>", "Bar", person.getLastName());
        assertThat(person.getLastName())
                .overridingErrorMessage(lastNameMessage)
                .isEqualTo("Bar");
 
        String emailMessage = String.format("Expected email to be <%s> but was <%s>", "[email protected]", person.getEmail());
        assertThat(person.getEmail())
                .overridingErrorMessage(emailMessage)
                .isEqualTo("[email protected]");
    }
}

Если имя пользователя «Бар», отображается следующее сообщение:

1
Expected firstName to be <Foo> but was <Bar>

Мы исправили одну проблему, но наше исправление вызвало другую проблему:

Тест не читается! Это намного хуже, чем наши предыдущие тесты!

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

Создание предметно-ориентированного языка

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

Домен-специфический язык (DSL) — это компьютерный язык, специализированный для конкретного домена приложения.

Следуя этому определению, мы получаем следующие требования для нашего языка, специфичного для нашего домена:

  1. Он должен говорить на языке, понятном специалистам в области. Например, имя человека не равно «Foo». У человека есть имя «Фу».
  2. Утверждения должны иметь пользовательские сообщения об ошибках, которые используют язык, специфичный для домена.
  3. Он должен иметь свободный API. Другими словами, должно быть возможно связать утверждения.

Примечание. Если вы хотите получить больше информации о реализации языка, специфичного для предметной области, с помощью Java, прочитайте статьи под названием «Подход к внутренним языкам, специфичным для предметной области, в Java» и «Ускоренный курс по разработке API Java Fluent API» .

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

  1. Создайте класс PersonAssert .
  2. Расширьте класс GenericAssert и предоставьте следующие параметры типа:
    1. Первый параметр типа указывает тип пользовательского класса утверждения. Установите значение этого параметра типа PersonAssert .
    2. Второй параметр типа указывает тип фактического значения. Установите значение этого параметра типа Person .
  3. Создайте конструктор, который принимает объект Person в качестве аргумента конструктора. Реализуйте этот конструктор, вызвав конструктор класса GenericAssert и передав следующие объекты в качестве аргументов конструктора:
    1. Первый аргумент конструктора определяет класс пользовательского утверждения. Установите значение этого аргумента конструктора PersonAssert.class .
    2. Второй аргумент конструктора — это фактическое значение. Передайте объект Person, указанный в качестве аргумента конструктора, в конструктор суперкласса.
  4. Добавьте метод assertThat () в созданный класс. Этот метод принимает объект Person в качестве параметра метода и возвращает объект PersonAssert . Реализуйте этот метод, выполнив следующие действия:
    1. Создайте новый объект PersonAssert и передайте объект Person в качестве аргумента конструктора.
    2. Вернуть созданный объект PersonAssert .
  5. Создайте методы, которые используются для записи утверждений против фактического объекта Person . Нам нужно создать методы утверждения для полей email , firstName , id и lastName . Мы можем реализовать каждый метод, выполнив следующие действия:
    1. Убедитесь, что фактический объект Person не является нулевым, вызвав метод isNotNull () класса GenericAssert .
    2. Создайте пользовательское сообщение об ошибке с помощью метода format () класса String .
    3. Убедитесь, что значение поля объекта Person равно ожидаемому значению. Мы можем сделать это, выполнив следующие действия:
      1. Вызовите метод assertThat () класса Assertions и укажите фактическое значение поля в качестве параметра метода.
      2. Переопределите сообщение об ошибке по умолчанию, вызвав метод overridingErrorMessage () класса GenericAssert . Передайте пользовательское сообщение об ошибке в качестве аргумента метода.
      3. Убедитесь, что фактическое значение свойства равно ожидаемому значению. Мы можем сделать это, вызвав метод isEqualTo () класса GenericAssert и передав ожидаемое значение в качестве аргумента метода.
    4. Вернуть ссылку на объект PersonAssert . Это гарантирует, что мы можем связать утверждения в наших модульных тестах.

Исходный код класса PersonAssert выглядит следующим образом:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
package example;
 
import org.fest.assertions.Assertions;
import org.fest.assertions.GenericAssert;
 
public class PersonAssert extends GenericAssert<PersonAssert, Person> {
 
    protected PersonAssert(Person actual) {
        super(PersonAssert.class, actual);
    }
 
    public static PersonAssert assertThat(Person actual) {
        return new PersonAssert(actual);
    }
 
    public PersonAssert hasEmail(String email) {
        isNotNull();
 
        String errorMessage = String.format(
                "Expected email to be <%s> but was <%s>",
                email,
                actual.getEmail()
        );
 
        Assertions.assertThat(actual.getEmail())
                .overridingErrorMessage(errorMessage)
                .isEqualTo(email);
 
        return this;
    }
 
    public PersonAssert hasFirstName(String firstName) {
        isNotNull();
 
        String errorMessage = String.format(
                "Expected first name to be <%s> but was <%s>",
                firstName,
                actual.getFirstName()
        );
 
        Assertions.assertThat(actual.getFirstName())
                .overridingErrorMessage(errorMessage)
                .isEqualTo(firstName);
 
        return this;
    }
 
    public PersonAssert hasId(Long id) {
        isNotNull();
 
        String errorMessage = String.format(
                "Expected id to be <%d> but was <%d>",
                id,
                actual.getId()
        );
 
        Assertions.assertThat(actual.getId())
                .overridingErrorMessage(errorMessage)
                .isEqualTo(id);
 
        return this;
    }
 
    public PersonAssert hasLastName(String lastName) {
        isNotNull();
 
        String errorMessage = String.format(
                "Expected last name to be <%s> but was <%s>",
                lastName,
                actual.getLastName()
        );
 
        Assertions.assertThat(actual.getLastName())
                .overridingErrorMessage(errorMessage)
                .isEqualTo(lastName);
 
        return this;
    }
}

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
import org.junit.Test;
 
import static net.petrikainulainen.junit.dsl.PersonAssert.assertThat;
 
public class PersonTest {
 
    @Test
    public void build_FESTAssert_DSL() {
        Person person = Person.getBuilder("Foo", "Bar")
                .email("[email protected]")
                .id(1L)
                .build();
 
        assertThat(person)
                .hasId(1L)
                .hasFirstName("Foo")
                .hasLastName("Bar")
                .hasEmail("[email protected]");
    }
}

Почему это важно?

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

Наше решение имеет три основных преимущества:

  • Мы переместили логику фактического утверждения из тестового метода в класс PersonAssert . Если API класса Person изменяется, мы должны вносить изменения только в класс PersonAssert . Мы просто сделали наш тест менее хрупким и простым в обслуживании.
  • Поскольку в нашем утверждении используется язык, понятный специалистам по предметной области, наши тесты становятся важной частью нашей документации. Наши тесты точно определяют, как наше приложение должно вести себя в конкретной ситуации. Это исполняемые спецификации, которые всегда актуальны.
  • Пользовательские сообщения об ошибках и более читаемый API гарантируют, что нам не нужно тратить время на то, чтобы выяснить, почему наш тест не прошел. Мы сразу знаем, почему это не удалось.

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

Превращение утверждений в предметно-ориентированный язык приближает нас к этой цели.

  • PS Пример приложения этого блога доступен на Github .