Зарегистрируйтесь, чтобы продолжить обучение

Интеграционные тесты Java: Корпоративные приложения на Spring Boot

Тестирование приложений на Spring Boot — неотъемлемая часть профессиональной жизни веб-разработчиков на Java. Сюда входит написание различных тестов:

  • Юнит-тестов для отдельных модулей
  • Интеграционных тестов, проверяющих работоспособность всего приложения

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

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

Веб-приложения работают по сети, обрабатывая HTTP-запросы. Такое поведение придется повторять прямо в тестах или как-то имитировать. Spring Boot позволяет использовать оба подхода. Мы остановимся на подходе с подменой веб-сервера, чтобы ускорить запуск и выполнение тестов. В остальном эти тесты проверяют работу приложения от запроса до ответа, что дает очень высокую степень уверенности в том, что приложение работает.

Для работы с тестами нужно установить зависимости:

testImplementation(platform("org.junit:junit-bom:5.10.0"))
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
testImplementation("org.springframework.boot:spring-boot-starter-test")
// Понадобится когда мы начнем работать с аутентификацией
testImplementation("org.springframework.security:spring-security-test")

Кроме классического Junit, здесь мы видим пакеты, специфичные для Spring Boot. Они дают все необходимые инструменты, чтобы мы могли писать тесты легко и эффективно.

Первый тест

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

Начнем с примера. Предположим, что у нас есть маршрут /api/users, который возвращает список пользователей. Тест на такой маршрут должен выполнить запрос на этот адрес. Вот как будет выглядеть структура файлов в этом случае:

tree src
src
├── main
│   ├── java
│   │   └── io
│   │       └── hexlet
│   │           └── spring
│   │               ├── Application.java
│   │               ├── controller
│   │               │   └── api
│   │               │       └── UsersController.java
│   │               ├── model
│   │               │   └── User.java
│   │               ├── repository
│   │               │   └── UserRepository.java
│   └── resources
│       ├── application.yml
└── test
    └── java
        └── io
            └── hexlet
                └── spring
                    └── controller
                        └── api
                            └── UsersControllerTest.java

Тесты Spring Boot расположены в директории src/test/java/io/hexlet/spring. Интеграционные тесты фактически повторяют структуру контроллеров, поэтому удобнее всего делать прямое соответствие между структурой контроллеров и тестами. В примере выше мы видим одни и те же директории. Название теста получается из названия контроллера с добавлением Test в название файла.

Сам тест выглядит так:

package io.hexlet.spring.controller.api;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;

@SpringBootTest
@AutoConfigureMockMvc
public class UsersControllerTest {

    @Autowired
    private MockMvc mockMvc;

    // Технически имя тестового метода не важно
    // Лучше использовать шаблон testНазваниеМетодаКонтроллера для основного сценария
    @Test
    public void testIndex() throws Exception {
        mockMvc.perform(get("/api/users"))
                .andExpect(status().isOk());
    }
}

Файл тестов — это классический JUnit-класс, в котором тестовые методы помечены аннотациями @Test. Все остальное — это уже специфика Spring Boot. Сюда относятся аннотации @SpringBootTest и @AutoConfigureMockMvc. Во время старта тестов Spring Boot читает эти аннотации, стартует приложение и конфигурирует его в соответствие с аннотациями. Например, нам становится доступным объект mockMvc, через который можно выполнять HTTP-запросы к нашему приложению. Разберем по шагам:

  • Метод get("/api/users") формирует объект запроса к указанной странице. Кроме запроса get, мы можем выполнить любой другой запрос
  • Метод mockMvc.perform() выполняет сформированный запрос. На самом деле здесь не происходит HTTP-вызова — запрос передается в приложение напрямую, поэтому тесты работают быстрее, чем с реальным веб-сервером
  • Метод andExpect(status().isOk()) проверяем, что в ответ вернулся ответ 200. По необходимости можно проверить любой другой статус

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

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

testImplementation("net.javacrumbs.json-unit:json-unit-assertj:3.2.2")

Использование выглядит так:

package io.hexlet.spring.controller.api;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;

@SpringBootTest
@AutoConfigureMockMvc
public class UsersControllerTest {

    @Autowired
    private MockMvc mockMvc;

    // Технически имя тестового метода не важно
    // Лучше использовать шаблон testНазваниеМетодаКонтроллера для основного сценария
    @Test
    public void testIndex() throws Exception {
        var result = mockMvc.perform(get("/api/users"))
                .andExpect(status().isOk())
                .andReturn();

        // Тело это строка, в этом случае JSON
        var body = result.getResponse().getContentAsString();
        assertThatJson(body).isArray();
        // Еще проверки
    }
}

Библиотека JsonUnit обладает широкими возможностями по проверке того, как устроен JSON. Подробнее с этими возможностями можно ознакомиться в официальной документации. Изучим несколько примеров:

import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;

// Compares two JSON documents (note lenient parsing of expected value)
assertThatJson("{\"a\":1, \"b\":2}").isEqualTo("{b:2, a:1}");

// Objects are automatically serialized before comparison
assertThatJson(jsonObject).isEqualTo("{\n\"test\": 1\n}");

// AssertJ map assertions (numbers are converted to BigDecimals)
assertThatJson("{\"a\":1}").isObject().containsEntry("a", BigDecimal.valueOf(1));

И последний шаг — запуск тестов:

./gradlew test
# В этом выводе может быть много дополнительных строчек с логами
UsersControllerTest > testIndex() PASSED

BUILD SUCCESSFUL in 4s

Взаимодействие с базой

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

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

Можно наполнить базу данных, написав пачку SQL-запросов, но это неудобно и сложно в поддержке, особенно на больших объемах. Было бы удобнее, если бы могли автоматически создавать объекты на базе сущностей и сохранять их в базу. В Java есть специальная библиотека — Instancio.

Посмотрим на работу такого теста на примере запроса, обновляющего пользователя. Для этой операции используем маршрут /api/users/{id}. Для выполнения запроса нам понадобится идентификатор пользователя, которого мы создадим с помощью библиотеки Instancio.

Для начала установим необходимые зависимости:

implementation("net.datafaker:datafaker:2.0.1")
implementation("org.instancio:instancio-junit:3.3.0")

Теперь посмотрим готовый тест, а затем разберем его:

package io.hexlet.spring.controller.api;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import java.util.HashMap;

import org.instancio.Instancio;
import org.instancio.Select;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import com.fasterxml.jackson.databind.ObjectMapper;

import io.hexlet.blog.model.User;
import io.hexlet.blog.repository.UserRepository;
import net.datafaker.Faker;

@SpringBootTest
@AutoConfigureMockMvc
public class UsersControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private Faker faker;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private ObjectMapper om;

    @Test
    public void testUpdate() throws Exception {
        var user = Instancio.of(User.class)
                .ignore(Select.field(User::getId))
                .supply(Select.field(User::getEmail), () -> faker.internet().emailAddress())
                .create();
        userRepository.save(user);

        var data = new HashMap<>();
        data.put("firstName", "Mike");

        var request = put("/api/users/" + user.getId())
                .contentType(MediaType.APPLICATION_JSON)
                // ObjectMapper конвертирует Map в JSON
                .content(om.writeValueAsString(data));

        mockMvc.perform(request)
                 .andExpect(status().isOk());

        user = userRepository.findById(user.getId()).get();
        assertThat(user.getFirstName()).isEqualTo(("Mike"));
    }
}

Шаг 1. Сначала мы создаем пользователя. Instancio делает это автоматически, базируясь на полях переданной модели. По умолчанию данные создаются для всех полей, но это не всегда удобно. Во-первых, не нужно заполнять значение для идентификатора, во-вторых, email должен быть настоящим, поэтому здесь мы используем кастомизацию и добавляем адрес с помощью Faker:

var user = Instancio.of(User.class)
        .ignore(Select.field(User::getId))
        .supply(Select.field(User::getEmail), () -> faker.internet().emailAddress())
        .create();
userRepository.save(user);

Шаг 2. Затем мы подготавливаем запрос. Сначала формируем объект с данными, затем преобразуем их в JSON и устанавливаем соответствующий заголовок. В самом запросе формируем правильный адрес, подставляя идентификатор созданного пользователя:

var data = new HashMap<>();
data.put("firstName", "Mike");

var request = put("/api/users/" + user.getId())
        .contentType(MediaType.APPLICATION_JSON)
        // ObjectMapper конвертирует Map в JSON
        .content(om.writeValueAsString(data));

Шаг 3. Выполняем запрос и проверяем, что он действительно изменил пользователя в базе данных:

mockMvc.perform(request)
            .andExpect(status().isOk());

user = userRepository.findById(user.getId()).get();
assertThat(user.getFirstName()).isEqualTo(("Mike"));

Кроме изменения данных в базе, имеет смысл протестировать ответ, который возвращается после запроса.

Обратите внимание на важную деталь, связанную с интеграционными тестами. На протяжении урока мы писали тесты и убеждались, что приложение работает, даже не посмотрев на реализацию самого приложения. В этом и заключается суть интеграционных тестов. Нам не важно, как написано приложение внутри — мы убеждаемся только в том, что оно работает правильно. Из-за этого интеграционные тесты очень устойчивы к изменениям в коде, они меняются в основном из-за изменений API.


Дополнительные материалы

  1. Документация Instancio

Аватары экспертов Хекслета

Остались вопросы? Задайте их в разделе «Обсуждение»

Вам ответят команда поддержки Хекслета или другие студенты

Для полного доступа к курсу нужен базовый план

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

Получить доступ
1000
упражнений
2000+
часов теории
3200
тестов

Открыть доступ

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

  • 130 курсов, 2000+ часов теории
  • 1000 практических заданий в браузере
  • 360 000 студентов
Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»

Наши выпускники работают в компаниях:

Логотип компании Альфа Банк
Логотип компании Aviasales
Логотип компании Yandex
Логотип компании Tinkoff
Рекомендуемые программы
профессия
от 25 000 ₸ в месяц
Разработка приложений на языке Java
10 месяцев
с нуля
Старт 2 января

Используйте Хекслет по-максимуму!

  • Задавайте вопросы по уроку
  • Проверяйте знания в квизах
  • Проходите практику прямо в браузере
  • Отслеживайте свой прогресс

Зарегистрируйтесь или войдите в свой аккаунт

Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»