Статьи

Написание тестов для кода доступа к данным — модульные тесты бесполезны

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

Модульные тесты Ответы на неправильный вопрос

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

  1. Правильные ли данные хранятся в используемой базе данных?
  2. Наш запрос к базе данных возвращает правильные данные?

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

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

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

Поучительная история: издевательства — часть проблемы

Было время, когда я писал модульные тесты для своего кода доступа к данным. В то время у меня было два правила:

  1. Каждый кусок кода должен тестироваться изолированно.
  2. Давайте использовать издевательства.

Я работал в проекте, который использовал Spring Data JPA, и динамические запросы были построены с использованием критериальных запросов JPA. Если вы не знакомы с Spring Data JPA, возможно, вы захотите прочитать четвертую часть моего учебного пособия по Spring Data JPA, в котором объясняется, как создавать запросы критериев JPA с помощью Spring Data JPA . В любом случае, я создал класс построителя спецификаций, который строит объекты Specification <Person> . После создания объекта Specification <Person> я передал его в свой репозиторий Spring Data JPA, который выполнил запрос и возвращает результаты запроса. Исходный код класса спецификатора выглядит следующим образом:

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
import org.springframework.data.jpa.domain.Specification;
   
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.CriteriaQuery;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
   
public class PersonSpecifications {
  
    public static Specification<Person> lastNameIsLike(final String searchTerm) {
           
        return new Specification<Person>() {
            @Override
            public Predicate toPredicate(Root<Person> personRoot,
                                    CriteriaQuery<?> query,
                                    CriteriaBuilder cb) {
                String likePattern = getLikePattern(searchTerm);             
                return cb.like(cb.lower(personRoot.<String>get(Person_.lastName)), likePattern);
            }
               
            private String getLikePattern(final String searchTerm) {
                return searchTerm.toLowerCase() + "%";
            }
        };
    }
}

Давайте посмотрим на тестовый код, который «проверяет», что класс построителя спецификаций создает «правильный» запрос. Помните, что я написал этот тестовый класс, следуя своим собственным правилам, что означает, что результат должен быть отличным. Исходный код класса PersonSpecificationsTest выглядит следующим образом:

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
import org.junit.Before;
import org.junit.Test;
import org.springframework.data.jpa.domain.Specification;
  
import javax.persistence.criteria.*;
  
import static junit.framework.Assert.assertEquals;
import static org.mockito.Mockito.*;
  
public class PersonSpecificationsTest {
      
    private static final String SEARCH_TERM = "Foo";
    private static final String SEARCH_TERM_LIKE_PATTERN = "foo%";
      
    private CriteriaBuilder criteriaBuilderMock;
      
    private CriteriaQuery criteriaQueryMock;
      
    private Root<Person> personRootMock;
  
    @Before
    public void setUp() {
        criteriaBuilderMock = mock(CriteriaBuilder.class);
        criteriaQueryMock = mock(CriteriaQuery.class);
        personRootMock = mock(Root.class);
    }
  
    @Test
    public void lastNameIsLike() {
        Path lastNamePathMock = mock(Path.class);      
        when(personRootMock.get(Person_.lastName)).thenReturn(lastNamePathMock);
          
        Expression lastNameToLowerExpressionMock = mock(Expression.class);
        when(criteriaBuilderMock.lower(lastNamePathMock)).thenReturn(lastNameToLowerExpressionMock);
          
        Predicate lastNameIsLikePredicateMock = mock(Predicate.class);
        when(criteriaBuilderMock.like(lastNameToLowerExpressionMock, SEARCH_TERM_LIKE_PATTERN)).thenReturn(lastNameIsLikePredicateMock);
  
        Specification<Person> actual = PersonSpecifications.lastNameIsLike(SEARCH_TERM);
        Predicate actualPredicate = actual.toPredicate(personRootMock, criteriaQueryMock, criteriaBuilderMock);
          
        verify(personRootMock, times(1)).get(Person_.lastName);
        verifyNoMoreInteractions(personRootMock);
          
        verify(criteriaBuilderMock, times(1)).lower(lastNamePathMock);
        verify(criteriaBuilderMock, times(1)).like(lastNameToLowerExpressionMock, SEARCH_TERM_LIKE_PATTERN);
        verifyNoMoreInteractions(criteriaBuilderMock);
  
        verifyZeroInteractions(criteriaQueryMock, lastNamePathMock, lastNameIsLikePredicateMock);
  
        assertEquals(lastNameIsLikePredicateMock, actualPredicate);
    }
}

Есть ли в этом смысл? НЕТ! Я должен признать, что этот тест — кусок дерьма, который не имеет никакого значения, и его следует удалить как можно скорее. Этот тест имеет три основные проблемы:

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

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

Тесты доступа к данным выполнены правильно

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

  • Наш код доступа к данным создает правильные запросы к базе данных.
  • Наша база данных возвращает правильные результаты запроса.

Если вы хотите узнать, как вы можете написать интеграционные тесты для репозиториев на базе Spring, вам следует прочитать мой пост в блоге под названием Spring Data JPA Tutorial: Integration Testing . Он описывает, как вы можете написать интеграционные тесты для репозиториев Spring Data JPA. Однако вы можете использовать ту же технику при написании интеграционных тестов для любого хранилища, в котором используется реляционная база данных. Например, в интеграционном тесте, написанном для тестирования примера приложения из моего руководства «Использование jOOQ с Spring», используется методика, описанная в этом посте.

Резюме

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

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

Остался только один вопрос: вы все еще пишете модульные тесты для своего кода доступа к данным?