Как избавиться от шаблонов в интеграционных тестах с помощью DBUnit и пользовательской аннотации.

Как избавиться от шаблонов в интеграционных тестах с помощью DBUnit и пользовательской аннотации.

8 ноября 2022 г.

Привет! Меня зовут Вячеслав Аксенов, я backend-разработчик, специализирующийся на разработке сложных backend-систем на Java и Kotlin. Также я пишу много кода для себя, который вы можете найти на моем GitHub: https://github.com/v-aksenov

О чем эта статья

Во время своей работы я часто вижу интеграцию службы и базы данных. А конкретно — с тестированием этой интеграции. Во время интеграционного тестирования базы данных каждый раз необходимо делать следующее:

* Установить начальное состояние таблиц * Запустить тест с конкретным запросом * Утвердить ответ * Утверждать содержимое таблиц после прохождения тестов

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

Пример экспериментального сервиса

Давайте рассмотрим пример, основанный на производственном коде. Существует веб-сервис Spring Boot, написанный на Java. Уровень контроллера основан на обычной реализации через аннотацию @RestController В качестве базы данных используется Postgresql. Для интеграции службы с базой данных используется оболочка Spring Data Jpa поверх JDBC для более быстрого взаимодействия.

Контроллер:

Модели:

Объект базы данных:

Репозиторий:

Как мы можем это проверить?

JUnit + mockMvc + Спринт-тест

Использовать репозитории из уровня репозитория непосредственно в тестах, установить состояние базы данных в тесте, сгенерировать модель для запроса как DTO, сделать вызов через mockMvc, затем утвердить ответ и состояние базы данных с помощью JUnit.< /p>

Пример теста:

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

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

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

<цитата>

Оптимизируйте регулярно используемую логику — это сэкономит вам огромное количество времени в будущем.

Давайте улучшим его с помощью популярной библиотеки DBUnit.

DBUnit — это платформа с открытым исходным кодом, помогающая решать такие проблемы, как заполнение баз данных и таблиц, а также сравнение таблиц и наборов данных с базой данных. Это также расширение для JUnit. В отличие от предыдущего метода - подготовку состояния БД и проверку на соответствие требуемому состоянию выполняет DBUnit.

Пример теста:

@DBUnit(
    caseInsensitiveStrategy = Orthography.LOWERCASE,
    batchedStatements = true,
    allowEmptyFields = true,
    schema = "cms"
)
@SpringBootTest
public class SimpleContactTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    @SneakyThrows
    @DataSet(
        cleanBefore = true,
        value = "controller/contact/update.success/dataset.json"
    )
    @ExpectedDataSet("controller/contact/update.success/dataset-expected.json")
    public void updateContactTest() {
        var request = new CreateContactRequest("name", "1112223344");

        var contentAsString = mockMvc.perform(
                MockMvcRequestBuilders.put(
                        ManagerApi.CONTACT,
                        "7624f434-cbc5-11ec-9d64-0242ac120002")
                    .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                    .content(objectMapper.writeValueAsBytes(request)))
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andReturn()
            .getResponse()
            .getContentAsString();

        var response = objectMapper.readValue(contentAsString, ContactResponse.class);

        Assertions.assertEquals(request.name(), response.name());
        Assertions.assertEquals(request.phoneNumber(), response.phoneNumber());
        Assertions.assertNotNull(response.id());
    }
}

Дополнительные файлы, описывающие состояние базы данных:

## controller/contact/update.success/dataset.json

{
   "contacts": [
       {
           "id": "7624f434-cbc5-11ec-9d64-0242ac120002",
           "name": "old name",
           "phone_number": "0000000000"
       }
   ]
}

## controller/contact/update.success/dataset-expected.json

{
   "contacts": [
       {
           "id": "7624f434-cbc5-11ec-9d64-0242ac120002",
           "name": "name",
           "phone_number": "1112223344"
       }
   ]
}

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

Давайте добавим пользовательскую аннотацию, чтобы указать запрос/ответ

Чтобы отказаться от описания моделей в java-коде для запросов и проверки ответов, вы можете написать свою аннотацию, которая будет использовать уже описанные модели из файла и использовать аннотацию @ParametrizedTest.

@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD, })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ArgumentsSource(JsonFileSourceProvider.class)
public @interface JsonFileSource {
   String file() default "";

   String expectFile() default "";
}

Класс, реализующий чтение из файла:

public class JsonFileSourceProvider
   implements AnnotationConsumer<JsonFileSource>, ArgumentsProvider {

   private final List<String> resources = new ArrayList<>();

   @Override
   public void accept(JsonFileSource jsonFileSource) {
       addResource(jsonFileSource.file());
       addResource(jsonFileSource.expectFile());
   }

   @Override
   public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
       return Stream.of(resources)
           .map(
               r -> r.stream()
                   .map(this::getJsonResource)
                   .toArray()
           )
           .map(Arguments::of);
   }

   private void addResource(String resource) {
       if (!resource.isEmpty()) {
           this.resources.add(resource);
       }
   }

   private String getJsonResource(String file) {
       try {
           return new String(
               Files.readAllBytes(
                   ResourceUtils.getFile(String.format("classpath:%s", file)).toPath()
               )
           );
       } catch (final IOException err) {
           return null;
       }
   }
}

С этой аннотацией тест становится намного проще:

@DBUnit(
   caseInsensitiveStrategy = Orthography.LOWERCASE,
   batchedStatements = true,
   allowEmptyFields = true,
   schema = "cms"
)
@SpringBootTest
public class SimpleContactTest {

   @Autowired
   private MockMvc mockMvc;

   @SneakyThrows
   @ParameterizedTest
   @DataSet(
       cleanBefore = true,
       value = "controller/contact/update.success/dataset.json"
   )
   @ExpectedDataSet("controller/contact/update.success/dataset-expected.json")
   @JsonFileSource(
       file = "controller/contact/update.success/request.json",
       expectFile = "controller/contact/update.success/response.json"
   )
   public void updateContactTest(String request, String response) {
       mockMvc.perform(
               MockMvcRequestBuilders.put(
                       ManagerApi.CONTACT, "7624f434-cbc5-11ec-9d64-0242ac120002")
                   .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                   .content(request))
           .andExpect(MockMvcResultMatchers.status().isOk())
           .andExpect(MockMvcResultMatchers.content().json(response));
   }
}

А все описание моделей запроса и ответа хранится в файлах:

## controller/contact/update.success/request.json

{
  "name": "old name",
  "phone_number": "0000000000"
}


## controller/contact/update.success/response.json

{
  "name": "name",
  "phone_number": "1112223344"
}

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

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

Вывод:

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

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

Фото AltumCode на Unsplash


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE