Статьи

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

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

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

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

Написание «хороших» юнит-тестов

Если мы хотим написать «хорошие» модульные тесты, мы должны написать модульные тесты, которые:

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

Дополнительное чтение:

Если мы напишем модульные тесты, которые удовлетворяют этим условиям, мы напишем хорошие модульные тесты. Правильно?

Раньше я так думал. Теперь я в этом сомневаюсь .

Дорога в ад вымощена добрыми намерениями

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

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

Подход, описанный в моем руководстве, имеет две основные проблемы:

Стандарты именования FTW?

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

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

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

Во-вторых , если мы попытаемся сделать имена методов как можно более короткими, имена методов на самом деле не будут описывать тестируемое состояние и ожидаемое поведение.

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

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

Вот имена тестовых методов, которые мы написали во время учебника Writing Clean Tests :

  • registerNewUserAccount_SocialSignInAndDuplicateEmail_ShouldThrowException ()
  • registerNewUserAccount_SocialSignInAndDuplicateEmail_ShouldNotSaveNewUserAccount ()
  • registerNewUserAccount_SocialSignInAndUniqueEmail_ShouldSaveNewUserAccountAndSetSignInProvider ()
  • registerNewUserAccount_SocialSignInAndUniqueEmail_ShouldReturnCreatedUserAccount ()
  • registerNewUserAccount_SocialSignInAnUniqueEmail_ShouldNotCreateEncodedPasswordForUser ()

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

Это не очень чистый или читабельный. Мы можем сделать намного лучше .

Там нет общей конфигурации

В этом уроке мы сделали наши модульные тесты намного лучше . Тем не менее, они по-прежнему страдают от того, что не существует «естественного» способа обмена конфигурациями между различными модульными тестами.

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

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

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

001
002
003
004
005
006
007
008
009
010
011
012
013
014
015
016
017
018
019
020
021
022
023
024
025
026
027
028
029
030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
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 com.googlecode.catchexception.CatchException.catchException;
import static com.googlecode.catchexception.CatchException.caughtException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Matchers.isA;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
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 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_SocialSignInAndDuplicateEmail_ShouldThrowException() 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(new User());
  
        catchException(registrationService).registerNewUserAccount(registration);
  
        assertThat(caughtException()).isExactlyInstanceOf(DuplicateEmailException.class);
    }
  
    @Test
    public void registerNewUserAccount_SocialSignInAndDuplicateEmail_ShouldNotSaveNewUserAccount() 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(new User());
  
        catchException(registrationService).registerNewUserAccount(registration);
  
        verify(repository, never()).save(isA(User.class));
    }
  
    @Test
    public void registerNewUserAccount_SocialSignInAndUniqueEmail_
ShouldSaveNewUserAccountAndSetSignInProvider() 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);
  
        registrationService.registerNewUserAccount(registration);
  
        ArgumentCaptor<User> userAccountArgument = ArgumentCaptor.forClass(User.class);
        verify(repository, times(1)).save(userAccountArgument.capture());
  
        User createdUserAccount = userAccountArgument.getValue();
  
        assertThatUser(createdUserAccount)
                .hasEmail(REGISTRATION_EMAIL_ADDRESS)
                .hasFirstName(REGISTRATION_FIRST_NAME)
                .hasLastName(REGISTRATION_LAST_NAME)
                .isRegisteredUser()
                .isRegisteredByUsingSignInProvider(SOCIAL_SIGN_IN_PROVIDER);
    }
  
  
    @Test
    public void registerNewUserAccount_SocialSignInAndUniqueEmail_ShouldReturnCreatedUserAccount() 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);
  
        assertThatUser(createdUserAccount)
                .hasEmail(REGISTRATION_EMAIL_ADDRESS)
                .hasFirstName(REGISTRATION_FIRST_NAME)
                .hasLastName(REGISTRATION_LAST_NAME)
                .isRegisteredUser()
                .isRegisteredByUsingSignInProvider(SOCIAL_SIGN_IN_PROVIDER);
    }
  
    @Test
    public void registerNewUserAccount_SocialSignInAnUniqueEmail_ShouldNotCreateEncodedPasswordForUser() 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);
  
        registrationService.registerNewUserAccount(registration);
  
        verifyZeroInteractions(passwordEncoder);
    }
}

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

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

Другими словами, эти модульные тесты трудно читать, трудно писать и сложно поддерживать. Мы должны сделать лучшую работу .

Резюме

Этот пост научил нас четырем вещам:

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

Следующая часть этого урока отвечает на этот очень важный вопрос:

Если наши существующие модульные тесты отстой, как мы можем их исправить?

Если вы хотите написать чистые тесты, вы должны прочитать мой учебник Writing Clean Tests .