Статьи

Три причины, почему мы не должны использовать наследование в наших тестах

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

  1. Во многих тестах используется одна и та же конфигурация, которая создает дублирующийся код.
  2. Создание объектов, используемых в наших тестах, создает дубликаты кода.
  3. Написание утверждений создает дублирующий код.

Первое, что приходит на ум, — это удалить дублирующийся код. Как мы знаем, принцип « Не повторяйся» (СУХОЙ) гласит:

Каждая часть знаний должна иметь одно, однозначное, авторитетное представление в системе.

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

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

  1. Наследование не подходит для повторного использования кода
  2. DZone опубликовал очень хорошее интервью с Misko Hevery, где объясняет, почему наследование не является правильным инструментом для повторного использования кода:

    Смысл наследования заключается в использовании полиморфного поведения, а НЕ в повторном использовании кода , и люди упускают это, они считают наследование дешевым способом добавления поведения в класс. Когда я разрабатываю код, мне нравится думать о вариантах. Когда я наследую, я сокращаю свои возможности. Теперь я подкласс этого класса и не могу быть подклассом чего-то другого. Я навсегда исправил мою конструкцию над суперклассом, и я нахожусь в зависимости от меняющихся API суперкласса. Моя свобода изменений фиксируется во время компиляции.

    Хотя Миско Хевери говорил о написании тестируемого кода, я думаю, что это правило относится и к тестам. Но прежде чем объяснить, почему я так думаю, давайте подробнее рассмотрим определение полиморфизма :

    Полиморфизм — это предоставление единого интерфейса сущностям разных типов.

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

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

Итак, почему это имеет значение? Это важно, потому что тесты тоже код! Вот почему это правило относится и к тестовому коду.

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

  • Наследование может оказать негативное влияние на производительность нашего набора тестов
  • Если мы используем наследование в наших тестах, это может отрицательно сказаться на производительности нашего набора тестов. Чтобы понять причину этого, мы должны понять, как JUnit работает с иерархиями классов:

    1. Прежде чем JUnit вызывает тесты тестового класса, он ищет методы, аннотированные аннотацией @BeforeClass . Он пересекает всю иерархию классов, используя отражение. После того, как он достиг java.lang.Object , он вызывает все методы, аннотированные аннотацией @BeforeClass (сначала родители).
    2. Прежде чем JUnit вызывает метод, аннотированный аннотацией @Test , он делает то же самое для методов, аннотированных аннотацией @Before .
    3. После того, как JUnit выполнил тест, он ищет метод, аннотированный аннотацией @After , и вызывает все найденные методы.
    4. После выполнения всех тестов тестового класса JUnit снова обходит иерархию классов и ищет методы, аннотированные аннотацией @AfterClass (и вызывает эти методы).

    Другими словами, мы тратим процессорное время двумя способами:

    1. Обход иерархии тестовых классов приводит к потере времени процессора.
    2. Вызов методов setup и teardown приводит к потере процессорного времени, если они не нужны нашим тестам.

    Я узнал об этом из книги под названием « Эффективное модульное тестирование» .

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

    Или ты?

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

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

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

  • Использование наследования затрудняет чтение тестов
  • Самые большие преимущества автоматизированных тестов:

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

    Мы хотим сделать наши тесты легко читаемыми, потому что

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

    Это хорошо, но на самом деле это не объясняет, почему использование наследования затрудняет чтение наших тестов. Я продемонстрирую, что я имел в виду, используя простой пример.

    Предположим, что мы должны написать модульные тесты для метода create () класса TodoCrudServiceImpl . Соответствующая часть класса TodoCrudServiceImpl выглядит следующим образом:

    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
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
     
    @Service
    public class TodoCrudServiceImpl implements TodoCrudService {
     
        private TodoRepository repository;
        
        @Autowired
        public TodoCrudService(TodoRepository repository) {
            this.repository = repository;
        }
            
        @Transactional
        @Overrides
        public Todo create(TodoDTO todo) {
            Todo added = Todo.getBuilder(todo.getTitle())
                    .description(todo.getDescription())
                    .build();
            return repository.save(added);
        }
        
        //Other methods are omitted.
    }

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

    Сначала мы создаем класс AbstractMockitoTest . Этот класс гарантирует, что все методы тестирования, найденные в его подклассах, вызываются MockitoJUnitRunner . Его исходный код выглядит следующим образом:

    1
    2
    3
    4
    5
    6
    import org.junit.runner.RunWith;
    import org.mockito.runners.MockitoJUnitRunner;
     
    @RunWith(MockitoJUnitRunner.class)
    public abstract class AbstractMockitoTest {
    }

    Во-вторых , мы создаем класс AbstractTodoTest . Этот класс предоставляет полезные служебные методы и константы для других тестовых классов, которые тестируют методы, связанные с записями todo. Его исходный код выглядит следующим образом:

    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
    import static org.junit.Assert.assertEquals;
     
    public abstract class AbstractTodoTest extends AbstractMockitoTest {
     
        protected static final Long ID = 1L;
        protected static final String DESCRIPTION = "description";
        protected static final String TITLE = "title";
     
        protected TodoDTO createDTO(String title, String description) {
            retun createDTO(null, title, description);
        }
     
        protected TodoDTO createDTO(Long id,
                                    String title,
                                    String description) {
            TodoDTO dto = new DTO();
            
            dto.setId(id);
            dto.setTitle(title);
            dto.setDescrption(description);
        
            return dto;
        }
        
        protected void assertTodo(Todo actual,
                                Long expectedId,
                                String expectedTitle,
                                String expectedDescription) {
            assertEquals(expectedId, actual.getId());
            assertEquals(expectedTitle, actual.getTitle());
            assertEquals(expectedDescription, actual.getDescription());
        }
    }

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

    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
    import org.junit.Before;
    import org.junit.Test;
    import org.mockito.Mock;
     
    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.when;
     
    public TodoCrudServiceImplTest extends AbstractTodoTest {
     
        @Mock
        private TodoRepository repositoryMock;
        
        private TodoCrudServiceImpl service;
        
        @Before
        public void setUp() {
            service = new TodoCrudServiceImpl(repository);
        }
        
        @Test
        public void create_ShouldCreateNewTodoEntryAndReturnCreatedEntry() {
            TodoDTO dto = createDTO(TITLE, DESCRIPTION);
            
            when(repositoryMock.save(isA(Todo.class))).thenAnswer(new Answer<Todo>() {
                @Override
                public Todo answer(InvocationOnMock invocationOnMock) throws Throwable {
                    Todo todo = (Todo) invocationOnMock.getArguments()[0];
                    todo.setId(ID);
                    return site;
                }
            });
                    
            Todo created = service.create(dto);
            
            verify(repositoryMock, times(1)).save(isA(Todo.class));
            verifyNoMoreInteractions(repositoryMock);
                    
            assertTodo(created, ID, TITLE, DESCRIPTION);
        }
    }

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

    • Кажется, что TodoRepository является фиктивным объектом. Этот тест должен использовать MockitoJUnitRunner . Где настроен тестовый бегун?
    • Модульный тест создает новые объекты TodoDTO , вызывая метод createDTO () . Где мы можем найти этот метод?
    • В модульном тесте, найденном в этом классе, используются константы. Где эти константы объявлены?
    • Модульный тест подтверждает информацию о возвращенном объекте Todo , вызывая метод assertTodo () . Где мы можем найти этот метод?

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

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

    Большая проблема в том, что такой код делает цикл обратной связи намного длиннее, чем необходимо.

    Что нам делать?

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

    Если мы не должны использовать наследование для повторного использования кода и конфигурации, что мы должны делать?

    Это очень хороший вопрос, и я отвечу на него в другом посте.