Меня зовут Вячеслав Аксёнов, я имею большой опыт разработки веб сервисов на Java / Kotlin с использованием Spring Framework. В своей работе я регулярно встречаюсь с задачами, в которых требуется настроить и протестировать интеграцию веб сервиса с базой данных. Также среди людей, которых я обучаю, я вижу большое количество вопросов на эту тему. Так что я решил, что будет полезно сделать подробную статью, где на синтетическом приложении рассмотрю основные нюансы, которые нужно иметь в виду при написании тестов на свой код, взаимодействующий с базой данных.

Цель статьи: рассмотрение различных подходов к тестированию интеграции веб сервиса на Kotlin с базой данных с помощью библиотеки H2. Рассмотрение примеров тестирования синтетического приложения и разбор позитивных и негативных сценариев.

Забегая вперед, источники можно найти в моем GitHub репозитории: ​​pokemon-app

Предыстория

В настоящее время особенно распространены сервисы в виде веб-приложения. И сложно представить какое-то веб-приложение, которое никак не хранит данные. Самым распространенным способом хранения данных сейчас являются базы данных. 

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

Описание инфраструктуры

Для примера используется базовый CRUD веб сервис на основе Spring Boot, написанный на Kotlin.

CRUD веб-сервисом называется сервис, который предоставляет функционал для создания (C), чтения (R), обновления (U), удаления (D) сущностей из базы данных через HTTP запросы. 

Для примера мы будем рассматривать сервис без функционала удаления и обновления - остается только создание и чтение. Так как принципиально все, что нужно мы покроем этими двумя методами.

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

Описание функционала веб сервиса

Зона ответственности сервиса из примера - интеграция с pokeApi для получения информации о весе покемона по его имени. А также сохранение этой информации в базу данных и предоставление возможности получить все сохраненные записи.

Для основного функционала сервиса используются следующие зависимости:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.module</groupId>
    <artifactId>jackson-module-kotlin</artifactId>
</dependency>
<dependency>
    <groupId>org.jetbrains.kotlin</groupId>
    <artifactId>kotlin-reflect</artifactId>
</dependency>
<dependency>
    <groupId>org.jetbrains.kotlin</groupId>
    <artifactId>kotlin-stdlib-jdk8</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

Структура базы данных

Таблица для хранения информации о весе покемона в базе данных приложения выглядит следующим образом:

CREATE TABLE pokemon (
    id     integer primary key auto_increment,
    name   varchar(25),
    weight integer
);

id - поле идентификатора
name - поле для хранения информации об имени покемона (имеет ограничение в 25 символов)
weight - поле с информацией о весе покемона.

Требования к тестированию

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

Это значит, что в данном случае идеально будет использовать интеграционные тесты, либо end-to-end тесты.

Интеграционные тесты предназначены для тестирования интеграций с любыми внешними системами - базами данных в том числе.

Схема 1. Покрываемая область при интеграционном тестировании
Схема 1. Покрываемая область при интеграционном тестировании

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

End-to-end тестирование в свою очередь предназначено для тестирование всего приложения со всеми интеграциями.

Схема 2. Область покрытия при end-to-end тестировании.
Схема 2. Область покрытия при end-to-end тестировании.

Как мы видим, в данном случае проверяются все компоненты системы, которые задействованы в сценарии, который тестируется.

Применение требований тестирования к веб сервису

В данном примере был выбран end-to-end способ тестирования для двух сценариев - для сохранения покемона в базу данных и для чтения покемонов из базы данных. 

Это значит, что будет проверен как HTTP контракт, так и интеграция с базой данных.

Для тестирования сервиса используются следующие библиотеки:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.kotest</groupId>
    <artifactId>kotest-bom</artifactId>
    <version>5.2.2</version>
    <type>pom</type>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.kotest.extensions</groupId>
    <artifactId>kotest-extensions-spring</artifactId>
    <version>1.0.1</version>
</dependency>

Функционал, который будет протестирован

Веб сервис имеет два эндпоинта с функционалом, который требуется покрыть тестами.

POST /pokemon - сохранение модели в базу данных.
В модели есть 2 обязательных поля - name (String) и weight (Integer).

Пример вызова:

###
POST http://localhost:8080/pokemon
Content-Type: application/json

{
  "name": "bulbasaur",
  "weight": 69
}

GET /pokemon - эндпоинт, который отвечает за предоставление всех записей из базы данных.

В ответе метод возвращает массив моделей с 3 полями - id (Long), name (String), weight (Integer).

Пример вызова:

###
GET http://localhost:8080/pokemon

Ответ вызова:

[
  {
    "id": 1,
    "name": "bulbasaur",
    "weight": 69
  },
  {
    "id": 2,
    "name": "ditto",
    "weight": 40
  }
]

Код контроллера:

@RestController
@RequestMapping("/pokemon")
class PokemonController(private val pokemonDao: PokemonDao) {

    @PostMapping
    fun savePokemon(@RequestBody pokemon: Pokemon) {
        pokemonDao.save(pokemon)
    }

    @GetMapping
    fun getAll(): List<Pokemon> = pokemonDao.getAll()

    @ExceptionHandler
    fun handleException(exception: Exception): ResponseEntity<*> {
        return ResponseEntity(exception.message, HttpStatus.BAD_REQUEST)
    }
}

Описание слоя DAO

Слой DAO (Data Access Object) отвечает исключительно за интеграцию с хранилищем. Для описания интеграций с базой данных используется jdbcTemplate. JdbcTemplate - библиотека Spring, которая позволяет писать запросы в нативном SQL.

Для простоты маппинга сущностей используется обычный objectMapper. В большой нагрузке такой маппинг может быть изменен на более оптимальный.

Dao слой выглядит следующим образом:

@Service
class PokemonDao(private val jdbcTemplate: JdbcTemplate, private val objectMapper: ObjectMapper) {

    fun save(pokemon: Pokemon) {
        jdbcTemplate.update(SAVE_POKEMON, pokemon.name, pokemon.weight)
    }

    fun getAll(): List<Pokemon> =
        jdbcTemplate.queryForList(SELECT_ALL_POKEMONS)
            .map { objectMapper.readValue(objectMapper.writeValueAsString(it), Pokemon::class.java) }
}

@Language("Sql")
private const val SAVE_POKEMON = "insert into pokemon values(default, ?, ?)"

@Language("Sql")
private const val SELECT_ALL_POKEMONS = "select * from pokemon"

Описание структуры тестов

Для тестирования используется библиотека Kotest, которая дает возможность бесшовной интеграции с Spring для поднятия контекста, а также имеет крайне удобный апи для операций сравнения результатов.

Вызовы на HTTP эндпоинты описываются с использованием MockMvc. MockMvc - также является одной из библиотек Spring, имеющей довольно неплохой апи для тестирования HTTP эндпоинтов веб сервисов. А также позволяет тестировать веб сервисы без их непосредственного запуска.

Тестирование сценария сохранения покемона

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

"save pokemon - success" {
            mockMvc.perform(
                MockMvcRequestBuilders
                    .post("/pokemon")
                    .content(objectMapper.writeValueAsString(
                      Pokemon(name = "saved pokemon name", weight = 1)
                    ))
                    .contentType(MediaType.APPLICATION_JSON)
            )

            pokemonDao.getAll().first { it.name == "saved pokemon name" } should {
                it.weight shouldBe 1
                it.id shouldNotBe null
            }
        }

Как мы видим, в случае сохранения покемона с полями name = "saved pokemon name" и weight = 1, мы можем получить его из списка покемонов в базе данных.

Скриншот 1. Результат работы теста позитивного сценария POST /pokemon.
Скриншот 1. Результат работы теста позитивного сценария POST /pokemon.

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

"save pokemon - too long name" {
            mockMvc.perform(
                MockMvcRequestBuilders
                    .post("/pokemon")
                    .content(objectMapper.writeValueAsString(
                        Pokemon(name = "saved pokemon name - just too long name to save", weight = 1))
                    )
                    .contentType(MediaType.APPLICATION_JSON)
            )
                .andExpect(MockMvcResultMatchers.status().is4xxClientError)
        }

В данном случае длина поля name окажется слишком длинной для максимально допустимой в 25 символов и в процессе insert будет выброшено исключение со стороны драйвера базы данных:

PreparedStatementCallback; SQL [insert into pokemon values(default, ?, ?)]; Value too long for column "name VARCHAR(25)": "'saved pokemon name - just too long name to save' (47)";

Это исключение будет обработано в @ExceptionHandler в контроллере и вернет код ошибки 400 клиенту.

Скриншот 2. Результат работы теста негативного сценария POST /pokemon.
Скриншот 2. Результат работы теста негативного сценария POST /pokemon.

Тестирование сценария получения покемонов

Для позитивного сценария достаточно проверить, что ответе на запрос GET возвращается запись, которая хранится в базе данных. 

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

Скрипт, который будет выполнен во время запуска приложения хранится в /resources/data.sql
и выглядит следующим образом:

insert into pokemon VALUES (default, 'test-pokemon', 45);

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

"get all pokemons" {
            val pokemons = mockMvc
                .get("/pokemon")
                .andReturn()
                .response
                .contentAsString
                .let { objectMapper.readValue(it, List::class.java) }
                .map { objectMapper.convertValue(it, Pokemon::class.java) }

            pokemons.first {it.name == "test-pokemon"} should {
                it.weight shouldBe 45
                it.id shouldNotBe null
            }
        }
Скриншот 3. Результат работы теста позитивного сценария GET /pokemon
Скриншот 3. Результат работы теста позитивного сценария GET /pokemon

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

"get all pokemons - not found" {
            val pokemons = mockMvc
                .get("/pokemon")
                .andReturn()
                .response
                .contentAsString
                .let { objectMapper.readValue(it, List::class.java) }
                .map { objectMapper.convertValue(it, Pokemon::class.java) }

            pokemons.firstOrNull { it.name == "test-pokemon not found"} shouldBe null
        }
Скриншот 4. Результат работы теста негативного сценария GET /pokemon
Скриншот 4. Результат работы теста негативного сценария GET /pokemon

Если запустить все тесты подряд в едином списке, то результаты будут следующие:

Скриншот 5. Результат работы всех тестов, запущенных единовременно
Скриншот 5. Результат работы всех тестов, запущенных единовременно

Обратите внимание на время выполнения. Если запускать тесты по одному, тогда Spring контекст будет подниматься для каждого теста - это занимает время.

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

Выводы

Итого, мы рассмотрели основные способы тестирования интеграции веб сервиса с базой данных на примере H2 + Spring Boot. Были рассмотрены отличия интеграционного и end-to-end тестирования, а также рассмотрен пример интеграции веб сервиса базой данных с последующим тестированием и подробным разборов позитивных и негативных сценариев.

Как вы тестируете интеграции с базами данных? Буду рад вашим мыслям в комментариях.

Исходники можно найти в моем GitHub репозитории: ​​pokemon-app

Фото от @jannisbrandtна Unsplash

Комментарии (3)


  1. Filex
    27.05.2022 10:47

    А как думаете, есть ли какие-то бонусы от использования Kotest в SpringBoot приложении, написанном на java ? Вообще возможно ли это?


    1. v-aksenov Автор
      27.05.2022 15:07
      +1

      Сложно придумать бонусы от попытки интегрировать kotest в тесты, написанные на java.
      Можно даже написать и скомпилировать такое приложение. Но основное преимущество kotest'a - удобный апи и хорошо читаемые лаконичные тесты будет потеряно однозначно.

      Плюс для java есть стандарт jUnit (jupiter) и работать с ним наличии большого количества документации и решенных проблем будет гораздо рациональнее


      1. Filex
        27.05.2022 15:38
        +1

        Спасибо за статью и ответ на комментарий!