Статьи

Написание чистых тестов — замена утверждений на предметно-ориентированный язык

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

Чистый код легко читается.

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

  • Если наши тесты легко читаются, легко понять, как работает наш код.
  • Если наши тесты легко читаются, легко найти проблему, если тест не пройден (без использования отладчика).

Писать чистые тесты несложно, но это требует много практики, и поэтому многие разработчики борются с этим.

Я тоже с этим боролся, и поэтому решил поделиться с вами своими выводами.

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

Данные не так важны

В моем предыдущем сообщении в блоге я обнаружил две проблемы, вызванные тестами, ориентированными на данные. Хотя в этом блоге говорилось о создании новых объектов, эти проблемы актуальны и для утверждений.

Давайте освежим нашу память и посмотрим на исходный код нашего модульного теста, который гарантирует, что метод registerNewUserAccount (RegistrationForm userAccountData) класса RepositoryUserService работает, как и ожидалось, когда новая учетная запись пользователя создается с использованием уникального адреса электронной почты и социального знака в провайдере.

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

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
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.runners.MockitoJUnitRunner;
import org.mockito.stubbing.Answer;
import org.springframework.security.crypto.password.PasswordEncoder;
 
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.mockito.Matchers.isA;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.verifyZeroInteractions;
import static org.mockito.Mockito.when;
 
@RunWith(MockitoJUnitRunner.class)
public class RepositoryUserServiceTest {
 
    private static final String REGISTRATION_EMAIL_ADDRESS = "[email protected]";
    private static final String REGISTRATION_FIRST_NAME = "John";
    private static final String REGISTRATION_LAST_NAME = "Smith";
    private static final Role ROLE_REGISTERED_USER = Role.ROLE_USER;
    private static final SocialMediaService SOCIAL_SIGN_IN_PROVIDER = SocialMediaService.TWITTER;
 
    private RepositoryUserService registrationService;
 
    @Mock
    private PasswordEncoder passwordEncoder;
 
    @Mock
    private UserRepository repository;
 
    @Before
    public void setUp() {
        registrationService = new RepositoryUserService(passwordEncoder, repository);
    }
 
 
    @Test
    public void registerNewUserAccount_SocialSignInAndUniqueEmail_ShouldCreateNewUserAccountAndSetSignInProvider() throws DuplicateEmailException {
        RegistrationForm registration = new RegistrationFormBuilder()
            .email(REGISTRATION_EMAIL_ADDRESS)
            .firstName(REGISTRATION_FIRST_NAME)
            .lastName(REGISTRATION_LAST_NAME)
            .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER)
            .build();
 
        when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null);
 
        when(repository.save(isA(User.class))).thenAnswer(new Answer<User>() {
            @Override
            public User answer(InvocationOnMock invocation) throws Throwable {
                Object[] arguments = invocation.getArguments();
                return (User) arguments[0];
            }
        });
 
        User createdUserAccount = registrationService.registerNewUserAccount(registration);
 
        assertEquals(REGISTRATION_EMAIL_ADDRESS, createdUserAccount.getEmail());
        assertEquals(REGISTRATION_FIRST_NAME, createdUserAccount.getFirstName());
        assertEquals(REGISTRATION_LAST_NAME, createdUserAccount.getLastName());
        assertEquals(SOCIAL_SIGN_IN_PROVIDER, createdUserAccount.getSignInProvider());
        assertEquals(ROLE_REGISTERED_USER, createdUserAccount.getRole());
        assertNull(createdUserAccount.getPassword());
 
        verify(repository, times(1)).findByEmail(REGISTRATION_EMAIL_ADDRESS);
        verify(repository, times(1)).save(createdUserAccount);
        verifyNoMoreInteractions(repository);
        verifyZeroInteractions(passwordEncoder);
    }
}

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

  • Значение свойства электронной почты является правильным.
  • Значение свойства firstName является правильным.
  • Значение свойства lastName является правильным.
  • Значение signInProvider является правильным.
  • Значение свойства роли правильное.
  • Пароль пуст.

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

  • Читатель должен знать различные состояния возвращаемого объекта . Например, если мы подумаем о нашем примере, читатель должен знать, что если свойства email , firstName , lastName и signInProvider возвращаемого объекта RegistrationForm имеют ненулевые значения, а значение свойства password равно null, это означает, что Объект — это регистрация, которая производится с помощью социального входа в провайдере.
  • Если созданный объект имеет много свойств, наши утверждения засоряют исходный код наших тестов. Мы должны помнить, что хотя мы хотим убедиться, что данные возвращаемого объекта верны, гораздо важнее описать состояние возвращаемого объекта .

Давайте посмотрим, как мы можем улучшить наши утверждения.

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

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

Доменно-управляемый дизайн (DDD) предоставляет одно решение этой проблемы. Эрик Эванс ввел термин « вездесущий язык» в своей книге « Доменно-управляемый дизайн» .

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

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

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

Реализация нашего предметно-ориентированного языка

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

  1. Мы должны отказаться от подхода, ориентированного на данные, и больше думать о реальном пользователе, информация которого найдена в объекте User .
  2. Мы должны использовать язык, на котором говорят эксперты домена.

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

Если мы следуем этим двум правилам, мы можем создать следующие правила для нашего предметно-ориентированного языка:

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

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

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

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

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
80
81
82
83
84
85
86
87
88
89
mport org.assertj.core.api.AbstractAssert;
import org.assertj.core.api.Assertions;
 
public class UserAssert extends AbstractAssert<UserAssert, User> {
 
    private UserAssert(User actual) {
        super(actual, UserAssert.class);
    }
 
    public static UserAssert assertThat(User actual) {
        return new UserAssert(actual);
    }
 
    public UserAssert hasEmail(String email) {
        isNotNull();
 
        Assertions.assertThat(actual.getEmail())
                .overridingErrorMessage( "Expected email to be <%s> but was <%s>",
                        email,
                        actual.getEmail()
                )
                .isEqualTo(email);
 
        return this;
    }
 
    public UserAssert hasFirstName(String firstName) {
        isNotNull();
 
        Assertions.assertThat(actual.getFirstName())
                .overridingErrorMessage("Expected first name to be <%s> but was <%s>",
                        firstName,
                        actual.getFirstName()
                )
                .isEqualTo(firstName);
 
        return this;
    }
 
    public UserAssert hasLastName(String lastName) {
        isNotNull();
 
        Assertions.assertThat(actual.getLastName())
                .overridingErrorMessage( "Expected last name to be <%s> but was <%s>",
                        lastName,
                        actual.getLastName()
                )
                .isEqualTo(lastName);
 
        return this;
    }
 
    public UserAssert isRegisteredByUsingSignInProvider(SocialMediaService signInProvider) {
        isNotNull();
 
        Assertions.assertThat(actual.getSignInProvider())
                .overridingErrorMessage( "Expected signInProvider to be <%s> but was <%s>",
                        signInProvider,
                        actual.getSignInProvider()
                )
                .isEqualTo(signInProvider);
 
        hasNoPassword();
 
        return this;
    }
 
    private void hasNoPassword() {
        isNotNull();
 
        Assertions.assertThat(actual.getPassword())
                .overridingErrorMessage("Expected password to be <null> but was <%s>",
                        actual.getPassword()
                )
                .isNull();
    }
 
    public UserAssert isRegisteredUser() {
        isNotNull();
 
        Assertions.assertThat(actual.getRole())
                .overridingErrorMessage( "Expected role to be <ROLE_USER> but was <%s>",
                        actual.getRole()
                )
                .isEqualTo(Role.ROLE_USER);
 
        return this;
    }
}

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

Замена утверждений 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
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
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.runners.MockitoJUnitRunner;
import org.mockito.stubbing.Answer;
import org.springframework.security.crypto.password.PasswordEncoder;
 
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.mockito.Matchers.isA;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.verifyZeroInteractions;
import static org.mockito.Mockito.when;
 
@RunWith(MockitoJUnitRunner.class)
public class RepositoryUserServiceTest {
 
    private static final String REGISTRATION_EMAIL_ADDRESS = "[email protected]";
    private static final String REGISTRATION_FIRST_NAME = "John";
    private static final String REGISTRATION_LAST_NAME = "Smith";
    private static final Role ROLE_REGISTERED_USER = Role.ROLE_USER;
    private static final SocialMediaService SOCIAL_SIGN_IN_PROVIDER = SocialMediaService.TWITTER;
 
    private RepositoryUserService registrationService;
 
    @Mock
    private PasswordEncoder passwordEncoder;
 
    @Mock
    private UserRepository repository;
 
    @Before
    public void setUp() {
        registrationService = new RepositoryUserService(passwordEncoder, repository);
    }
 
 
    @Test
    public void registerNewUserAccount_SocialSignInAndUniqueEmail_ShouldCreateNewUserAccountAndSetSignInProvider() throws DuplicateEmailException {
        RegistrationForm registration = new RegistrationFormBuilder()
            .email(REGISTRATION_EMAIL_ADDRESS)
            .firstName(REGISTRATION_FIRST_NAME)
            .lastName(REGISTRATION_LAST_NAME)
            .isSocialSignInViaSignInProvider(SOCIAL_SIGN_IN_PROVIDER)
            .build();
 
        when(repository.findByEmail(REGISTRATION_EMAIL_ADDRESS)).thenReturn(null);
 
        when(repository.save(isA(User.class))).thenAnswer(new Answer<User>() {
            @Override
            public User answer(InvocationOnMock invocation) throws Throwable {
                Object[] arguments = invocation.getArguments();
                return (User) arguments[0];
            }
        });
 
        User createdUserAccount = registrationService.registerNewUserAccount(registration);
 
        assertThat(createdUserAccount)
            .hasEmail(REGISTRATION_EMAIL_ADDRESS)
            .hasFirstName(REGISTRATION_FIRST_NAME)
            .hasLastName(REGISTRATION_LAST_NAME)
            .isRegisteredUser()
            .isRegisteredByUsingSignInProvider(SOCIAL_SIGN_IN_PROVIDER);
 
        verify(repository, times(1)).findByEmail(REGISTRATION_EMAIL_ADDRESS);
        verify(repository, times(1)).save(createdUserAccount);
        verifyNoMoreInteractions(repository);
        verifyZeroInteractions(passwordEncoder);
    }
}

Наше решение имеет следующие преимущества:

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

Давайте потратим немного времени, чтобы подвести итог тому, что мы узнали из этого поста в блоге.

Резюме

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

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