Статьи

Написание чистых тестов — Остерегайтесь магии

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

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

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

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

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

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

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

Константы на помощь

Мы используем константы в нашем коде, потому что без констант наш код был бы завален магическими числами . Использование магических чисел имеет два последствия:

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

Другими словами,

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

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

  1. Магические числа, которые относятся к одному тестовому классу. Типичным примером такого магического числа является значение свойства объекта, созданного в тестовом методе. Мы должны объявить эти константы в тестовом классе .
  2. Магические числа, которые имеют отношение к нескольким тестовым классам. Хорошим примером такого магического числа является тип содержимого запроса, обрабатываемого контроллером Spring MVC. Мы должны добавить эти константы в неинстанцируемый класс .

Давайте внимательнее посмотрим на обе ситуации.

Объявление констант в тестовом классе

Итак, почему мы должны объявлять некоторые константы в нашем тестовом классе?

В конце концов, если мы думаем о преимуществах использования констант, первое, что приходит на ум, — это то, что мы должны исключить магические числа из наших тестов, создав классы, которые содержат константы, используемые в наших тестах. Например, мы могли бы создать класс TodoConstants, который содержит константы, используемые в классах TodoControllerTest , TodoCrudServiceTest и TodoTest .

Это плохая идея .

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

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

На мой взгляд, самый простой способ справиться с такой ситуацией — объявить константы в тестовом классе.

Давайте выясним, как мы можем улучшить модульный тест, описанный в предыдущей части этого урока . Этот модульный тест написан для проверки метода 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
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);
    }
}

Проблема заключается в том, что этот контрольный пример использует магические числа при создании нового объекта RegistrationForm , настраивает поведение макета UserRepository , проверяет, верна ли информация о возвращенном объекте User , и проверяет, вызваны ли правильные методы метода из макета UserRepository. в проверенном методе обслуживания.

После того как мы удалили эти магические числа, объявив константы в нашем тестовом классе, исходный код нашего теста выглядит следующим образом:

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 RegistrationForm();
        registration.setEmail(REGISTRATION_EMAIL_ADDRESS);
        registration.setFirstName(REGISTRATION_FIRST_NAME);
        registration.setLastName(REGISTRATION_LAST_NAME);
        registration.setSignInProvider(SOCIAL_SIGN_IN_PROVIDER);
 
        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);
    }
}

Этот пример демонстрирует, что объявление констант в классе теста имеет три преимущества:

  1. Наш тестовый пример легче читать, потому что магические числа заменены на константы, которые названы правильно.
  2. Наш тестовый пример легче поддерживать, потому что мы можем изменять значения констант, не внося никаких изменений в реальный тестовый пример.
  3. Проще написать новые тесты для метода registerNewUserAccount () класса RepositoryUserService, потому что мы можем использовать константы вместо магических чисел. Это означает, что нам не нужно беспокоиться об опечатках.

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

Добавление констант в непроизводимый класс

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

Давайте предположим, что нам нужно написать два модульных теста для REST API:

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

В этих модульных тестах используется среда Spring MVC Test. Если вы не знакомы с ним, вы можете взглянуть на мой
Spring MVC Тестовое пособие .

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

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
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
 
import java.nio.charset.Charset;
 
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {WebUnitTestContext.class})
@WebAppConfiguration
public class TodoControllerTest {
 
    private static final MediaType APPLICATION_JSON_UTF8 = new MediaType(
            MediaType.APPLICATION_JSON.getType(), MediaType.APPLICATION_JSON.getSubtype(),
            Charset.forName("utf8")
    );
 
    private MockMvc mockMvc;
 
    @Autowired
    private ObjectMapper objectMapper;
 
    @Autowired
    private WebApplicationContext webAppContext;
 
    @Before
    public void setUp() {
        mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext).build();
    }
 
    @Test
    public void add_EmptyTodoEntry_ShouldReturnHttpRequestStatusBadRequest() throws Exception {
        TodoDTO addedTodoEntry = new TodoDTO();
 
        mockMvc.perform(post("/api/todo")
                .contentType(APPLICATION_JSON_UTF8)
                .content(objectMapper.writeValueAsBytes(addedTodoEntry))
        )
                .andExpect(status().isBadRequest());
    }
}

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

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
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
 
import java.nio.charset.Charset;
 
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {WebUnitTestContext.class})
@WebAppConfiguration
public class NoteControllerTest {
 
    private static final MediaType APPLICATION_JSON_UTF8 = new MediaType(
            MediaType.APPLICATION_JSON.getType(), MediaType.APPLICATION_JSON.getSubtype(),
            Charset.forName("utf8")
    );
 
    private MockMvc mockMvc;
 
    @Autowired
    private ObjectMapper objectMapper;
 
    @Autowired
    private WebApplicationContext webAppContext;
 
    @Before
    public void setUp() {
        mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext).build();
    }
 
    @Test
    public void add_EmptyNote_ShouldReturnHttpRequestStatusBadRequest() throws Exception {
        NoteDTO addedNote = new NoteDTO();
 
        mockMvc.perform(post("/api/note")
                .contentType(APPLICATION_JSON_UTF8)
                .content(objectMapper.writeValueAsBytes(addedNote))
        )
                .andExpect(status().isBadRequest());
    }
}

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

Значит ли это, что мы должны объявлять эту константу в каждом таком тестовом классе?

Нет!

Мы должны переместить эту константу в неинстанцируемый класс по двум причинам:

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

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

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

01
02
03
04
05
06
07
08
09
10
11
import org.springframework.http.MediaType;
 
public final class WebTestConstants {
    public static final MediaType APPLICATION_JSON_UTF8 = new MediaType(
            MediaType.APPLICATION_JSON.getType(), MediaType.APPLICATION_JSON.getSubtype(),
            Charset.forName("utf8")
    );
    
    private WebTestConstants() {
    }
}

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

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
import com.fasterxml.jackson.databind.ObjectMapper;
import net.petrikainulainen.spring.jooq.config.WebUnitTestContext;
import net.petrikainulainen.spring.jooq.todo.dto.TodoDTO;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
 
import java.nio.charset.Charset;
 
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {WebUnitTestContext.class})
@WebAppConfiguration
public class TodoControllerTest {
 
    private MockMvc mockMvc;
 
    @Autowired
    private ObjectMapper objectMapper;
 
    @Autowired
    private WebApplicationContext webAppContext;
 
    @Before
    public void setUp() {
        mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext).build();
    }
 
    @Test
    public void add_EmptyTodoEntry_ShouldReturnHttpRequestStatusBadRequest() throws Exception {
        TodoDTO addedTodoEntry = new TodoDTO();
 
        mockMvc.perform(post("/api/todo")
                        .contentType(WebTestConstants.APPLICATION_JSON_UTF8)
                        .content(objectMapper.writeValueAsBytes(addedTodoEntry))
        )
                .andExpect(status().isBadRequest());
    }
}

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

Если мы изменим значение константы, которая добавлена ​​в класс констант, это изменение повлияет на каждый тестовый случай, который использует эту константу. Вот почему мы должны минимизировать количество констант, которые добавляются в класс констант .

Резюме

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

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