Статьи

Тестовые построители данных и объектная мать: другой взгляд

Создание объектов в тестах, как правило, является кропотливой работой и, как правило, создает много повторяемого и трудно читаемого кода. Существует два распространенных решения для работы со сложными тестовыми данными: Object Mother и Test Data Builder . Оба имеют свои преимущества и недостатки, но (умно) в сочетании могут принести новое качество ваших испытаний.

Примечание: вы уже можете найти много статей как об Object Mother и о Object Mother Test Data Builder поэтому я сделаю свое описание очень кратким.

Объект Мать

Вкратце, Object Mother — это набор фабричных методов, которые позволяют нам создавать похожие объекты в тестах:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
// Object Mother
public class TestUsers {
 
    public static User aRegularUser() {
        return new User("John Smith", "jsmith", "42xcc", "ROLE_USER");
    }
 
    // other factory methods
 
}
 
// arrange
User user = TestUsers.aRegularUser();
User adminUser = TestUsers.anAdmin();

Каждый раз, когда требуется пользователь с немного разными вариациями данных, создается новый фабричный метод, который позволяет Object Mother расти со временем. Это один из недостатков Object Mother . Эта проблема может быть решена путем введения Test Data Builder .

Тестовый построитель данных

Test Data Builder использует шаблон Builder для создания объектов в модульных тестах. Краткое напоминание Builder :

Шаблон Builder — это шаблон проектирования программного обеспечения для создания объектов. […] Задача шаблона построения состоит в том, чтобы найти решение для анти-шаблона телескопического конструктора.

Давайте рассмотрим пример Test Data Builder :

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
public class UserBuilder {
 
    public static final String DEFAULT_NAME = "John Smith";
    public static final String DEFAULT_ROLE = "ROLE_USER";
    public static final String DEFAULT_PASSWORD = "42";
 
    private String username;
    private String password = DEFAULT_PASSWORD;
    private String role = DEFAULT_ROLE;
    private String name = DEFAULT_NAME;
 
    private UserBuilder() {
    }
 
    public static UserBuilder aUser() {
        return new UserBuilder();
    }
 
    public UserBuilder withName(String name) {
        this.name = name;
        return this;
    }
 
    public UserBuilder withUsername(String username) {
        this.username = username;
        return this;
    }
 
    public UserBuilder withPassword(String password) {
        this.password = password;
        return this;
    }
 
    public UserBuilder withNoPassword() {
        this.password = null;
        return this;
    }
 
    public UserBuilder inUserRole() {
        this.role = "ROLE_USER";
        return this;
    }
 
    public UserBuilder inAdminRole() {
        this.role = "ROLE_ADMIN";
        return this;
    }
 
    public UserBuilder inRole(String role) {
        this.role = role;
        return this;
    }
 
    public UserBuilder but() {
        return UserBuilder
                .aUser()
                .inRole(role)
                .withName(name)
                .withPassword(password)
                .withUsername(username);
    }
 
    public User build() {
        return new User(name, username, password, role);
    }
}

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

1
2
3
4
5
6
7
UserBuilder userBuilder = UserBuilder.aUser()
    .withName("John Smith")
    .withUsername("jsmith");
 
User user = userBuilder.build();
User admin = userBuilder.but()
    .withNoPassword().inAdminRole();

Приведенный выше код выглядит довольно мило. У нас есть свободный API, который улучшает читаемость тестового кода и наверняка устраняет проблему наличия нескольких фабричных методов для вариаций объектов, которые нам нужны в тестах при использовании Object Mother .

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

Примечание: пример, используемый в этой статье, является относительно простым. Он используется для визуализации решения.

Мать объекта и построитель тестовых данных объединены

Ни одно из решений не является идеальным. Но что, если мы объединим их? Представьте, что Object Mother возвращает Test Data Builder . Имея это, вы можете манипулировать состоянием компоновщика перед вызовом терминальной операции. Это своего рода шаблон.

Посмотрите на пример ниже:

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
public final class TestUsers {
 
    public static UserBuilder aDefaultUser() {
        return UserBuilder.aUser()
                .inUserRole()
                .withName("John Smith")
                .withUsername("jsmith");
    }
 
    public static UserBuilder aUserWithNoPassword() {
        return UserBuilder.aUser()
                .inUserRole()
                .withName("John Smith")
                .withUsername("jsmith")
                .withNoPassword();
    }
 
    public static UserBuilder anAdmin() {
        return UserBuilder.aUser()
                .inAdminRole()
                .withName("Chris Choke")
                .withUsername("cchoke")
                .withPassword("66abc");
    }
}

Теперь TestUsers предоставляет фабричный метод для создания похожих тестовых данных в наших тестах. Он возвращает экземпляр компоновщика, поэтому мы можем быстро и удобно изменять объект в нашем тесте по мере необходимости:

1
2
UserBuilder user = TestUsers.aUser();
User admin = user.but().withNoPassword().build();

Преимущества велики. У нас есть шаблон для создания похожих объектов, и у нас есть возможности построителя, если нам нужно изменить состояние возвращаемого объекта перед его использованием.

Обогащение тестового построителя данных

Размышляя о вышесказанном, я не уверен, действительно ли необходимо хранить отдельную Object Mother . Мы могли бы легко переместить методы из Object Mother напрямую в Test Data Builder :

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
public class UserBuilder {
 
    public static final String DEFAULT_NAME = "John Smith";
    public static final String DEFAULT_ROLE = "ROLE_USER";
    public static final String DEFAULT_PASSWORD = "42";
 
    // field declarations omitted for readability
 
    private UserBuilder() {}
 
    public static UserBuilder aUser() {
        return new UserBuilder();
    }
 
    public static UserBuilder aDefaultUser() {
        return UserBuilder.aUser()
                .withUsername("jsmith");
    }
 
    public static UserBuilder aUserWithNoPassword() {
        return UserBuilder.aDefaultUser()
                .withNoPassword();
    }
 
    public static UserBuilder anAdmin() {
        return UserBuilder.aUser()
                .inAdminRole();
    }
 
    // remaining methods omitted for readability
 
}

Благодаря этому мы можем поддерживать создание User данных внутри одного класса.

Обратите внимание, что в этом Test Data Builder является тестовым кодом. В случае, если у нас уже есть конструктор в производственном коде, создание Object Mother возвращающего экземпляр Builder звучит как лучшее решение.

Как насчет изменяемых объектов?

С подходом Test Data Builder есть некоторые возможные недостатки, когда речь идет о изменяемых объектах. И во многих приложениях я в основном имею дело с изменчивыми объектами (иначе говоря, beans или anemic data model ), и, вероятно, многие из вас также.

Шаблон Builder предназначен для создания неизменных ценностных объектов — в теории. Как правило, если мы имеем дело с изменчивыми объектами, на первый взгляд, построитель Test Data Builder может показаться дублированием:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Mutable class with setters and getters
class User {
    private String name;
    public String getName() { ... }
    public String setName(String name) { ... }
 
    // ...
}
 
public class UserBuilder {
    private User user = new User();
 
    public UserBuilder withName(String name) {
        user.setName(name);
        return this;
    }
 
    // other methods
 
    public User build() {
        return user;
    }
}

В тесте мы можем создать такого пользователя:

1
2
3
4
User aUser = UserBuiler.aUser()
    .withName("John")
    .withPassword("42abc")
    .build();

Вместо:

1
2
3
User aUser = new User();
aUser.setName("John");
aUser.setPassword("42abc");

В таком случае создание Test Data Builder является компромиссом . Это требует написания большего количества кода, который необходимо поддерживать. С другой стороны, читаемость значительно улучшена.

Резюме

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

Как вы справляетесь с построением данных в ваших тестах?

Ресурсы