Всё началось с того, что перед нами поставили задачу покрыть тестами наши сервисы для микросервисной платформы TOT Pyramid, которую мы развиваем последние два года. Мы были вправе выбрать стек технологий самостоятельно. Данные тесты должны запускаться в CI каждый раз при создании мердж‑реквеста и проверять, что изменения ничего не сломали. Выбор пал на Rest‑assured, тем более хотелось писать тесты на модном, молодёжном Kotlin.
В данной статье хочу поделиться опытом написания автотестов на Rest‑assured + Kotlin. Статья не претендует на Rocket Science — в ней я приведу простые примеры и основные принципы тестирования API, которые применяются у нас в TOT Systems.
Поэтому, если вы ищете инструмент для покрытия API автотестами и при этом хотите писать на Kotlin, то эта статья для вас.
Что есть REST-assured?
REST‑assured — довольно популярная Java‑библиотека для тестирования REST API. У неё подробная документация и много примеров использования. Есть поддержка Kotlin и функции‑обёртки для написания тестов в формате Given‑When‑Then. Если у вас стоит задача автоматизировать тесты для вашего микросервиса, то REST‑assured вполне справится с этой задачей.
Тестовый проект
Для примера написал небольшой сервис котиков — его можно скачать и запустить через gradle.
Все эндпойнты описаны в таблице ниже.
Запрос |
Действие |
GET /cat/{id} |
Получить кота по id |
GET /cats |
Получить всех котов |
POST /cat |
Создать кота |
DELETE /cat/{id} |
Удалить кота |
PUT /cat/{id} |
Обновить информацию по коту |
В примере с тестами будем использовать:
Junit 5;
Jackson;
Assertj;
Gradle;
а также Allure для генерации отчетов.
В примере с тестами на Github представлена структура проекта. Вы можете взять её за основу при проектировании своих тестов либо изменить её под свои задачи.
Для начала создадим gradle проект и добавим базовые зависимости в файл build.gradle.kts:
Hidden text
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
kotlin("jvm") version "1.6.10"
id("io.qameta.allure") version "2.8.1"
application
}
group = "ru.org.habr"
version = "1.0.0"
repositories {
mavenCentral()
}
val junitVersion = "5.9.0"
val restAssuredVersion = "5.1.1"
val jacksonVersion = "2.13.3"
val allureVersion = "2.19.0"
dependencies {
testImplementation("org.junit.jupiter:junit-jupiter-api:$junitVersion")
testImplementation("org.junit.jupiter:junit-jupiter-params:$junitVersion")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junitVersion")
implementation("io.rest-assured:rest-assured:$restAssuredVersion")
implementation("io.rest-assured:json-path:$restAssuredVersion")
testImplementation("io.rest-assured:kotlin-extensions:$restAssuredVersion")
implementation("com.fasterxml.jackson.core:jackson-annotations:$jacksonVersion")
implementation("com.fasterxml.jackson.core:jackson-core:$jacksonVersion")
implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion")
implementation("io.qameta.allure:allure-rest-assured:$allureVersion")
testImplementation("io.qameta.allure:allure-junit5:$allureVersion")
testImplementation("org.slf4j:slf4j-simple:2.0.0")
implementation("com.typesafe:config:1.4.2")
implementation("org.assertj:assertj-core:3.23.1")
}
val allureConfig = allure {
configuration = "testImplementation"
version = allureVersion
autoconfigure = true
aspectjweaver = true
clean = true
useJUnit5 {
version = allureVersion
}
}
tasks.withType<Test>() {
allureConfig
useJUnitPlatform()
systemProperties["PORT"] = properties["port"]
systemProperties["URL"] = properties["url"]
}
tasks.withType<KotlinCompile>() {
kotlinOptions.jvmTarget = "1.8"
}
В файле gradle.properties можно указать ip адрес и порт сервиса:
Hidden text
kotlin.code.style=official
url=http://127.0.0.1
port=9009
Также стоит создать объект Config для получения системных переменных, которые будут подтягиваться из файла gradle.properties:
Hidden text
object Config {
val url: String = System.getProperty("URL")
val port: Int = System.getProperty("PORT").toInt()
}
Создадим базовый класс, от которого будут наследоваться все тесты — BaseTest:
Hidden text
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
open class BaseTest {
@BeforeAll
fun setup() {
RestAssured.filters(AllureRestAssured())
}
@AfterAll
fun tearDown() {
RestAssured.reset()
}
}
В этом классе зададим основную конфигурацию, которая будет использована для всех запросов. Также стоит создать объект Specs для различных спецификаций:
Hidden text
object Specs {
private val logConfig = LogConfig.logConfig().enableLoggingOfRequestAndResponseIfValidationFails(LogDetail.ALL)
private val config = RestAssuredConfig.config().logConfig(logConfig)
val requestSpec: RequestSpecification = RequestSpecBuilder()
.setBaseUri(Config.url)
.setPort(Config.port)
.addHeader("Accept", "application/json")
.setContentType(ContentType.JSON)
.setConfig(config)
.build()
val responseSpec: ResponseSpecification = ResponseSpecBuilder()
.log(LogDetail.BODY)
.build()
}
Для создания кота нужно указать 2 поля — имя и порода. Поэтому создадим data класс:
Hidden text
@JsonInclude(JsonInclude.Include.NON_NULL)
data class Cat(
val id: Int? = null,
val name: String,
val breed: String,
)
Также мы будем десериализовывать наш респонс в объекты, чтобы делать проверки не через синтаксис JsonPath, а сравнивать поля объектов. Для этого можно написать функцию‑расширение для ValidatableResponse, а заодно и для Response:
Hidden text
//Функция-расширение для преобразования тела ответа в класс
inline fun <reified T> ValidatableResponse.extractAs(): T {
return this.extract().body().`as`(T::class.java)
}
//Функция-расширение для преобразования тела ответа в класс
inline fun <reified T> Response.extractAs(): T {
return this.then().extract().body().`as`(T::class.java)
}
Ожидаем, что в респонсе помимо указанных полей вернется еще id котика. Объект для десериализации выглядит так:
Hidden text
data class CatResponse(
val id: Int,
val name: String,
val breed: String,
)
Мы часто будем использовать одни и те же запросы. Например, добавление котиков будет встречаться в тестах регулярно, поэтому имеет смысл выносить запросы в функции‑шаги. Пример:
Hidden text
@Step("Добавление котика")
fun addCat(cat: Cat): Response {
return Given {
spec(requestSpec)
} When {
body(cat)
post("/cat")
} Then {
spec(responseSpec)
} Extract {
response()
}
}
Теперь для создания котика достаточно создать экземпляр класса Cat, вызвать функцию и передать нужного кота в нее — addCat(cat).
Напишем простой тест на создание котика:
Hidden text
@Epic("Котик")
@Feature("Добавление котика")
class AddCatTest : BaseTest() {
@Test
@Severity(SeverityLevel.BLOCKER)
@DisplayName("Проверка респонса после добавления котика в сервис")
fun addSimpleCat() {
val cat = Cat(name = "vasiliy", breed = "III — BEN")
When {
addCat(cat)
} Then {
statusCode(201)
val catResponse = extractAs<CatResponse>()
assertThat(catResponse.name).isEqualTo(cat.name)
assertThat(catResponse.breed).isEqualTo(cat.breed)
}
}
@Test
@Severity(SeverityLevel.BLOCKER)
@DisplayName("Проверка респонса после добавления котика в сервис V2")
fun addSimpleCatV2() {
val cat = Cat(name = "vasiliy", breed = "III — BEN")
val catResponse = addCat(cat).extractAs<CatResponse>()
assertAll(
{ assertThat(catResponse.name).isEqualTo(cat.name) },
{ assertThat(catResponse.breed).isEqualTo(cat.breed) }
)
}
}
Как видим, тут можно убрать блок Given, а во втором примере полностью отойти от структуры Given‑When‑Then, если вам это нужно.
Теперь добавим тест на получение котика:
Hidden text
@Epic("Котик")
@Feature("Получение котика")
class GetCatTest : BaseTest() {
@Test
@Severity(SeverityLevel.NORMAL)
@DisplayName("Получение котика")
fun getSimpleCat() {
val cat = Cat(name = "plotva", breed = "II — RAG")
val catId = addCat(cat).getId()
When {
getCat(catId)
} Then {
statusCode(200)
val catResponse = extractAs<CatResponse>()
assertThat(catResponse.name).isEqualTo(cat.name)
assertThat(catResponse.breed).isEqualTo(cat.breed)
}
}
}
Как видим, после добавления котика мы десериализуем респонс в объект. В этом примере нам достаточно только id котика. Можно написать еще одну функцию‑расширение для удобства:
Hidden text
//Функция-расширение для получения id из тела ответа
fun Response.getId(path: String = "id"): Int {
return this.jsonPath().getInt(path)
}
Таким образом можно переписать строчку
val catId = addCat(cat).extractAs<CatResponse>().id
В
val catId = addCat(cat).getId()
Удаление и обновление информации о котике в статье приводить не буду — они есть в тестовом проекте.
Allure-отчёты
Для тех, кто любит смотреть отчеты о прохождении тестов, можно добавить отчёт, который генерирует Allure.
Как вы могли заметить, в тестовом примере каждый класс и функции покрываются аннотациями аллюра — они нужны для удобного построения итогового отчёта.
Ниже приведу пример того, как выглядит главная страница отчёта в allure.
Чтобы сгенерировать отчёт Allure, перед запуском тестов нужно выполнить команду./gradlew downloadAllure в терминале или воспользоваться боковым меню gradle в идее.
После прохождения тестов осталось выполнить команду./gradlew allureServe — в результате поднимется сервер с отчётом.
Личные впечатления от инструмента
Мне было очень комфортно писать автотесты на Kotlin, это позволило довольно просто сериализовать/десериализовать объекты, использовать функции‑расширения.
Итоги
Благодаря сочетанию REST‑assured + Kotlin мы смогли в короткие сроки покрыть наши сервисы автотестами. Тесты очень легко писать, и они имеют хорошо читаемый вид. А после прохождения тестов можно получить Allure‑отчёт.
Поэтому рекомендую присмотреться к данному инструменту — скачать тестовый проект и покрыть его тестами для ознакомления;)