Продолжаем автоматизировать функциональные тесты на Kotlin и знакомиться с возможностями фреймворка Kotest
Расскажу про расширения Kotest
:
- Что это такое
- Как расширения помогают писать тесты
- Реализацию запуска расширений в
Kotest
- Некоторые встроенные расширения
- Про расширение для Spring
- Углублюсь в интеграцию Kotest и Spring Boot Test
- Сравню с Junit5
- И на закуску добавлю отчеты Allure
⚠️Будет много кода, внутренностей и примеров.
Все части руководства:
- Kotlin. Автоматизация тестирования (часть 1). Kotest: Начало
- Kotlin. Автоматизация тестирования (Часть 2). Kotest. Deep Diving
О себе
Максим Кочетков, QA Лид Автоматизации на одном из масштабных проектов Мир Plat.Form (НСПК).
Проект транспортной процессинговой платформы зародился 3 года назад и вырос до четырех команд, где трудятся в общей сложности более 10 разработчиков в тестировании (SDET), а еще аналитики, разработчики и технологи.
Наша задача — автоматизировать функциональные тесты на уровне отдельных сервисов, интеграций между ними и E2E до попадания функционала в релиз — всего порядка 40 микро-сервисов.
От 1 до 5 микро-релизов в неделю.
Взаимодействие между сервисами — Kafka
, внешний API — REST, а также 3 фронтовых Web приложения.
Разработка самой системы и тестов ведется на языке Kotlin, а движок для тестов был выбран Kotest.
В данной статье и в остальных публикациях серии я максимально подробно рассказываю о тестовом Движке и вспомогательных технологиях в формате Руководства/Tutorial.
Парадигма расширений
Что такое расширение для фреймворка тестирования?
Это класс, который реализует определенный интерфейс-маркер для фреймворка тестирования или его производные интерфейсы.
Интерфейс расширения предоставляет методы по перехвату событий жизненного цикла или даже для изменения этого цикла.
Например, интерфейс расширения, которое может отключить тест, то есть изменить его жизненный цикл.
interface EnabledExtension : Extension {
suspend fun isEnabled(descriptor: Descriptor): Enabled
}
Расширение подключается к проекту / классу / тесту с помощью аннотаций или программно: добавлением в реестр расширений.
Например, расширения Junit5 обычно подключаются на класс с помощью аннотации @ExtendWith
:
@ExtendWith(SpringExtension::class)
internal class Junit5Test
Также этой аннотацией можно пометить другие аннотации:
@ExtendWith(SpringExtension.class)
public @interface SpringBootTest {
}
Система расширений — это стандартный функционал для современного тестового фреймворка.
- В Junit4 — это интерфейсы
TestRule
иMethodRule
- В Junit5 — это
Extension
- В Kotest — тоже интерфейс
Extension
Я бы выделил два вида расширений в Kotest
: Listener
и Interceptor
Listener
- На вход принимает неизменяемый объект, например
TestCase
илиTestResult
, возможно, еще какой-то контекст; - Что-то делает и результат транслирует в сторонней сущности, например, репорте;
- Ничего не возвращает, либо отдает какой-то неизменяемый результат для журналирования.
Например, недавно появившийся в Kotest
интерфейс InstantiationErrorListener
позволяет перехватить ошибку при создании сущности класса теста. Он решает проблему, когда в результате неверного контекста или ошибок в инициализации класс с тестами просто не удалось создать. Тогда в отчете может вообще отсутствовать этот несозданный тест, отчет будет успешным, а сборка проваленной.
interface InstantiationErrorListener : Extension {
suspend fun instantiationError(kclass: KClass<*>, t: Throwable)
}
Все слушатели могут выполняться асинхронно, так как не влияют на жизненный цикл теста и друг друга, а результат их выполнений собирается в коллекцию.
Первое перехватываемое событие перед началом выполнения теста реализуется в слушателях
BeforeInvocationListener
:
Interceptor
- На вход принимает неизменяемый объект или функцию;
- Что-то делает с объектом, запускает функцию, обрабатывает результат функции, а может и не запускает функцию;
- На выходе возвращает новую сущность или результат функции, возможно измененный;
- Выходные данные влияют на дальнейшее выполнение жизненного цикла теста;
- Результат ((внутри фреймворка) передается следующему перехватчику или расширению как в паттерне
Chain of Responsibility
.
Название Interceptor
абстрактное — не факт, что оно фигурирует в имени интерфейса, который подходит под описание
В качестве примера приведу фундаментальное расширение: ConstructorExtension
.
Оно перехватывает момент создания объекта класса теста, на выходе ожидает внутренний объект фреймворка — Spec
.
Тут можно также обработать ошибки создания, как в InstantiationErrorListener
, но также придется взять на себя ответственность за подготовку всего тестового класса для дальнейших действий в цепочке.
Сразу отвечаю на вопрос:Что будет если несколько таких расширений?
Будет использован результат первого добавленного в реестр, если оно вернет неnull
. Либо вызывается логика созданияSpec
по умолчанию.
На самом деле внутри Kotest
большая часть функционала также запускается по модели перехватчиков.
Специальным внутренним перехватчикам делегируется поиск и запуск пользовательских расширений:
В ходе этого этапа создается функция высшего порядка с помощью свертывания fold, с порядком, равным количеству перехватчиков. То есть каждый перехватчик превращается в функцию, которая вызывается внутри другого перехватчика, и результат последнего выполнения рекурсивно передается в вышестоящую функцию.
1
Запускается первый перехватчик — в ядре Kotest5.4.1
этоTestPathContextInterceptor
, он выполняет подготовку контекстаcoroutine
и вызывает следующий перехватчик.2
Где-то в середине списка выполняетсяTestCaseExtensionInterceptor
, который внутри ищет пользовательские перехватчики и выполняет уже их.3
В случае этой статьи последовательно выполняются два пользовательских внешних перехватчика:KotestAllureListener
иSpringTestExtension
4
Последним выполняетсяCoroutineDebugProbeInterceptor
и передает свой результат обратно в вызвавший его перехватчик и обратно до верхнего вызова
Как расширения помогают писать тесты?
Тот же Spring Test
можно нормально использовать совместно с тестовым Фреймворком только через расширение, так как необходимо контролировать создание сущностей тестов и контекст к ним.
Расширения для всех основных фреймворков тестирования работают примерно одинаково, поэтому на единой кодовой базе можно очень быстро создать набор адаптеров-расширений для всех фреймворков, а все вызовы делегировать единой внутренней логике.
Очень популярны расширения для создания тестовых дублёров (test doubles).
Например, с помощью расширения MockitoExtension
для Junit5
можно легко создать заглушки всех репозиториев и не поднимать реальную базу, просто добавив аннотацию @Mock
на поле.
Опуская момент с настройкой ответов ...
@ExtendWith(MockitoExtension::class)
internal class Junit5Test {
@Mock UserRepository userRepository
}
- Расширение берет на себя всю работу по созданию ресурсов для тестирования;
- Контролирует жизненный цикл ресурса и привязывает его к циклу тестов, чтобы сохранить изоляцию между сценариями;
- Освобождает ресурсы;
- Обрабатывает ошибки;
- И много чего еще, в зависимости от конкретного расширения.
Расширения позволяют сократить время и строки кода при создании тестов.
А также ошибки в тестах, так как расширения сами оттестированы, а ваш код обвязки тестов скорее всего нет.
Немного встроенных расширений Kotest
Вся документация по расширениям есть в разделе Extensions.
Но мы здесь собрались, чтобы попробовать самое интересное, а не парсить документацию.
Однако документация у них супер классная! Есть вообще все!
- Версии под каждый релиз;
- Удобная навигация;
- Приятный для чтения дизайн;
- Подробный
Changelog
;- Ссылки на статьи /
StackOverflow
/GitHub
/ стабильные и snapshot версии.
Все это заслуга разработчиковKotest
и ФреймворкаDocusaurus 2.0
— мой лайк Docusaurus 2.0
В Kotest
есть набор встроенных расширений. Все они в артефакте io.kotest:kotest-extensions-jvm
,
который транзитивно приходит вместе с основным io.kotest:kotest-runner-junit5
.
Находятся в пакете io.kotest.extensions
— просто имейте это ввиду, там их много, а я расскажу про несколько.
Возьмем расширение SystemEnvironmentTestListener
Все просто — в конструкторе SystemEnvironmentTestListener
мы задаем набор переменных окружения, которые подменяем на время работы теста.
Внутри теста эти переменные будут иметь значения, указанные пользователем. После теста переменные возвращают свои значения.
Расширение не потокобезопасно, поэтому нужно помечать тест @DoNotParallelize
— даже если не пускаете тесты параллельно, нужно пометить!
Либо применять расширение сразу ко всему проекту.
@DoNotParallelize
internal class KotestSystemEnvironmentTest : StringSpec() {
/* 1 */
override fun extensions() =
listOf(SystemEnvironmentTestListener(/* 2 */mapOf("USERNAME" to "TEST", "OS" to "Astra Linux"), /* 3 */OverrideMode.SetOrOverride))
init {
/* 4 */
println("Before use listener: " + System.getenv("USERNAME"))
println("Before use listener: " + System.getenv("OS"))
"Scenario: environment variables should be mocked" {
/* 5 */
System.getenv("USERNAME") shouldBe "TEST"
System.getenv("OS") shouldBe "Astra Linux"
}
}
}
1
Расширение применяется на уровне спецификации.2
Словарь с переменными и новыми значениями.3
Режим переопределения. Данный режим в любом случае перезаписывает переменную. АOverrideMode.SetOrError
, например, только добавляет, но падает с ошибкой если пытаемся переписать существующую переменную.4
В этом месте выводятся все еще реальные значения.5
А в тесте уже переопределенные.
Кстати классOverrideMode
— этоsealed class
И вKotest
очень много примеров использования вместоenum
-овsealed class
-ов
Посмотрим очень интересное расширение SpecSystemExitListener
Иногда в тестируемом приложении может вызываться System.exit()
, чтобы завершить процесс, например, выполнить graceful shutdown
или завершить проложение из-за недостатка ОП с кодом 137
.
Эту ситуацию можно перехватить и проверить, что:
System.exit()
действительно был вызван- Код выхода действительно соответствует ситуации
@DoNotParallelize /* 1 */
internal class KotestSystemExitTest : StringSpec() {
/* 2 */
override fun extensions() = listOf(SpecSystemExitListener)
init {
"Scenario: testing application try use System.exit" {
/* 3 */ shouldThrow<SystemExitException> {
runApplicationWithOutOfMemoryExitCode() /* 4 */
}.exitCode shouldBe 137 /* 5 */
}
}
}
private fun runApplicationWithOutOfMemoryExitCode(): Nothing = exitProcess(137)
1
Здесь тоже нет гарантий на работу в параллельном режиме, поэтому@DoNotParallelize
.2
Добавляем расширение.3
Выполняем код и ожидаем специальное исключение от Kotest —SystemExitException
.4
Внутри выполняется приложение, которое вызываетSysytem.exit()
==exitProcess()
.5
Ожидаемый код окончания137
.
Последнее на сегодня и, наверное, самое популярное расширение ConstantNowTestListener
Часто в приложении используется текущая дата, обычно в UTC. И часто очень сложно предсказать какая получится текущая дата, чтобы ее проверить на выходе.
Также часто необходимо подменить текущую дату на определенную, чтобы проверить логику и не очень удобно делать это через системное время.
Рассматриваемое расширение решает эту проблему и позволяет переопределить метод now()
у любой реализации Temporal
, например LocalDate
/ ZonedDateTime
.
@DoNotParallelize
internal class KotestNowTest : StringSpec() {
override fun extensions() = listOf(
/* 1 */ ConstantNowTestListener<LocalDate>(LocalDate.EPOCH),
/* 2 */ ConstantNowTestListener<LocalTime>(LocalTime.NOON)
)
init {
"Scenario: date and time will be mocked, but dateTime not" {
/* 3 */
LocalDate.now() shouldBe LocalDate.EPOCH
LocalTime.now() shouldBe LocalTime.NOON
/* 4 */
val localDateTimeNow = LocalDateTime.now()
delay(100)
LocalDateTime.now() shouldBeAfter localDateTimeNow
}
}
}
1
Заменяемnow()
для классаLocalDate
— будет возвращатьLocalDate.EPOCH
(01.01.1970
).2
Заменяемnow()
для классаLocalTime
— будет возвращатьLocalTime.NOON
(12:00
).3
Проверяем, что теперьnow()
действительно возвращает статичное значение.4
Но классLocalDateTime
все еще работает по-старому.
Интеграция со Spring
Вот мы и добрались до кульминации рассказа. На самом деле все это затевалось, чтобы показать как Kotest
работает со Spring Test
и Spring Boot Test
в частности.
Что использовать Kotest
для написания unit
/ интеграционных
/ e2e
/ функциональных
тестов для вашего Spring Boot
приложения не сложнее, чем Junit
.
А в части читаемости и поддерживаемости функциональных тестов даже предпочтительнее, по мнению автора.
Добавляем необходимые зависимости в наш Gradle
проект
Начинаем с плагинов:
plugins {
id 'org.jetbrains.kotlin.jvm' version '1.7.10'
id 'org.jetbrains.kotlin.plugin.spring' version '1.7.10'
id 'org.springframework.boot' version '2.7.2'
}
Плагин для поддержки kotlin
, плагин для корректной работы Spring AOP с неизменяемыми по умолчанию классами котлина kotlin.plugin.spring
и spring boot
плагин.
Далее активируем удобный spring.dependency-management
из состава spring boot
плагина, чтобы использовать заранее подготовленный BOM
с версиями большинства библиотек
и не заботится о совместимости.
После подключения нет необходимости указывать версии библиотек в блоке dependencies
.
Версии можно проверить на сайте docs.spring.io
apply plugin: 'io.spring.dependency-management'
Подключаем зависимости:
dependencies {
// Для примера приложения
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin'
// Sprint Boot Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
// HTTP клиент для e2e тестирования
testImplementation 'io.rest-assured:rest-assured'
// JUnit5
testImplementation('org.junit.jupiter:junit-jupiter')
testRuntimeOnly('org.junit.jupiter:junit-jupiter-engine')
testImplementation('org.junit.jupiter:junit-jupiter-params')
// Kotest
testImplementation platform('io.kotest:kotest-bom:5.4.1')
testImplementation 'io.kotest:kotest-runner-junit5'
// Spring + Kotest
testImplementation('io.kotest.extensions:kotest-extensions-spring:1.1.2') { exclude group: 'io.kotest' }
}
Обращаю внимание на несколько вещей:
- Без
jackson-module-kotlin
не будет работать десериализация вdata
классыKotlin
. Часто забывается при создании нового проекта.- Движок для тестирования
junit-jupiter-engine
не нужен на этапе компиляции, поэтомуtestRuntimeOnly
.testImplementation platform('io.kotest:kotest-bom:5.4.1')
kotest нет среди библиотекSpring Boot
, поэтому подключаемBOM
.kotest-extensions-spring
это целевое расширение для интеграцииKotest
иSpring
, находится в отдельном проекте и ведет свое версионирование.
Включаем junit5
— он в любом случае нужен как для запуска собственных тестов, так и для запуска тестов kotest
:
test {
useJUnitPlatform()
systemProperty "kotest.framework.dump.config", "true" // Хотим напечатать конфигурацию запуска для Kotest
}
Готовим конфигурацию проекта
Добавляем объект SpringExtension
для всего проекта.
object KotestProjectConfig : AbstractProjectConfig() {
override fun extensions() = listOf(SpringExtension)
}
То есть контекст будет подниматься и переиспользоваться для любой Kotest
спецификации.
Если хочется включить Spring
только для некоторых классов, то добавляем расширения на уровне спецификации:
class MyTestSpec : FunSpec() {
override fun extensions() = listOf(SpringExtension)
}
Так или иначе, после включения расширения создается спринговый TestContextManager
, которому делегируется инициализация контекста и класса спецификации.
По умолчанию подключается возможность внедрять все аргументы из конструктора теста, даже без дополнительных аннотаций типа @Autowired
.
Эта возможность доступна через Kotest
перехватчик ConstructorExtension
— Spring
расширение реализует его и берет на себя создание объекта класса спецификации.
internal class MyTestSpec(propertyResolverInConstructor: PropertyResolver) : FunSpec()
Тут аргумент propertyResolverInConstructor
внедряется без дополнительных телодвижений, как полагается, через конструктор, а не через setter
!
Что будем тестировать?
Тестируем Spring
контроллер с одним методом POST
, который проверяет текст в запросе.
Входящий запрос:
data class RequestDto(
val text: String?
)
Ответ:
data class ResponseDto(
val code: Int,
val message: String
)
И сам контроллер:
@RestController
class ValidationController {
@PostMapping("/validation", consumes = [APPLICATION_JSON_VALUE], produces = [APPLICATION_JSON_VALUE])
fun sampleValidateEndpoint(@RequestBody request: RequestDto): ResponseDto =
when {
request.text == null -> ResponseDto(1, "Null text") /* 1 */
request.text.isBlank() -> ResponseDto(2, "Blank text") /* 2 */
else -> ResponseDto(0, "Ok") /* 3 */
}
}
1
Если в запросе текст отсутствует, то отвечаем{ "code": 1, "message": "Null text" }
2
Если в запросе текст пустой или из пробелов, то отвечаем{ "code": 2, "message": "Blank text" }
3
Если в текст есть, то{ "code": 0, "message": "Ok" }
Создаем E2E сценарий
В тесте мы поднимаем полноценный сервер на случайном порту, подключаемся к нему HTTP
клиентом, отправляем реальные запросы и проверяем реальные ответы, — поэтому End 2 End.
Будем писать, как полагается, Data Driven Test на 3 набора входных данных в BDD стиле.
Теоретически такой тест может быть использован в BDD подходе к разработке. И написание этого теста не требует наличия рабочей функциональности — только четкие требования.
Для сценария Kotest
предоставляет множество стилей — я всегда выбираю FreeSpec
.
Во всех предыдущих частях использовал либо StringSpec
для плоских тестов без вложенности, либо FreeSpec
для тестов с вложенностью и логическими блоками.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
/* 1 */
internal class ValidationControllerKotestTest(
@Value("\${local.server.port}") private val localServerPort: String /* 2 */
) : FreeSpec()
1
ПодключаемSpringBootTest
, то есть говорим тесту, что нужно поднимать весь контекст и выполнять все настроенные стартеры, но на случайном доступном порту.2
Для теста нужно знать номер случайного порта, на котором поднимается приложение, получаем его в конструкторе.
Я хочу показать, что в конструкторе можно внедрить любой Bean
из контекста без использования аннотаций:
internal class ValidationControllerKotestTest(
@Value("\${local.server.port}") private val localServerPort: String,
propertyResolverInConstructor: PropertyResolver /* 1 */
) : FreeSpec()
1
Допустим я предпочитаю получатьproperty
через методыPropertyResolver
— внедря этотBean
и в тесте чуть позже обязательно его проверю!