Делаем типовой проект на Kotlin для тестирования RestAPI сервиса в Docker
В этой статье я расскажу, как сделать типовой проект-шаблон на Kotlin для наращивания покрытия любых RestAPI сервисов. Создадим его с нуля.
8К открытий9К показов
Максим Кочетков
Тестировщик Мир Plat.Form
Всем привет! Меня зовут Максим Кочетков. Я занимаюсь тестированием ПО более 10 лет. Из них около 7 лет автоматизацией на Java, Kotlin и Groovy. В команде Мир Plat.Form (ИТ-бренд Национальной системы платёжных карт) я отвечаю за вопросы, связанные с автоматизацией тестирования. Наши сервисы, например, платёжную систему «Мир» и Систему быстрых платежей (СБП), используют десятки миллионов россиян.
В этой статье я расскажу, как сделать проект-шаблон для наращивания покрытия любых RestAPI сервисов. Мы создадим проект с нуля: поднимем RestAPI-сервис в контейнере и протестируем с помощью http-клиента Retrofit. Код напишем на Kotlin, а тестовый движок — в сочетании с Kotest. RestAPI-cервис будет реализован как заглушка с помощью библиотеки Wiremock. Поднимать контейнер мы будем в TestContainers. Результат теста сохраним в формате Allure и сгенерируем отчёт.
Настраиваем Wiremock
Wiremock — это инструмент для создания заглушек HTTP-сервисов, который написан на Java и под капотом использует производительный Netty сервер. Подходит не только для функционального, но и нагрузочного тестирования. Есть аналог MockServer, который создан на NodeJs. Я рекомендую именно Wiremock, потому что для него можно писать расширения на Java для реализации сложного поведения.
Есть 2 варианта описания и регистрации заглушек:
- создать в Java-коде и передавать на сервер через Wiremock Admin API;
- оформить в формате json и подкладывать в определённую папку на сервере.
Будем использовать второй вариант — он более наглядный, и его могут редактировать не знакомые с Java коллеги. Например frontend-разработчик способен создавать Wiremock-заглушки для эмуляции backend части.
В ресурсах создаем папку wiremock, в ней под-папки __files и mappings. Названия этих двух директорий зарезервированы Wiremock.
Наш сервис будет иметь один контроллер — Employee с двумя методами POST и GET, на создание и получения сотрудника. Создаем два json файла с описанием заглушек в папке mappings — это описание того, как будет сопоставляться входящий запрос, и как формироваться исходящий ответ. Еще 2 файла в папке __files — это тело ответа, они находятся в отдельной директории. В будущем наша иерархия увеличится, а тело ответа разрастётся на сотню-другую строк, поэтому делаем сразу удобно.
Описание заглушки employee_get.json:
В заглушке ожидаем method GET с URL-path (все, что после порта) советующим регулярке “/employee/\\d+“. Если пришел такой запрос, — отвечаем 200 с указанными
headers и телом из файла employee_get_body.json:
В ответе двойные фигурные скобки вызывают встроенный движок шаблонов с широким набором функций. В данном случае — у пришедшего запроса взять сегмент URL-path с индексом 1 (нумерация с 0), то есть ID.
И вторая заглушка в файле employee_post.json
Тут все аналогично, но добавляется авторизация basicAuthCredentials. Обрабатываться будут только те запросы, у которых есть заголовок Authorization с логином и паролем в формате Basic авторизации. И ответ employee_post_body.json:
randomValue length=5 type=’NUMERIC’ — функция, которая сгенерирует случайное число с пятью цифрами. А jsonPath request.body ‘$.name’ — взять значение из тела запроса по json-path поле name.
Сборочный Gradle скрипт
Перед написанием кода нужно настроить сборку нашего проекта и определить зависимости. Используем Gradle 6.8.3. Создаём необходимые файлы в корне проекта, сразу постараемся применить лучшие практики.
Предпочитаю Groovy DSL. Он лучше заточен на работу с замыканиями и благодаря динамической типизации менее многословен, но ничего не имею против Kotlin DSL
settings.gradle — так как у нас одномодульный проект, то здесь только название. Если бы модулей было несколько, то были бы их имена, а также настройки, которые необходимо выполнить до запуска основного скрипта. Например откуда качать плагины, если у вас корпоративные ограничения:
gradle.properties — здесь храним переменные проекта, которые доступны в сборочном скрипте. В основном это версии библиотек:
build.gradle — сборочный скрипт:
Блок plugins — блок с плагинами для Kotlin, отчетов Allure и ben-manes.versions для обновления версий библиотек.
Блок test — настраиваем тестовый движок на junit5 и записываем в системные переменные теста версию Wiremock, которая пригодится для создания контейнера. На самом деле используем мы Kotest, но через адаптер к Junit5, чтобы переиспользовать весь функционал по интеграции Gradle и JUnit.
Блок tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile) — настройка версии Java для всех задач по компиляции (из документации Kotlin).
Блок allure — настройка генерации отчета. Важно отключить autoconfigure и указать верный путь до результатов resultsDir
Описываем модель для http-клиента
Модель сотрудника очень простая:
Для DTO всегда используем nullable типы и выставляем по умолчанию null. Иначе могут быть NPE если сервер пришлет не все поля и не получится проверить отсутствие значения.
Retrofit позволяет описывать модель взаимодействия с сервисом с помощью Интерфейсов, где методы и их аргументы аннотируются всей необходимой информацией. Также для работы нужна реализация HTTP-клиента в нашем случае это OkHttp.
Последовательность такая:
- описываем интерфейс взаимодействия;
- создаем http клиент с нужным набором перехватчиков (Interseptor);
- создаем инстанс Retrofit с базовым URL и созданным ранее http клиентом, а также с конвертором для тела запросов и ответов;
- вызываем
Retrofit.create<ServiceName>()
для создания реализации интерфейса.
Retrofit использует для реализации Java Dynamic Proxy. Аналоги: Feign(Netflix), Jax-rs. По тем же принципам работают ORM фреймворки / Spring Data JPA.
Теперь создаём модель взаимодействия
Каждый метод возвращает объект Call<T>
— отложенный вызов. В качестве параметра используется класс DTO. Однако если мы не знаем что нам вернется или не хотим ничего конвертировать (сериализовать), то используем Call<ResponseBody>
.ResponseBody. Это специальный класс от Retrofit который содержит сырой ответ.
Если путь начинается со слеша (/) , то Retrofit посчитает его корневым и удалит всё, что после порта в базовом URL. Например для @GET(“/employee”) адрес “http://localhost:80/one/two” превратится в “http://localhost:80/employee”, а для @GET(“employee”) в “http://localhost:80/one/two/employee”.
Чтобы всё запустить необходимо инициировать запрос, вызвав блокирующий метод Call<T>.execute()
. Он выполнит запрос, получит ответ и вернёт нам результат в виде контейнера Response<T>
. Контейнер содержит все параметры ответа в том числе код и статус ответа, и сконвертированное тело.
Создадим утилитный класс для работы с Retrofit
slf4jInterceptor — перехватчик для логирования всех запросов в Slf4J. В зависимости от уровня Slf4J устанавливаются подробности журналирования.
allureInterceptor — перехватчик для добавления всех запросов в Allure отчет.
jsonConverter — конвертер для Json запросов и ответов. Все конверторы идут как дополнительные зависимости от разработчиков Retrofit. Важно помнить, что этот конвертор всегда будет пытаться преобразовать в Json и будет выдавать ошибку, если формат тела некорректен. Существует множество видов конверторов и нужно аккуратно выбирать подходящий. Либо создавать свой вариант со сложной логикой. Есть возможность получать сырые запросы через Call<ResponseBody>
в обход конвертора.
createOkHttpClient — функция для создания клиента с перехватчиками.
createRetrofit — функция для создания Retrofit с базовым URL, с перехватчиками и конвертором.
Создаём конфигурацию тестов
Перед написанием тестов выполним настройку движка и создадим класс Registry необходимых объектов, чтобы держать их в единичном экземпляре.
Для конфигурации тестов я предпочитаю использовать Spring Test и паттерн Dependency Injection. Но в статье мы этого делать конечно же не будем, так как это слишком большая тема.
Создаём класс-наследник от AbstractProjectConfig. Это позволит Kotest найти наш класс в classpath и применить переопределённые методы.
parallelism — это настройка Kotest, здесь указываем размер пула потоков для тестовых классов. Тесты внутри тестовых классов будут выполняться в одном потоке.
wiremockVersion — версия Wiremock из системной переменной, которую мы передали из build.gradle.
containerUrl — расширение для типа GenericContainer, чтобы собрать базовый URL доступа к контейнеру с хост машины.
wiremockContainer — описание контейнера Wiremock. В конструкторе задаем образ, а далее настройки:
- withExposedPorts(8080) — пробрасываем внутренний порт 8080 на внешний случайный;
- withCommand — добавляем аргументы к запуску сервер. –verbose — включить вывод логов. –global-response-templating — включить движок шаблонов Wiremock, для обработки выражений вида {{ function }};
- withClasspathResourceMapping — смонтировать содержимое папки wiremock/ из тестовых ресурсов в нашем проекте на внутреннюю директорию контейнера /home/wiremock — отсюда Wiremock прочитает все описания заглушек. BindMode.READ_ONLY — означает, что во время работы Wiremock не сможет изменит содержание нашей локальной папки, даже если почистит смонтированную директорию в контейнере.
wiremockClient — создаём клиент для настройки сервера Wiremock из кода тестов. Здесь используется делегат lazy для обеспечения ленивой инициализации по первому запросу, а в последующие запросы возвращает вычисленное значение и делает это потокобезопасно. Без lazy работать не будет, так как на момент создания нашей конфигурации контейнер ещё не запущен, и у него нет адреса и порта. А первый запрос делает из прогона теста, когда контейнер уже поднят.
retrofitJson — создаем инстанс Retrofit c базовым URL на контейнер, json конвертором и двумя перехватчиками. Также используем lazy.
employeeService — создаём реализацию интерфейса для доступа к сервису employee, так же в одном экземпляре.
listeners() — расширяем Kotest двумя слушателями.
- wiremockContainer.perProject — контроль за жизненным циклом контейнера, в данном случае один запуск на весь прогон (есть и другие варианты)
Конфигурация готова! Переходим к созданию тестов!
Создаём тестовый класс
У нас будет пример с одним тестовым классом и двумя тестами внутри.
Kotest оперирует понятием спецификация, а не тестовый класс. Мы тоже будем с этого момента использовать понятие спецификация, а в голове держать, что это тоже самое, что тестовый класс. А тест мы будем называть сценарием. Внутри сценария будут шаги в BDD стиле Дано-Когда-Тогда.
Делаем заготовку для двух сценариев:
Спецификацию наследуем от FreeSpec. Это один из стилей написания тестов, который я предпочитаю использовать. Для простых unit-тестов рекомендую брать StringSpec.
Allure аннотации над тестом нужно для упорядочивания в отчете и связей с внешними системами контроля задач, требованиё и тестовых сценариев (приведены далеко не все):
- @Epic — верхний уровень иерархии тестов и сущностей в Jira;
- @Feature — тестируемый функционал;
- @Story — минимальный законченный кусочек функционала с бизнес смыслом;
- @Link — ссылка на требования либо несколько ссылок – @Links.
concurrency() — устанавливаем количество одновременно запущенных сценариев. Отмечу, что запускаться они будут в одном потоке, но асинхронно. Как? Благодаря использованию coroutines в Kotest — каждый сценарий и его шаги это suspend-функции и запускаются в корутинах, таким образом Kotlin занимается управлением последовательностью команд в конкурентной среде. Фактически в тесте у нас есть неблокирующий вызов ввода/вывода при выполнении запроса к серверу и ожидании ответа, на который и тратится основное время (и не один). Это время простоя в ожидании ответа от сервера и позволяет выполнять тесты асинхронно в одном потоке и более эффективно использовать процессорное время!
Блок init — в этом блоке мы собираем нашу тестовую модель. Сейчас там два сценария без реализации. Синтаксис “строка” — { } создаёт контейнер для будущих шагов сценария (обратите внимание, что используется оператор minus).
Уже сейчас можно запустить пустую модель и получить отчет, например для проведения review.
Добавляем первый сценарий:
В первом шаге используем генератор случайных данных Arb из библиотек Kotest.
Во втором шаге берём из Registry объектов employeeService, — вызов get() отдает объект Call<Employee>
, а неблокирующий метод awaitResponse() возвращает результат Response<Employee>
, который сохраняем в response.
В третьем шаге проверяем ответ:
- response.asClue — если внутри блока asClue случится ошибка проверки, то в дополнении в ожидаемому и актуальному результату в сообщение AssertionError будет добавлен вывод toString() целого объекта response;
- it.code() shouldBe 200 — проверяем статус код. shouldBe — базовая проверка из библиотеки ассертов Kotest;
- it.body().shouldNotBeNull().asClue — получаем тело, проверяем на null, при этом не используем !! , так как хотим получить AssertionError с полноценным описанием ошибки, а не NPE. Далее вызов asClue необходим, чтобы добавить в сообщение об ошибке весь объект employee, так как toString() вышестоящего response не выводит тело, а лишь метаинформацию;
- далее проверяем каждое поле employee.
Ещё отмечу, что в шагах используется интерполяция строк, и в название добавляются ожидаемые значения, что очень помогает при анализе отчётов о выполнении.
Теперь сценарий на создание сотрудника:
В первом шаге используем генерацию строки по регулярному выражению Arb.stringPattern. Проще подключить Faker для генерации красивых имен, но сейчас нам не нужны лишние зависимости.
Во втором шаге отправляем запрос также как в первом тесте, но добавляем данные Basic авторизации в заголовок — Credentials.basic(“user”, “user”)
Третий шаг — проверяем, что пришло на сервер заглушек. Метод wiremockClient.verifyThat отправляет запрос на сервер Wiremock, чтобы удостовериться, пришёл ли туда ровно один ожидаемый запрос с URL-path равным /employee и заголовком Authorization со значением созданной ранее Basic авторизацией. Часто этой проверкой пренебрегаю. Если Wiremock используется для эмуляции интеграции с другим сервисом, то крайне важно верифицировать все пришедшие запросы!
И четвертый шаг почти полностью повторяет последний шаг из первого теста.
Запускаем и генерируем отчет
Всё готово к первому запуску. Осталось проверить, что в системе запущен Docker и есть разрешение на монтирование диска, где расположен проект. Ну и установлен JDK не ниже 11 версии. И ещё нужно установить в Idea плагин для Kotest, чтобы наблюдать результаты выполнения в динамике.
Выполняем в нашем проекте задачу gradle test из группы verification через Gradle Tools в Idea и наблюдаем за результатом:
Осталось сгенерировать отчет Allure.
Выполняем gradle allureReport из группы other также через Gradle Tools. Результат можно наблюдать в папке build/reports/allure-report, открыв index.html в браузере. Но обязательно через контекстное меню, так как для корректного отображения отчета необходим web-сервер, который нам любезно обеспечивает Idea. Есть вариант выполнить gradle allureServe, — тогда web-сервер запустит Gradle плагин.
А вот как будет выглядеть отчет о тестировании:
Самый удобный вид для просмотра тестовой модели и результатов — Behaviors в левом меню. Два наших сценария находятся в конце структуры Epic -> Feature -> Story, которые мы описали в виде аннотация в тесте. В правой области расположено содержимое теста, а также присутствует активная ссылка из аннотации @Link и два вложения от allureInterceptor для http клиента: Request и Response. Обычно такие отчёты хранятся в специальном плагине для CI либо на отдельном сервере отчётов, где можно собирать метрики и анализировать результаты.
На этом всё!
Заключение
Готовый проект, с написанным выше тестом, вы найдёте в моём GitHub. В итоге мы создали полноценный проект, готовый к масштабированию на сотни и тысячи API тестов.
Мы настроили Gradle сборку с плагинами для поддержки Kotlin и Allure отчётов. Сконфигурировали запуск тестов и максимально упростили работу с версиями зависимостей. Мы научились настраивать сервер заглушек Wiremock и запускать его в контейнере из кода тестов. Освоили Retrofit и дополнения к нему.
А самое главное научились быстро писать тесты с помощью тестового движка Kotest, генерировать тестовые данные и запускать сценарии параллельно.
Успехов вам в освоении новых технологий!
8К открытий9К показов