Kotlin. Автоматизация тестирования (Часть 3). Расширения Kotest и Spring Test

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Kotest


Продолжаем автоматизировать функциональные тесты на 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 Запускается первый перехватчик — в ядре Kotest 5.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 перехватчик ConstructorExtensionSpring расширение реализует его и берет на себя создание объекта класса спецификации.


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 и в тесте чуть позже обязательно его проверю!

Источник: https://habr.com/ru/company/nspk/blog/685330/


Интересные статьи

Интересные статьи

Всем доброго времени суток. Мы решили описать построение отказоустойчивой инфраструктуры для bitrix в рамках серии статей, и сегодня публикуем первую часть. Вариант, который мы будем описывать, - не и...
В этом посте будет описано практическое применение semantic-release для terraform модуля terraform-yandex-compute (Модуль Terraform, который создает вычислительные ресурсы в облаке Яндекса) c Github a...
Это третья часть серии заметок о реактивном программировании, в которой будет представлено введение в WebFlux - реактивной веб-фреймворк Spring. Читать далее ...
Статья о том, как упорядочить найм1. Информируем о вакансии2. Ведём до найма3. Автоматизируем скучное4. Оформляем и выводим на работу5. Отчитываемся по итогам6. Помогаем с адаптацией...
Вам приходилось сталкиваться с ситуацией, когда сайт или портал Битрикс24 недоступен, потому что на диске неожиданно закончилось место? Да, последний бэкап съел все место на диске в самый неподходящий...