Тестирование Kotlin/JS: фреймворки, корутины и все-все-все

Kotlin – блестящий проект. Изначально задуманный как просто JVM-язык, впоследствии он получил поддержку компиляции под все мейнстримные платформы, среди которых — JavaScript.

Вводная. У меня есть пет-проект — сайт и API-платформа для комьюнити по игре Elite: Dangerous. Бэкенд – на Kotlin/JVM (Ktor+Hibernate), фронтенд – на Kotlin/JS (KVision+Fomantic UI). О пет-проекте я расскажу как-нибудь потом, а о фронте поподобрнее.

  • KVision – фронтэнд-фреймворк для Kotlin, объединяющий в себе идеи из различных десктопных фреймворков (от Swing и JavaFX до WinForms и Flutter) и синтаксические возможности Kotlin, например, DSL-билдеры.

  • Fomantic-UI – форк Semantic-UI, компонентного веб-фреймворка для HTML/JS, который можно сравнить с Bootstrap, только Fomantic будет поинтереснее.

Не так давно я загорелся мыслью связать эти два мира и написать библиотеку для KVision, которая бы, как минимум, облегчила написание KVision-страниц с Fomantic-элементами. И, как подобается open source проекту, я планировал покрыть библиотеку тестами. Вот об этом приключении и будет эта статья.

Код

В первую очередь определимся с задачей. Есть у нас на руках следующий код простой Fomantic-кнопки:

package com.github.kam1sh.kvision.fomantic

import pl.treksoft.kvision.html.*
import pl.treksoft.kvision.panel.SimplePanel


open class FoButton(text: String) : Button(text = text, classes = setOf("ui", "button")) {
    var primary: Boolean = false
        set(value) {
            if (value) addCssClass("primary") else removeCssClass("primary")
            field = value
        }
}

fun SimplePanel.foButton(
    text: String,
    init: (FoButton.() -> Unit)? = null
): FoButton {
    val btn = FoButton(text)
    init?.invoke(btn)
    add(btn)
    return btn
}    

И есть парочка тестов:

package com.github.kam1sh.kvision.fomantic

import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlinx.browser.document
import kotlinx.coroutines.*
import pl.treksoft.kvision.panel.ContainerType
import pl.treksoft.kvision.panel.Root

class FoButtonTest {
    lateinit var kvapp: Root

    @BeforeTest
    fun before() {
        kvapp = Root("kvapp", containerType = ContainerType.NONE, addRow = false)        
    }

    @Test
    fun genericButtonTest() {
        kvapp.foButton("Button")
        assertEqualsHtml("""...""")
    }

    @Test
    fun buttonPrimaryTest() {
        val btn = kvapp.foButton("Button") { primary = true }
        assertEqualsHtml("""...""")
        btn.primary = false
        assertEqualsHtml("""...""")
    }
}

fun assertEqualsHtml(expected: String, message: String? = null) {
    val actual = document.getElementById("kvapp")?.innerHTML
    assertEquals(expected, actual, message)
}

Другими словами: “вшиваем” KVision в div с id=kvapp, создаём кнопку и потом ассертим её HTML-код.

Вопрос. Откуда должен взяться первоначальный div? Можно просто добавить HTML-код через какой-нибудь document.body?.insertAdjacentHTML(...), но что, если нам очень-очень хочется добавить прямо свою страничку вроде такой?

<source lang="html">
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js"></script>
    <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/semantic.min.css">
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/semantic.min.js"></script>
</head>
<body>
    <main>
        <div id="kvapp">

        </div>
    </main>
</body>
</html>
</source>

You’ve lost karma

Давайте сначала обратимся к разделу тестирования документации Kotlin/JS.
For browser projects, it downloads and installs the Karma test runner with other required dependencies; for Node.js projects, the Mocha test framework is used.
Ага. Karma и Mocha. Также говорится, что конфигурируется Karma через js-скрипты в папке karma.config.d.

После поиска по документации Karma по конфигам, приходим методом проб и ошибок к такому конфиг-скрипту:

// karma.config.d/page.js
config.set({
  customContextFile: "../../../../../src/test/resources/test.html"
})

Файл test.html, в моём случае, находится по пути src/test/resources/test.html. Из-за того, что Karma запускается в каталоге build/js/packages/kvision-fomantic-test/node_modules, нам для начала надо подняться на пять каталогов повыше.

Всё готово, верно? Запускаем ./gradlew browserTest, ииии… получаем Disconnected (0 times), because no message in 30000 ms.

Это всё потому, что наша HTML-страница несколько отличается от оригинальной, в которой выполняется особый JS-код. Оригинал можно посмотреть в build/js/node_modules/karma/static/context.html.

Копируем недостающий код в место сразу перед main-блоком:

<!-- The scripts need to be in the body DOM element, as some test running frameworks need the body
     to have already been created so they can insert their magic into it. For example, if loaded
     before body, Angular Scenario test framework fails to find the body and crashes and burns in
     an epic manner. -->
<script src="context.js"></script>
<script type="text/javascript">
    // Configure our Karma and set up bindings
    %CLIENT_CONFIG%
    window.__karma__.setupContext(window);

    // All served files with the latest timestamps
    %MAPPINGS%
</script>
<!-- Dynamically replaced with <script> tags -->
%SCRIPTS%
<!-- Since %SCRIPTS% might include modules, the `loaded()` call needs to be in a module too.
This ensures all the tests will have been declared before karma tries to run them. -->
<script type="module">
    window.__karma__.loaded();
</script>
<script nomodule>
    window.__karma__.loaded();
</script>

Запускаем, и… прикона, работает.

Корутины мои корутины

Но это всё более-менее просто. А если у нас есть корутины? Типа, HTTP-клиент Ktor или просто нам надо добавить задержку. Вот в Python мы бы просто повесили модификатор async, поставили к pytest плагин pytest-async, и всё.

Попробуем навесить suspend на функцию теста.

> Task :compileTestKotlinJs FAILED

e: …src/test/kotlin/com/github/kam1sh/kvision/fomantic/FoButtonTest.kt: (44, 5): Unsupported [suspend test functions]

– Gradle

Хоба. Низзя. Тикет ещё не закрыт.

Ладно, тогда выполним весь код теста в runBlocking {}. Однако…

runBlocking эксклюзивен для Kotlin/JVM.

Это проблема, тоже есть тикет, правда, закрытый, мол, это by design. В качестве решения предлагается использовать GlobalScope.promise, и в принципе с ним можно написать runBlocking в одну строку:

fun runBlocking(block: suspend (scope: CoroutineScope) -> Unit) = GlobalScope.promise { block(this) }

Однако этого недостаточно. Если вы напишете такой код, у вас в определённый момент тесты начнут отваливаться по таймауту. Можно в Karma поднять таймаут следующим способом:

config.set({
  client: {
    mocha: {
      timeout: 9000
    }
  }
})

Но вечно поднимать таймауты не получится. Это просто workaround.

Mocha, из которой, на самом деле, и запускается код тестов, имеет два решения:

  • Делать функцию тестов, которые принимают done-колбэк, и дёргать колбэк по завершении теста.

  • Делать функцию теста такой, чтобы она возвращала Promise.

К сожалению, если вы попробуете добавить колбэк в объявление параметров метода, то ничего хорошего вы не получите. Точнее говоря, такой коллбэк в kotlin-test-js не поддерживается.
Второе, увы и ах, тоже не сделать. Ну, тест может возвращать Promise, но в Mocha он как бы не будет передан.

Что нам, собственно, остаётся? Как связать два мира — мир корутин и мир тестирования?

Встречайте Волшебника из Канзаса – Kotest. Его разработчики всё за нас решили.

Давайте попробуем. Меняем только зависимости и исходники тестов, конфигурация будет прежней.

// build.gradle.kts
testImplementation("io.kotest:kotest-assertions-core-js:4.3.2")
testImplementation("io.kotest:kotest-framework-api-js:4.3.2")
testImplementation("io.kotest:kotest-framework-engine:4.3.2")
class FoButtonTest : FunSpec({
    var kvapp: Root? = null
    beforeEach {
        kvapp = Root("kvapp", containerType = ContainerType.NONE, addRow = false)
    }
    test("generic button") {
        kvapp!!.foButton("Button")
        assertEqualsHtml("""...""")
    }

    test("primary button") {
        val btn = kvapp!!.foButton("Button") { primary = true }
        assertEqualsHtml("""...""")
        btn.primary = false
        delay(200)
        assertEqualsHtml("""...""")
    }
})

Про kotest я много рассказывать не буду, ограничусь тем, что FunSpec — лишь один из возможных стилей написания тестов.

Если внимательно присмотреться, то можно заметить, что во втором тесте есть delay() из корутин и при этом нигде нет модификатора suspend.

А он есть:

Это пока что всё, что я могу рассказать про kotest. Я ещё буду развивать библиотеку, и когда она будет готова, я анонсирую её выход в дискорде Fomantic и слаке Kotlin/kvision. И параллельно буду познавать kotest.

Если вам этого было мало, то приношу извинения: я хотел ещё здесь показать вывод ./gradlew --debug browserTest, но подготовка этого материала и так достаточно затянулась из-за появления личной жизни, так что если интересно — созерцайте отладочные логи Gradle сами.

Ну и что? Ну и ничего. Кушайте Kotlin/JS, запивайте kotest-ом и берегите себя.

Let’s block ads! (Why?)

Read More

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *