Всем привет! Меня зовут Максим Кочетков. Я занимаюсь тестированием ПО более 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:
{
"request": {
"method": "GET",
"urlPathPattern": "/employee/\\d+"
},
"response": {
"status": 200,
"bodyFileName": "employee_get_body.json",
"headers": {
"Content-Type": "application/json;charset=utf-8"
}
}
}
В заглушке ожидаем method GET с URL-path (все, что после порта) советующим регулярке «/employee/\\d+«. Если пришел такой запрос, — отвечаем 200 с указанными
headers и телом из файла employee_get_body.json:
{ "id": "{{request.pathSegments.[1]}}", "name": "Max"}
В ответе двойные фигурные скобки вызывают встроенный движок шаблонов с широким набором функций. В данном случае — у пришедшего запроса взять сегмент URL-path с индексом 1 (нумерация с 0), то есть ID.
И вторая заглушка в файле employee_post.json
{
"request": {
"method": "POST",
"urlPathPattern": "/employee",
"basicAuthCredentials" : {
"username" : "user",
"password" : "user"
}
},
"response": {
"status": 200,
"bodyFileName": "employee_post_body.json",
"headers": {
"Content-Type": "application/json;charset=utf-8"
}
}
}
Тут все аналогично, но добавляется авторизация basicAuthCredentials. Обрабатываться будут только те запросы, у которых есть заголовок Authorization с логином и паролем в формате Basic авторизации. И ответ employee_post_body.json:
{
"id": "{{randomValue length=5 type='NUMERIC'}}",
"name": "{{jsonPath request.body '$.name'}}"
}
randomValue length=5 type=’NUMERIC’ — функция, которая сгенерирует случайное число с пятью цифрами. А jsonPath request.body ‘$.name’ — взять значение из тела запроса по json-path поле name.
Сборочный Gradle скрипт
Перед написанием кода нужно настроить сборку нашего проекта и определить зависимости. Используем Gradle 6.8.3. Создаём необходимые файлы в корне проекта, сразу постараемся применить лучшие практики.
Предпочитаю Groovy DSL. Он лучше заточен на работу с замыканиями и благодаря динамической типизации менее многословен, но ничего не имею против Kotlin DSL
settings.gradle — так как у нас одномодульный проект, то здесь только название. Если бы модулей было несколько, то были бы их имена, а также настройки, которые необходимо выполнить до запуска основного скрипта. Например откуда качать плагины, если у вас корпоративные ограничения:
rootProject.name = 'article-tprogger-first'
gradle.properties — здесь храним переменные проекта, которые доступны в сборочном скрипте. В основном это версии библиотек:
kotlin.code.style=official
## Project Version
version=0.1.0
## Project Group
group=ru.iopump.qa.tprogger
## Gradle version. Adjust 'Use Gradle From' = 'wrapper task'
gradleWrapperVersion=6.8.3
## Gradle Plugins
benManesPluginVersion=0.38.0
## Kotest
kotlinVersion=1.4.32
kotestVersion=4.4.3
kotestAllureVersion=1.0.1
## Logger
slf4jVersion=1.7.30
## Reporting
allureVersion=2.13.9
allurePluginVersion=2.8.1
aspectVersion=1.9.6
## Docker
testContainers=1.15.2
## Http
retrofitVersion=2.9.0
okhttpVersion=4.9.1
## Mock
wiremockVersion=2.27.2
build.gradle — сборочный скрипт:
plugins {
id 'idea'
id 'com.github.ben-manes.versions' version "$benManesPluginVersion"
id 'org.jetbrains.kotlin.jvm' version "$kotlinVersion"
id "io.qameta.allure" version "$allurePluginVersion"
}
repositories { mavenLocal(); mavenCentral() }
wrapper {
distributionType = Wrapper.DistributionType.ALL
gradleVersion = gradleWrapperVersion
}
test {
useJUnitPlatform()
systemProperties << ['wiremock.version': wiremockVersion]
}
dependencies {
/* Kotlin */
implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8'
/* Logging API and Impl*/
implementation "org.slf4j:slf4j-api:$slf4jVersion"
runtimeOnly "org.slf4j:slf4j-simple:$slf4jVersion"
/* Retrofit Lib and Http Client Implementation */
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
implementation "io.qameta.allure:allure-okhttp3:$allureVersion"
/* Retrofit Convertors and Interceptors*/
implementation "com.squareup.retrofit2:converter-jackson:$retrofitVersion"
implementation "com.squareup.retrofit2:converter-scalars:$retrofitVersion"
implementation "com.squareup.okhttp3:logging-interceptor:$okhttpVersion"
/* Kotest */
testImplementation "io.kotest:kotest-runner-junit5:$kotestVersion"
testImplementation "io.kotest:kotest-assertions-core:$kotestVersion"
testImplementation "io.kotest:kotest-property:$kotestVersion"
/* Kotest Extensions */
testImplementation "ru.iopump.kotest:kotest-allure:$kotestAllureVersion"
testImplementation "io.kotest:kotest-extensions-testcontainers:$kotestVersion"
/* Main Testcontainers lib */
testImplementation "org.testcontainers:testcontainers:$testContainers"
/* Wiremock Client */
testImplementation "com.github.tomakehurst:wiremock:$wiremockVersion"
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
kotlinOptions.jvmTarget = JavaVersion.VERSION_11
}
allure {
version = allureVersion
aspectjVersion = aspectVersion
autoconfigure = false
aspectjweaver = true
resultsDir = file "$rootDir/allure-results"
}
Блок 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-клиента
Модель сотрудника очень простая:
data class Employee(
val id: Int? = null,
val name: String? = null,
)
Для 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.
Теперь создаём модель взаимодействия
interface EmployeeService {
@GET("employee/{id}")
fun get(@Path("id") id: Int): Call<Employee>
@POST("employee")
fun create(
@Header("Authorization") bearer: String,
@Body employee: Employee
): Call<Employee>
}
Каждый метод возвращает объект 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
object HttpUtil {
private val log = LoggerFactory.getLogger(HttpUtil::class.java)
val slf4jInterceptor: Interceptor = HttpLoggingInterceptor(log::info).apply {
when {
log.isTraceEnabled -> setLevel(HttpLoggingInterceptor.Level.BODY)
log.isDebugEnabled -> setLevel(HttpLoggingInterceptor.Level.HEADERS)
log.isInfoEnabled -> setLevel(HttpLoggingInterceptor.Level.BASIC)
else -> setLevel(HttpLoggingInterceptor.Level.NONE)
}
}
val allureInterceptor: Interceptor = AllureOkHttp3()
val jsonConverter: Converter.Factory = JacksonConverterFactory.create()
private fun createOkHttpClient(interceptors: Collection<Interceptor>) =
OkHttpClient.Builder().apply {
interceptors.forEach { interceptor -> addInterceptor(interceptor) }
}.build()
fun createRetrofit(
baseUrl: String,
converter: Converter.Factory,
interceptors: Collection<Interceptor>
): Retrofit = Retrofit.Builder()
.baseUrl(baseUrl)
.client(createOkHttpClient(interceptors))
.addConverterFactory(converter)
.build()
}
slf4jInterceptor — перехватчик для логирования всех запросов в Slf4J. В зависимости от уровня Slf4J устанавливаются подробности журналирования.
allureInterceptor — перехватчик для добавления всех запросов в Allure отчет.
jsonConverter — конвертер для Json запросов и ответов. Все конверторы идут как дополнительные зависимости от разработчиков Retrofit. Важно помнить, что этот конвертор всегда будет пытаться преобразовать в Json и будет выдавать ошибку, если формат тела некорректен. Существует множество видов конверторов и нужно аккуратно выбирать подходящий. Либо создавать свой вариант со сложной логикой. Есть возможность получать сырые запросы через Call<ResponseBody>
в обход конвертора.
createOkHttpClient — функция для создания клиента с перехватчиками.
createRetrofit — функция для создания Retrofit с базовым URL, с перехватчиками и конвертором.
Создаём конфигурацию тестов
Перед написанием тестов выполним настройку движка и создадим класс Registry необходимых объектов, чтобы держать их в единичном экземпляре.
Для конфигурации тестов я предпочитаю использовать Spring Test и паттерн Dependency Injection. Но в статье мы этого делать конечно же не будем, так как это слишком большая тема.
Создаём класс-наследник от AbstractProjectConfig. Это позволит Kotest найти наш класс в classpath и применить переопределённые методы.
object RegistryAndProjectConfiguration : AbstractProjectConfig() {
override val parallelism: Int = 2
private val wiremockVersion: String = System.getProperty("wiremock.version")
val GenericContainer<*>.containerUrl: String
get() = "http://$containerIpAddress:$firstMappedPort"
val wiremockContainer = GenericContainer<Nothing>(
"rodolpheche/wiremock:$wiremockVersion"
).apply {
withExposedPorts(8080)
withCommand("--verbose", "--global-response-templating")
withClasspathResourceMapping("wiremock", "/home/wiremock", BindMode.READ_ONLY)
}
val wiremockClient: WireMock by lazy {
WireMock.create()
.host(wiremockContainer.containerIpAddress)
.port(wiremockContainer.firstMappedPort)
.build()
}
val retrofitJson: Retrofit by lazy {
HttpUtil.createRetrofit(
wiremockContainer.containerUrl,
jsonConverter,
listOf(allureInterceptor, slf4jInterceptor)
)
}
val employeeService: EmployeeService by lazy { retrofitJson.create() }
override fun listeners(): List<Listener> = listOf(
wiremockContainer.perProject("wiremock")
)
}
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 стиле Дано-Когда-Тогда.
Делаем заготовку для двух сценариев:
@Epic("Tproger example")
@Feature("Employee endpoint")
@Story("CRUD")
@Link("tproger.ru", url = "https://tproger.ru/")
class EmployeeSpec : FreeSpec() {
override fun concurrency(): Int = 2
init {
"Scenario: Getting employee by id" - { }
"Scenario: Creating new employee" - { }
}
}
Спецификацию наследуем от FreeSpec. Это один из стилей написания тестов, который я предпочитаю использовать. Для простых unit-тестов рекомендую брать StringSpec.
Allure аннотации над тестом нужно для упорядочивания в отчете и связей с внешними системами контроля задач, требованиё и тестовых сценариев (приведены далеко не все):
- @Epic — верхний уровень иерархии тестов и сущностей в Jira;
- @Feature — тестируемый функционал;
- @Story — минимальный законченный кусочек функционала с бизнес смыслом;
- @Link — ссылка на требования либо несколько ссылок — @Links.
concurrency() — устанавливаем количество одновременно запущенных сценариев. Отмечу, что запускаться они будут в одном потоке, но асинхронно. Как? Благодаря использованию coroutines в Kotest — каждый сценарий и его шаги это suspend-функции и запускаются в корутинах, таким образом Kotlin занимается управлением последовательностью команд в конкурентной среде. Фактически в тесте у нас есть неблокирующий вызов ввода/вывода при выполнении запроса к серверу и ожидании ответа, на который и тратится основное время (и не один). Это время простоя в ожидании ответа от сервера и позволяет выполнять тесты асинхронно в одном потоке и более эффективно использовать процессорное время!
Блок init — в этом блоке мы собираем нашу тестовую модель. Сейчас там два сценария без реализации. Синтаксис «строка» — { } создаёт контейнер для будущих шагов сценария (обратите внимание, что используется оператор minus).
Уже сейчас можно запустить пустую модель и получить отчет, например для проведения review.
Добавляем первый сценарий:
"Scenario: Getting employee by id" - {
var expectedId = 0
"Given test environment is up and test data prepared" {
expectedId = Arb.positiveInts().next()
}
lateinit var response: Response<Employee>
"When client sent request to get the employee by id=$expectedId" {
response = employeeService.get(expectedId).awaitResponse()
}
"Then client received response with status 200 and id=$expectedId" {
response.asClue {
it.code() shouldBe 200
it.body().shouldNotBeNull().asClue { employee ->
employee.name shouldBe "Max"
employee.id shouldBe expectedId
}
}
}
}
В первом шаге используем генератор случайных данных 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.
Ещё отмечу, что в шагах используется интерполяция строк, и в название добавляются ожидаемые значения, что очень помогает при анализе отчётов о выполнении.
Теперь сценарий на создание сотрудника:
"Scenario: Creating new employee" - {
lateinit var employee: Employee
"Given test environment is up and test data prepared" {
employee = Employee(
name = Arb.stringPattern("[A-Z]{1}[a-z]{5,10}").next()
)
}
lateinit var response: Response<Employee>
val basic = Credentials.basic("user", "user")
"When client sent request to create new $employee" {
response = employeeService.create(basic, employee).awaitResponse()
}
"Then server received request with $employee" {
wiremockClient.verifyThat(
1,
WireMock.postRequestedFor(WireMock.urlPathEqualTo("/employee"))
.withHeader("Authorization", WireMock.equalTo(basic))
)
}
"And client received response with status 200 and $employee" {
response.asClue {
it.code() shouldBe 200
it.body().shouldNotBeNull().asClue { e ->
e.name shouldBe employee.name
e.id.shouldNotBeNull().shouldBePositive()
}
}
}
}
В первом шаге используем генерацию строки по регулярному выражению 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, генерировать тестовые данные и запускать сценарии параллельно.
Успехов вам в освоении новых технологий!