Статьи

Написание чистых тестов — вопросы именования

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

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

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

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

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

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

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

Дьявол кроется в деталях

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

Давайте выясним, что это значит.

Классы именования тестов

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

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

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

  1. Если тестовый класс принадлежит к первой группе, мы должны назвать его, используя следующую формулу: [Имя тестируемого класса] Test . Например, если мы пишем тесты для класса RepositoryUserService , имя нашего тестового класса должно быть: RepositoryUserServiceTest . Преимущество этого подхода заключается в том, что в случае сбоя теста это правило помогает нам выяснить, какой класс нарушен, не читая тестовый код.
  2. Если класс принадлежит ко второй группе, мы должны назвать его, используя следующую формулу: [Имя протестированной функции] Test . Например, если мы будем писать тесты для функции регистрации, имя нашего тестового класса должно быть RegistrationTest . Идея, лежащая в основе этого правила, заключается в том, что в случае сбоя теста использование этого соглашения об именах помогает нам выяснить, какая функция нарушена, не читая тестовый код.

Методы именования

Я большой поклонник соглашения об именах, представленного Роем Ошеровом . Его идея состоит в том, чтобы описать тестируемый метод (или функцию), ожидаемый ввод или состояние и ожидаемое поведение от имени тестового метода.

Другими словами, если мы будем следовать этому соглашению об именах, мы должны назвать наши тестовые методы следующим образом:

  1. Если мы пишем тесты для одного класса, мы должны назвать наши методы тестирования с помощью следующей формулы: [имя тестируемого метода] _ [ожидаемое состояние ввода / тестируемого состояния] _ [ожидаемое поведение] . Например, если мы напишем модульный тест для метода registerNewUserAccount (), который выдает исключение, когда данный адрес электронной почты уже связан с существующей учетной записью пользователя, мы должны назвать наш метод тестирования следующим образом: registerNewUserAccount_ExistingEmailAddressGiven_ShouldThrowException () .
  2. Если мы пишем тесты для одной функции, мы должны назвать наши методы тестирования, используя следующую формулу: [имя проверенной функции] _ [ожидаемое состояние ввода / проверенное состояние] _ [ожидаемое поведение] . Например, если мы напишем интеграционный тест, который проверяет, отображается ли сообщение об ошибке, когда пользователь пытается создать новую учетную запись пользователя, используя адрес электронной почты, который уже связан с существующей учетной записью пользователя, мы должны назвать метод теста следующим образом. registerNewUserAccount_ExistingEmailAddressGiven_ShouldShowErrorMessage () .

Это соглашение об именах гарантирует, что:

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

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

  • Каковы особенности нашего приложения?
  • Каково ожидаемое поведение функции или метода при получении ввода X?

Кроме того, если тест не пройден, мы довольно хорошо понимаем, что не так, прежде чем читать исходный код провального теста.

Довольно круто, а?

Именование полей класса теста

Тестовый класс может иметь следующие поля:

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

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

Это правило звучит довольно «просто» (присвоение имен всегда сложно), и мне было легко следовать этому правилу, когда я называю тестируемый класс и другие классы, которые используют мои тесты. Например, если мне нужно добавить поле TodoCrudService в мой тестовый класс, я использую имя crudService .

Когда я добавляю поля, содержащие тестовые двойники, в мой тестовый класс, я обычно добавляю тип двойного теста в конец имени поля. Например, если я добавил макет TodoCrudService в свой тестовый класс, я использовал имя crudServiceMock .

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

Именование локальных переменных

Когда мы называем локальные переменные, используемые в наших тестовых методах, мы должны следовать тем же принципам, которые используются, когда мы называем переменные, найденные в коде нашего приложения.

На мой взгляд, наиболее важными правилами являются:

  • Опишите значение переменной. Хорошее практическое правило заключается в том, что имя переменной должно описывать содержимое переменной.
  • Не используйте сокращенные имена, которые никому не очевидны. Сокращенные имена снижают читабельность, и зачастую вы ничего не получаете, используя их.
  • Не используйте общие имена, такие как dto , modelObject или data .
  • Быть последовательным. Следуйте правилам именования используемого языка программирования. Если у вашего проекта есть свои соглашения об именах, вы должны их соблюдать.

Хватит теории. Давайте применим эти уроки на практике.

Применение теории на практике

Давайте посмотрим на модифицированный модульный тест (я сделал его хуже), который можно найти в примере приложения из моего учебника Spring Social .

Этот модульный тест написан для проверки метода registerNewUserAccount () класса 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
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 RepositoryUserService service;
 
    @Mock
    private PasswordEncoder passwordEncoderMock;
 
    @Mock
    private UserRepository repositoryMock;
 
    @Before
    public void setUp() {
        service = new RepositoryUserService(passwordEncoderMock, repositoryMock);
    }
 
 
    @Test
    public void registerNewUserAccountByUsingSocialSignIn() throws DuplicateEmailException {
        RegistrationForm form = new RegistrationForm();
        form.setEmail("[email protected]");
        form.setFirstName("John");
        form.setLastName("Smith");
        form.setSignInProvider(SocialMediaService.TWITTER);
 
        when(repositoryMock.findByEmail("[email protected]")).thenReturn(null);
        
        when(repositoryMock.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 modelObject = service.registerNewUserAccount(form);
 
        assertEquals("[email protected]", modelObject.getEmail());
        assertEquals("John", modelObject.getFirstName());
        assertEquals("Smith", modelObject.getLastName());
        assertEquals(SocialMediaService.TWITTER, modelObject.getSignInProvider());
        assertEquals(Role.ROLE_USER, modelObject.getRole());
        assertNull(modelObject.getPassword());
 
        verify(repositoryMock, times(1)).findByEmail("[email protected]");
        verify(repositoryMock, times(1)).save(modelObject);
        verifyNoMoreInteractions(repositoryMock);
        verifyZeroInteractions(passwordEncoderMock);
    }
}

Этот модульный тест имеет довольно много проблем:

  • Имена полей довольно общие, и они описывают типы двойников теста.
  • Название метода тестирования «довольно хорошее», но оно не описывает заданные данные или ожидаемое поведение.
  • Имена переменных, используемые в тестовом методе, ужасны.

Мы можем улучшить читаемость этого модульного теста, внеся в него следующие изменения:

  1. Измените имя поля RepositoryUserService на registrationService (имя класса обслуживания немного неправильное, но давайте проигнорируем это).
  2. Удалите слово «макет» из имен полей полей PasswordEncoder и UserRepository .
  3. Измените имя метода теста на: registerNewUserAccount_SocialSignInAndUniqueEmail_ShouldCreateNewUserAccountAndSetSignInProvider () .
  4. Измените имя переменной формы на регистрацию .
  5. Измените имя переменной modelObject на созданный пользовательский аккаунт .

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

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
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 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 RegistrationForm();
        registration.setEmail("[email protected]");
        registration.setFirstName("John");
        registration.setLastName("Smith");
        registration.setSignInProvider(SocialMediaService.TWITTER);
 
        when(repository.findByEmail("[email protected]")).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("[email protected]", createdUserAccount.getEmail());
        assertEquals("John", createdUserAccount.getFirstName());
        assertEquals("Smith", createdUserAccount.getLastName());
        assertEquals(SocialMediaService.TWITTER, createdUserAccount.getSignInProvider());
        assertEquals(Role.ROLE_USER, createdUserAccount.getRole());
        assertNull(createdUserAccount.getPassword());
 
        verify(repository, times(1)).findByEmail("[email protected]");
        verify(repository, times(1)).save(createdUserAccount);
        verifyNoMoreInteractions(repository);
        verifyZeroInteractions(passwordEncoder);
    }
}

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

  1. Название метода тестирования описывает ожидаемое поведение тестируемого метода при создании новой учетной записи пользователя с использованием поставщика социальных сетей и уникального адреса электронной почты. Единственный способ получить эту информацию из «старого» теста — это прочитать исходный код метода теста. Это, очевидно, намного медленнее, чем чтение только имени метода. Другими словами, присвоение хороших имен методам испытаний экономит время и помогает нам получить краткий обзор требований к тестируемому методу или функции.
  2. другие изменения превратили общий тест CRUD в «вариант использования». «Новый» метод испытаний четко описывает
    1. Какие шаги имеет этот вариант использования?
    2. Что возвращает метод registerNewUserAccount (), когда он получает регистрацию, которая выполняется с помощью поставщика социальных сетей и имеет уникальный адрес электронной почты.

    На мой взгляд, «старый» контрольный пример не смог этого сделать.

Я не совсем доволен именем объекта RegistrationForm, но оно определенно лучше, чем оригинальное имя.

Резюме

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

Тем не менее, наш тестовый пример все еще имеет некоторые проблемы. Эти проблемы:

  • Код, который создает новые объекты RegistrationForm, просто устанавливает значения свойств созданного объекта. Мы можем улучшить этот код, используя средства построения тестовых данных.
  • Стандартные утверждения JUnit, которые проверяют правильность информации возвращаемого объекта User , не очень читабельны. Другая проблема заключается в том, что они проверяют только правильность значений свойств возвращенного объекта User . Мы можем улучшить этот код, превратив утверждения в предметно-ориентированный язык.

Я опишу оба метода в будущем.

А пока я хотел бы услышать, какие соглашения об именах вы используете.