Хочу поделиться опытом создания системы автоматизации функционального тестирования на языке на Kotlin.
Основой для создания / конфигурирования / запуска / контроля выполнения тестов — будет набирающий популярность молодой фреймворк Kotest (в прошлом Kotlin Test).
Проанализировав все популярные варианты для Kotlin — выяснилось, что есть всего два "нативных":
Либо бесконечное кол-во из Java мира: Junit4/5, TestNG, Cucumber JVM или другие BDD фреймворки.
Выбора пал на Kotest с бОльшим кол-вом "лайков" на GitHub, чем у Spek.
Руководств по автоматизации тестирования на Kotlin, особенно в сочетании с Kotest — немного.
Думаю, что написать цикл статей про Kotest, а также про организацию проекта авто-тестов, сборку, запуск и сопутствующие технологии — хорошая идея.
Все части руководства:
О себе
Я являюсь QA Лидом на одном из проектов Мир Plat.Form (НСПК). Проект зародился около года назад и уже вырос до четырех команд, где трудится в общей сложности около 10 разработчиков в тестировании (SDET), без учета остальных участников в лице аналитиков, разработчиков и технологов.
Наша задача — автоматизировать функциональные тесты на уровне отдельных сервисов, интеграций между ними и E2E до попадания функционала в master
— всего порядка 30 микро-сервисов. Взаимодействие между сервисами — Kafka, внешний API — REST, а также 2 фронтовых Web приложения.
Разработка самой системы и тестов ведется на языке Kotlin, а движок для тестов был выбран Kotest.
В данной статье и в остальных публикациях серии я максимально подробно рассказываю о тестовом Движке и вспомогательных технологиях в формате Руководства/Tutorial.
Минусы
Сходу определимся по глобальным минусам и учтем, что проект быстро развивается. Те проблемы, что есть в актуальной на время написания статьи версии 4.2.5
, уже могут быть исправлены в свежих релизах.
Первым, очевидным, минусом является очень быстрое изменение минорных и мажорных версий.
Еще в марте 2020 фреймворк назывался KotlinTest
, с версии 4.0.0
поменял название всех пакетов, потерял совместимость с плагином Idea и был переименован в Kotest
, а также стал мульти-платформенным.
После обновления с версии 4.0.7
до 4.1
перестали работать расширения, написанные ранее, также были изменены названия базовых модулей и много чего еще, то есть принцип семантического версионирования нарушился между минорными версиями 4.0
и 4.1
.
Это немыслимо для Java мира — это что-то из JS.
У инструмента пока небольшое комьюнити.
В пользу широкого функционала иногда приносится в жертву продуманный дизайн.
Не все предоставляемые стили написания тестов адекватно транслируются в отчет. На текущий момент не корректно отображаются репорты для data-driven и property-based тестов.
Шаблоны и фабрики тестов работают не как ожидается.
Встроенное расширение allure совсем никуда не годится (к примеру, оно пытается обрабатывать аннотации, которыми в принципе невозможно аннотировать тестовые DSL методы).
Однако ни одного критического или блокирующего дефекта я не встретил.
Почему Kotest?
Многие разработчики на Kotlin не уделяют много внимания на выбор тестового фреймворка и продолжают использовать Junit4
или Junit5
.
Для них тесты — это, как правило, класс, помеченный аннотацией @SpringBootTest
, набор методов с аннотацией @Test
, возможно методы before
и beforeClass
с соответствующими аннотациями.
Для полноценных функциональных e2e тестов этого недостаточно.
Нужен инструмент предоставляющий возможность создавать понятные тесты на основе требований, удобную организацию проверок, тестовых данных и отчетности.
Так вот Kotest
позволяет:
- писать крайне понятные тесты в BDD стиле с помощью Kotlin DSL и функций расширения,
- легко создавать data driven тесты в функциональном стиле
- с помощью DSL определять обратные вызовы перед тестом и тестовым классом и после них.
- определить действия на уровне всего прогона (фича, которой нет явно в junit)
- использовать встроенные интуитивные проверки
- простое конфигурирование тестовых классов и тестового проекта из кода
и много чего еще, см. полную документацию и сам проект в GitHub
Начинаем создавать тест
Какой стиль выбрать?
Kotest дает возможность выбора между несколькими вариантами DSL для формирования структуры тестов.
Самый базовый и простой String Spec — идеально подойдет для написания unit-тестов с одним уровнем вложенности.
Для полноценных функциональных авто-тестов нужно что-то посложнее: по структуре схожее с Gherkin
, но менее формализованное, особенно по ключевым словам.
После долгих экспериментов я остановился на стиле FreeSpec.
Используя Kotest
я рекомендую продолжать писать тесты в BDD стиле, как в языке Gherkin
(Cucumber).
FreeStyle
не накладывает ограничений на именование тестов, ключевые слова и вложенность, поэтому эти вещи нужно контролировать на уровне code-style, best practice, обучения и Merge-Request`ов.
Иерархия тестовых сущностей
В нашем подходе будет 5 базовых тестовых сущностей (или уровней) в рамках Kotest.
Важно определить это сейчас, потому что в дальнейшем оперировать я буду этими понятиями:
Тестовый прогон — Execution (или Project)
Запуск определенного набора тестов
Спецификация — Spec
Тестовый класс. В cucumber — это Feature
Контейнер теста — Top Level Test
Сценарий верхнего уровня в Спецификации. В cucumber — это Scenario
Шаг теста — Nested Test
Шаг в сценарии, который начинается с ключевого слова.
Ключевое слово обозначает этап: подготовка (Дано), воздействие (Когда), проверка ожидаемой реакции (Тогда).
В cucumber — это Step
Вложенные Шаги — Nested Step
Это любая дополнительная информация о произведенных действиях, например аннотация@Step
вAllure
.
В рамках описания сценария эти шаги не несут нагрузки — они нужны для отчета, для отладки, для выяснения причин ошибки.
Kotest позволяет создавать любую вложенность, но в данном подходе ограничиваемся4 - Шаг теста - Nested Test
— дальнейшая вложенность воспринимается как шаги для отчета.
С точки зрения Форматирования теста и Review интерес представляют уровни 1 — 4.
В Gherkin
есть сущность Структура Сценария (Scenario Template) — это реализация Data Driven.
В Kotest уровень 3. Контейнер теста - Top Level Test
, также может являться Структурой Сценария — то есть помножиться на наборы тестовых данных.
Превращаем требования в сценарий
Допустим мы тестируем REST API сервиса и имеются требования.
Не известно, как будем отправлять запросы, как получать, десериализовать и проверять, но сейчас это не нужно.
Пишем скелет сценария:
open class KotestFirstAutomatedTesting : FreeSpec() {
private companion object {
private val log = LoggerFactory.getLogger(KotestFirstAutomatedTesting::class.java)
}
init {
"Scenario. Single case" - {
val expectedCode = 200
"Given server is up" { }
"When request prepared and sent" { }
"Then response received and has $expectedCode code" { }
}
}
}
Очень похоже на Сценарии на Gherkin
Во-первых, стоит обратить внимание, что здесь нет понятия тестовый класс
, а есть спецификация
(FreeSpec
). И это не спроста.
Вспоминаем, что Kotlin DSL — это type-safe builder, а значит при запуске тесты сначала формируют дерево тестов / тестовых контейнеров / pre и after функций / вложенных шагов умноженных на наборы тестовых данных.
Отмечу использование интерполяции строк в имени шага "Then response received and has $expectedCode code"
Принцип работы DSL
Контейнер теста.
Используется минус после названия! Важно его не пропускать!
Тест наследуется от класса FreeSpec
, в свою очередь он реализует FreeSpecRootScope
:
abstract class FreeSpec(body: FreeSpec.() -> Unit = {}) : DslDrivenSpec(), FreeSpecRootScope
В FreeSpecRootScope
для класса String
переопределяется оператор -
:
infix operator fun String.minus(test: suspend FreeScope.() -> Unit) { }
Соответсвенно запись "Scenario. Single case" - { }
вызывает функцию расширения String.minus
, передает внутрь функциональный тип с контекстом FreeScope
и добавляет в дерево тестов контейнер.
Напомню, что в Kotlin, если лямбда-аргумент является последним в функции, то для него круглые скобки можно опускать.
Шаги теста
В том же интерфейсе FreeSpecRootScope
для класса String
переопределяется оператор вызова invoke
infix operator fun String.invoke(test: suspend TestContext.() -> Unit) { }
Запись "string" { }
является вызовом функции расширения с аргументом функционального типа с контекстом TestContext
.
Реализация теста и проверок
Вот как будет выглядеть тест с реализацией:
init {
"Scenario. Single case" - {
//region Variables
val expectedCode = 200
val testEnvironment = Server()
val tester = Client()
//endregion
"Given server is up" {
testEnvironment.start()
}
"When request prepared and sent" {
val request = Request()
tester.send(request)
}
lateinit var response: Response
"Then response received" {
response = tester.receive()
}
"And has $expectedCode code" {
response.code shouldBe expectedCode
}
}
}
Поясню некоторые момент и мотивацию
- Константы для конкретного сценария определены прямо в блоке и окружены конструкцией Idea для сворачивания
- Для обмена информацией между шагами приходится использовать переменные типа
lateinit var response: Response
, определенные непосредственно перед блоком, в котором они инициализируются
Kotest Assertions и Matchers
В Kotest уже есть довольно обширная библиотека Assertions and Matchers
.
Зависимость testImplementation "io.kotest:kotest-assertions-core:$kotestVersion"
предоставляет набор Matcher-ов, SoftAssertion и Assertion для проверки Исключений.
Есть возможность расширять ее и добавлять свои комплексные Matcher-ы, а также использовать уже готовые расширения.
Немного расширим последний шаг и добавим в него побольше проверок:
"And has $expectedCode code" {
assertSoftly {
response.asClue {
it.code shouldBe expectedCode
it.body.shouldNotBeBlank()
}
}
val assertion = assertThrows<AssertionError> {
assertSoftly {
response.asClue {
it.code shouldBe expectedCode + 10
it.body.shouldBeBlank()
}
}
}
assertion.message shouldContain "The following 2 assertions failed"
log.error("Expected assertion", assertion)
}
assertSoftly { code }
Soft Assert из библиотеки assertionsKotest
— выполнит блок кода полностью и сформирует сообщение со всеми ошибками.response.asClue { }
MUST HAVE для проверок в тестах. Scope функция kotlinasClue
— при возникновении ошибки добавит в сообщение строковое представление всего объектаresponse
Matchers
Matchers отKotest
— отличная расширяемая библиотека проверок, полностью покрывает базовые потребности.
shouldBe
— infix версия проверки на равенство.
shouldBeBlank
— не infix (т.к. нет аргумента) проверка на пустоту строки.assertThrows<AssertionError>
Статическая функция расширенной для Котлина библиотеки Junit5
inline fun <reified T : Throwable> assertThrows(noinline executable: () -> Unit)
— выполняет блок, проверяет тип ожидаемого Исключения и возвращает его для дальнейших проверок
Добавляем pre / after обратные вызовы
Существует большое количество вариантов обратных вызовов на события тестов.
На текущий момент (4.3.5
) все встроенные события представлены в файле io.kotest.core.spec.CallbackAliasesKt
в артефакте kotest-framework-api-jvm
в виде typealias
:
typealias BeforeTest = suspend (TestCase) -> Unit
typealias AfterTest = suspend (Tuple2<TestCase, TestResult>) -> Unit
typealias BeforeEach = suspend (TestCase) -> Unit
typealias AfterEach = suspend (Tuple2<TestCase, TestResult>) -> Unit
typealias BeforeContainer = suspend (TestCase) -> Unit
typealias AfterContainer = suspend (Tuple2<TestCase, TestResult>) -> Unit
typealias BeforeAny = suspend (TestCase) -> Unit
typealias AfterAny = suspend (Tuple2<TestCase, TestResult>) -> Unit
typealias BeforeSpec = suspend (Spec) -> Unit
typealias AfterSpec = suspend (Spec) -> Unit
typealias AfterProject = () -> Unit
typealias PrepareSpec = suspend (KClass<out Spec>) -> Unit
typealias FinalizeSpec = suspend (Tuple2<KClass<out Spec>, Map<TestCase, TestResult>>) -> Unit
typealias TestCaseExtensionFn = suspend (Tuple2<TestCase, suspend (TestCase) -> TestResult>) -> TestResult
typealias AroundTestFn = suspend (Tuple2<TestCase, suspend (TestCase) -> TestResult>) -> TestResult
typealias AroundSpecFn = suspend (Tuple2<KClass<out Spec>, suspend () -> Unit>) -> Unit
Существует 2 вида интерфейсов, которые реализуют обратные вызовы:
- Listener
- Extension
Первый позволяет создать обратные вызов на событие теста и предоставляет immutable описание теста или результат (для after
).
Второй позволяет вмешиваться в выполнение теста, что-то изменять, получать информацию о внутреннем состоянии движка, то есть небезопасен и подходит для разработки расширений, влияющих на функционал Фреймворка.
Ограничимся событиями Listener
— поверьте, этого более чем достаточно.
Этот интерфейс, в свою очередь, делится еще на 2 основных:
TestListener
ProjectListener
Добавить свой callback можно множеством способов:
- переопределить метод в спецификации или проекте
- реализовать свой
Listener
и добавить его в список Слушателей явно - реализовать свой
Listener
и аннотировать его@AutoScan
- вызвать метод экземпляра спецификации или проекта — наиболее удобный способ, который будем рассматривать
Обратные вызовы уровня спецификации
Самый простой способ добавить callback для одного из типов доступных событий — это вызвать одноименный метод из FreeSpec
, каждый из которых принимает функциональный тип соответствующего события:
init {
///// ALL IN INVOCATION ORDER /////
//// BEFORE ////
beforeSpec { spec ->
log.info("[BEFORE][1] beforeSpec '$spec'")
}
beforeContainer { onlyContainerTestType ->
log.info("[BEFORE][2] beforeContainer onlyContainerTestType '$onlyContainerTestType'")
}
beforeEach { onlyTestCaseType ->
log.info("[BEFORE][3] beforeEach onlyTestCaseType '$onlyTestCaseType'")
}
beforeAny { containerOrTestCaseType ->
log.info("[BEFORE][4] beforeAny containerOrTestCaseType '$containerOrTestCaseType'")
}
beforeTest { anyTestCaseType ->
log.info("[BEFORE][5] beforeTest anyTestCaseType '$anyTestCaseType'")
}
//// AFTER ////
afterTest { anyTestCaseTypeWithResult ->
log.info("[AFTER][1] afterTest anyTestCaseTypeWithResult '$anyTestCaseTypeWithResult'")
}
afterAny { containerOrTestCaseTypeAndResult ->
log.info("[AFTER][2] afterAny containerOrTestCaseTypeAndResult '$containerOrTestCaseTypeAndResult'")
}
afterEach { onlyTestCaseTypeAndResult ->
log.info("[AFTER][3] afterEach onlyTestCaseTypeAndResult '$onlyTestCaseTypeAndResult'")
}
afterContainer { onlyContainerTestTypeAndResult ->
log.info("[AFTER][4] afterContainer onlyContainerTestTypeAndResult '$onlyContainerTestTypeAndResult'")
}
afterSpec { specWithoutResult ->
log.info("[AFTER][5] afterSpec specWithoutResult '$specWithoutResult'")
}
//// AT THE END ////
finalizeSpec {specWithAllResults ->
log.info("[FINALIZE][LAST] finalizeSpec specWithAllResults '$specWithAllResults'")
}
"Scenario" - { }
}
В коде выше все обратные вызовы определены в порядке выполнения до и после теста.
before
beforeSpec
Выполняется сразу после создания экземпляра классFreeSpec
и перед выполнением первого теста, имеет один аргумент —Spec
описание спецификацииbeforeContainer
Выполняется только перед контейнером тестаTestType.Container
, имеет один аргумент —TestCase
описание контейнера тестаbeforeEach
Выполняется только перед шагами (тестами) в контейнере тестаTestType.Test
, имеет один аргумент —TestCase
описание шага теста (вложенный сценарий)beforeAny
Выполняется перед контейнером тестаTestType.Container
и перед шагамиTestType.Test
, имеет один аргумент —TestCase
описание шага или контейнераbeforeTest
Выполняется перед любой сущностьюTestCase
будь то контейнер или шаг, или новыйTestType
которого пока не существует.
Фактически сейчас этоbeforeAny
. Нужен для сохранения совместимости с прошлыми версиями (когда не былоTestType
) и с будущими (когда будут новыеTestType
)
after
afterTest
АналогичноbeforeTest
только после.
Имеет аргумент пару —TestCase
+TestResult
afterAny
АналогичноbeforeAny
только после.
Имеет аргумент пару —TestCase
+TestResult
afterEach
АналогичноbeforeEach
только после.
Имеет аргумент пару —TestCase
+TestResult
afterContainer
АналогичноbeforeContainer
только после.
Имеет аргумент пару —TestCase
+TestResult
afterSpec
АналогичноbeforeSpec
только после.
Имеет аргумент пару —Spec
finalizeSpec
Выполняется сразу после окончания работы всех тестов в Спецификации.
Имеет аргумент пару — класс спецификации KClass<out Spec>
+ отображение всех тестов и результатов Map<TestCase, TestResult>
Обратные вызовы уровня проекта
Kotest предоставляет возможность определить callback на события всего запуска тестов.
Их два:
beforeAll
Выполняется перед первым тестом в запускеafterAll
Выполняется после окончания всех тестов
Определить можно с помощью реализации ProjectListener
и добавления в список слушателей проекта в конфигурации проекта, в AbstractProjectConfig
либо одиночке Project
.
Также можно переопределить одноименные методы в AbstractProjectConfig
— в большинстве случаев предпочтительный способ:
object ProjectConfig : AbstractProjectConfig() {
private val log = LoggerFactory.getLogger(ProjectConfig::class.java)
override fun beforeAll() {
log.info("[BEFORE PROJECT] beforeAll")
}
override fun afterAll() {
log.info("[AFTER PROJECT] afterAll")
}
}
Делаем Data Driven Test
В пакете io.kotest.data
предоставлен набор классов и функций для организации Data Driven Testing
Создадим простейший тест c Data Provider-ом:
init {
"Scenario. Single case" - {
val testEnvironment = Server()
val tester = Client()
"Given server is up. Will execute only one time" {
testEnvironment.start()
}
forAll(
row(1, UUID.randomUUID().toString()),
row(2, UUID.randomUUID().toString())
) { index, uuid ->
"When request prepared and sent [$index]" {
tester.send(Request(uuid))
}
"Then response received [$index]" {
tester.receive().code shouldBe 200
}
}
}
}
Выглядит довольно просто, а главное понятно (наверное)
- Начало, как в обычном тесте — определяем контейнер для тестов.
- Следующий шаг
Given server is up
выполнится также, как в обычном тесте — единожды. - Далее следует функция
forAll
. Она принимает наборыRow
и функциональный блок, в котором продолжаем декларировать шаги теста. - Функция
row
определяет один набор тестовых данных для одной итерации.
В пакете файлеio.kotest.data.rows.kt
определено 22 функции для разного кол-ва данных в одном наборе.
Если этого не хватает, то есть возможность реализовать свою последовательность в подходе Property Based Testing (это выходит за рамки этой статьи) - В итоге имеем:
forAll( row(1, UUID.randomUUID().toString()), row(2, UUID.randomUUID().toString()) ) { index, uuid -> block }
2 итерации со своим набором тестовых данных.
В каждом наборе 2 значения. Функциональный блок, который выполнится 2 раза.
Существует важное ограничение на имена в рамках контейнера — все имена шагов должны быть уникальными.
Поэтому в шагах добавлены уникальные индексы [$index]
.
Можно обойтись без индекса и печатать uuid
в каждом шаге — индекс используется только для упорядоченности в отчете.
Заключение
Во-первых, привожу ссылку на все примеры qa-kotest-articles/kotest-first.
По итогу имеем полноценный фреймворк для запуска тестов.
Широкий контроль жизненного цикла всего запуска, спецификации, каждого сценария и его шагов.
Результаты запуска трансформируются в стандартные junit отчеты, все события публикуются в слушатели junit для корректного отображения в консоли Idea.
Также имеется Idea плагин.
Data Driven без всяких аннотаций или дополнительных классов.
Все это дело похоже на Groovy Spoke, но только для Kotlin.
Ресурсы
Kotlin. Автоматизация тестирования (Часть 2). Kotest. Deep Diving
Официальная документация Kotest
Планы
В планах написание следующих частей, которые покроют тему 'Kotlin. Автоматизация тестирования':
- Kotest. Расширения, конфигурирование проекта, конфигурирование спецификации и конфигурирование тестов, тэги и фабрики тестов, Property Based Testing
- Spring Test. Интеграция с Kotest. Конфигурирование тестового контекста и контроль жизненного цикла бинов.
- Ожидания Awaitility. Retrofit для тестирования API. Работа c БД через Spring Data Jpa.
- Gradle. Масштабируемая и распределенная структура множества проектов авто-тестов.
- Управление окружением. TestContainers, gradle compose plugin, kubernetes java api + helm