Любая асинхронность повышает уровень тревожности при разработке автоматических тестов. Особенно в ситуации, когда нужно выполнить проверку корректности работы системы, основанной на графе состояний со взаимодействием с внешним API через Flow. Примером такой системы может быть Telegram-бот, построенный на диалоге с внешним пользователем. В этой статье мы с вами создадим простой бот на Kotlin (с поддержкой многоязычности) и последовательно разберем возможные способы использования асинхронных моков и тестов для Flow с использованием библиотеки mockk и типобезопасной библиотеки для взаимодействия с API Telegram на Kotlin.

Перед написанием приложения рассмотрим основные понятия API для Telegram. Для получения входящих сообщений бот может использовать механизм долгоживущего подключения к API Telegram (longpolling) или публиковать адрес веб-хука для отправки уведомлений из серверов Telegram с использованием push-модели. В первом случае процесс может быть запущен на любом устройстве, имеющим доступ к сети Интернет, во втором у сервера должен быть опубликован внешний DNS или IP-адрес и на этапе запуска приложение оно уведомляет серверы API о полном URL для подключения к веб-хуку.

При запуске бот может забрать обновления через вызов метода getUpdates, ответом на который будет список структур с описанием сообщений, полученных за время, пока бот был недоступен. Структуры содержат данные об отправителе сообщения, идентификаторе чата, дата-времени отправки, текстовом и детальном содержании сообщения (например, при отправке слэш-команд). После обработки сообщения бот может сформировать запрос к API Telegram с передачей структуры возвращаемого сообщения (текст, аудио-видео, голосовые сообщения, контакты, геолокация, счет на оплату, опросы и игры). Поскольку диалог может быть протяженным, на стороне приложения бота обычно поддерживается конечный автомат, который может использоваться для определения состояния и предыстории диалога. Также при отправке ответа могут быть добавлены описание набор клавиш для меню и флаги, влияющие на уведомления и возможность пересылки сообщения.

В качестве примера для разработки и последующего тестирования бота мы будем использовать типобезопасную библиотеку TelegramBotAPI, основанную на использовании корутин и Flow для асинхронной обработки входящих сообщений. Прежде всего добавим необходимые зависимости в dependencies-блок в build.gradle.kts:

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    kotlin("jvm") version "1.6.21"
}

group = "tech.dzolotov"
version = "1.0-SNAPSHOT"

val tgbotapi_version by extra("2.0.0")

repositories {
    mavenCentral()
}

dependencies {
    implementation("dev.inmo:tgbotapi:$tgbotapi_version")
    implementation("dev.inmo:tgbotapi.utils:$tgbotapi_version")
}

tasks.withType<KotlinCompile> {
    kotlinOptions.jvmTarget = "1.8"
}

Для регистрации нового бота обратимся к служебному боту @BotFather с командой /newbot и после ввода отображаемого и внутреннего имени получим токен, который будет использоваться для инициализации подключения. Для создания нового бота запросим экземпляр TelegramBot через builder-функцию telegramBot(token) и инициализируем основной контекст для обработки сообщений вызовом функции бота buildBehaviourWithFSMAndStartLongPolling<T>. Функция устанавливает долгоживущее соединение с серверами Telegram API и инициализирует внутренний конечный автомат, для которого должен быть указан тип интерфейса, описывающего текущее состояние сессии пользователя (как расширение интерфейса State). Поскольку список состояний имеет конечный размер, разумно использовать sealed interface для перечисления всех возможных состояний. Пока создадим единственное состояние, в которое будет выполнен переход после обработки команды /start.

sealed interface BotState : State
data class StartedState(override val context:ChatId, val locale:Locale):BotState

И запустим основной цикл событий для обработки входящих сообщений:

suspend fun main() {
  val bot = telegramBot(token)
    bot.buildBehaviourWithFSMAndStartLongPolling<BotState> { 
    }.join()
}

Builder-метод возвращает объект Job и мы подключаемся к нему для сохранения активного цикла сообщений и продолжения работы приложения в режиме прослушивания. Следующим шагом добавим обработку команды start, которая приведет к изменению текущего состояния (оно привязывается к контексту, в нашем случае - к идентификатору чата) и отправке приветственного сообщения.

suspend fun main() {
  val bot = telegramBot(token)
  bot.buildBehaviourWithFSMAndStartLongPolling<BotState> {
    command("start") {
      startChain(StartedState(it.chat.id, Locale.forLanguageTag("ru")))
      sendTextMessage(it.chat.id, "Hello")
    }
  }.join()
}

Подключимся к боту и убедимся, что реакция на команду корректная.

Следующим шагом добавим поддержку многоязычности (для этого мы сохранили в контекст состояния объект Locale). Для локализации строк существует множество библиотек для Kotlin, в этом примере мы будем использовать плагин de.comahe.i18n4k, использующий механизм кодогенерации для создания классов с локализациями из properties-файлов. Добавим поддержку плагина и необходимые зависимости в build.gradle.kts:

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    kotlin("jvm") version "1.6.21"
    id("de.comahe.i18n4k") version "0.4.0"
}

group = "tech.dzolotov"
version = "1.0-SNAPSHOT"

val tgbotapi_version by extra("2.0.0")
val i18n4k_version by extra("0.4.0")
val mockk_version by extra("1.12.4")

repositories {
    mavenCentral()
}

dependencies {
    implementation("dev.inmo:tgbotapi:$tgbotapi_version")
    implementation("de.comahe.i18n4k:i18n4k-core:$i18n4k_version")
    implementation("de.comahe.i18n4k:i18n4k-core-jvm:$i18n4k_version")
    implementation("dev.inmo:tgbotapi.utils:$tgbotapi_version")
}

tasks.withType<KotlinCompile> {
    kotlinOptions.jvmTarget = "1.8"
}

i18n4k {
    sourceCodeLocales = listOf("ru","en")
}

Файлы локализации (.properties) добавляются в каталог /src/main/i18n. Нужно не забывать, что properties-файлы корректно работают только в ANSI-кодировке и для работы с кириллицей необходимо включить поддержку прозрачной трансляции кодировки в IDE (в IntelliJ IDEA и Android Studio: Settings -> Editor -> File Encodings -> включить Transparent native-to-ascii conversion).

BotMessage_en.properties

hello=Welcome
BotMessages_ru.properties

hello=Добро пожаловать

После сборки проекта строки локализации будут доступны в сгенерированном классе BotMessages через поле hello и метод toString(locale).

suspend fun start(context: DefaultBehaviourContextWithFSM<BotState>, message:CommonMessage<TextContent>) {
  val locale = Locale.forLanguageTag("ru")
  context.startChain(StartedState(message.chat.id, locale))
  sendTextMessage(it.chat.id, BotMessages.hello.toString(locale))
}

suspend fun main() {
  val bot = telegramBot(token)
  bot.buildBehaviourWithFSMAndStartLongPolling<BotState> {
    command("start") {
      val locale = Locale.forLanguageTag("ru")
      context.startChain(StartedState(it.chat.id, locale))
      context.sendTextMessage(it.chat.id, BotMessages.hello.toString(locale))
    }
  }.join()
}

Можно ли как-то получить информацию от пользователя о предпочтительном языке? Давайте попробуем извлечь объект исходного сообщения и посмотреть, что мы из него можем использовать. Для этого можно использовать дополнительный объект фильтрации входящего потока, который содержит flow со всеми входящими сообщениями:

suspend fun main() {
    val bot = telegramBot(token)
    val flowUpdatesFilter = FlowsUpdatesFilter()
    bot.buildBehaviourWithFSM<BotState>(flowUpdatesFilter=flowUpdatesFilter) {
        retrieveAccumulatedUpdates(flowUpdatesFilter)
        flowUpdatesFilter.allUpdatesFlow.collect {
            println(it)
        }
    }
}

При отправке сообщения в бот будет отображена подробная информация в консоли о содержании объекта Update, поступившего от API, например:

MessageUpdate(updateId=249090759, data=PrivateContentMessageImpl(messageId=31, from=CommonUser(id=ChatId(chatId=112121111), firstName=Test, lastName=Test, username=Username(username=@testuser), ietfLanguageCode=ru), chat=PrivateChatImpl(id=ChatId(chatId=112121111), username=Username(username=@testuser), firstName=Test, lastName=Test), content=TextContent(text=/start, textSources=[BotCommandTextSource(source=/start)]), date=DateTime(1653733956000), editDate=null, hasProtectedContent=false, forwardInfo=null, replyTo=null, replyMarkup=null, senderBot=null))

Эта структура нам будет полезна в будущем для создания моков при выполнении автоматизированного тестирования, сейчас только отметим, что в информации об отправителе есть код страны в соответствии с языком, выбранном у пользователя. Используем информацию о языке пользователя и заменим обработку команды /start и также выделим отдельную функцию для описания логики.

suspend fun onStart(context: DefaultBehaviourContextWithFSM<BotState>, chatId: ChatId, locale: String?) {
    val locale = Locale.forLanguageTag(locale ?: "ru")
    context.startChain(StartedState(chatId, locale))
    context.sendTextMessage(chatId, BotMessages.hello.toString(locale))
}

suspend fun main() {
  val bot = telegramBot(token)
  bot.buildBehaviourWithFSMAndStartLongPolling<BotState> {
    command("start") {
      onStart(this, it.chat.id, it.from?.asCommonUser()?.languageCode)
    }
  }.join()
}

Теперь добавим немножко полезной функциональности и будем выводить случайную шутку по запросу команды /joke (будем использовать Joke API, а именно REST-метод для извлечения случайной шутки про программирование https://v2.jokeapi.dev/joke/Programming). Структура ответа от API может быть определена из следующего примера:

{
    "error": false,
    "category": "Programming",
    "type": "twopart",
    "setup": "Hey baby I wish your name was asynchronous...",
    "delivery": "... so you'd give me a callback.",
    "flags": {
        "nsfw": false,
        "religious": false,
        "political": false,
        "racist": false,
        "sexist": false,
        "explicit": false
    },
    "id": 54,
    "safe": true,
    "lang": "en"
}

Для однострочных шуток используется другая структура (отмечаем только важные поля)

{
    "category": "Programming",
    "type": "single",
    "joke": "The generation of random numbers is too important to be left to chance.",
}

Для поддержки json-сериализации добавим поддержку kotlinx-serialization-json в build.gradle.kts:

plugins {
    kotlin("jvm") version "1.6.21"
    id("de.comahe.i18n4k") version "0.4.0"
    id("org.jetbrains.kotlin.plugin.serialization") version "1.6.21"
}

dependencies {
    //...здесь размещаем предыдущие зависимости...
    implementation("io.ktor:ktor-serialization-kotlinx-json:2.0.1")
}

Создадим сериализуемый data-класс для описания содержательной части ответа:

@kotlinx.serialization.Serializable
data class Joke(val type: String, val setup: String? = null, val delivery: String? = null, val joke: String? = null)

Создадим отдельную функцию для получения случайной шутки (для дальнейшего unit-тестирования):

val jokeApi = "https://v2.jokeapi.dev/joke/Programming"

suspend fun getJoke(client:HttpClient):String? {
    return try {
        val response: HttpResponse = client.get(jokeApi)
        val joke = Json { ignoreUnknownKeys = true }.decodeFromString(Joke.serializer(), response.bodyAsText())
        if (joke.type == "twopart") {
            joke.setup + "\n" + joke.delivery
        } else {
            joke.joke.toString()
        }
    } catch (e:Exception) {
        null
    }
}

И зарегистрируем обработчик команды joke для отправки в ответном сообщении случайной шутки:

command("joke") {
    sendTextMessage(it.chat.id, getJoke()!!)
}

Мы сделали необходимый минимум функциональности и теперь готовы перейти к автоматическим тестам. И здесь нас ждет несколько сложных моментов:

  • все функции обработчики сообщений в боте являются корутинами (даже если их тестировать независимо, это нужно учитывать);

  • при выполнении функции вызываются методы контекста обработчика (startChain и sendTextMessage), что осложняет возможность прямого тестирования реального объекта бота (поскольку в этом случае запрос будет отправляться в API и выдавать ошибку из-за отсутствия диалога с тестовым пользователем);

  • полноценное тестирование подразумевает имитацию действий пользователя (отправку сообщения и/или команд), а для этого нужно встроиться в поток входящих сообщений и заменить собой Flow<Update>.

Давайте разбираться последовательно. Для тестирования корутин в Kotlin существует библиотека kotlinx-coroutines, которая реализует создание специального диспетчера (TestDispatcher) и контекста для запуска корутин в обычных JUnit тестов с ожиданием завершения выполнения кода и поддержкой виртуального времени (для автоматического пропуска длительных задержек, реализуемых через delay).

Добавим в блок dependencies поддержку библиотеки kotlin.test для запуска тестов (будем использовать JUnitPlatform) и зависимости от kotlinx-coroutines-test.

dependencies {
    testImplementation(kotlin("test"))
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1")
    //здесь подставить ранее добавленные зависимости
}

tasks.test {
    useJUnitPlatform()
}

Начнем с создания Unit-теста для проверки корректности извлечения случайной шутки. Для корректного выполнения корутины будем использовать launcher-функцию runTest из kotlinx-coroutines-test:

import kotlin.test.Test
import kotlin.test.assertNotNull

class TestBot {
    @Test
    fun testJoke() = runTest {
        val joke = getJoke()
        assertNotNull(joke)
    }
}

Но основной недостаток такого тестирования в том, что здесь происходит обращение к ресурсам сети и тест может быть провален в случае, если интернет или сервер REST API недоступен. Для подмены ответа мы будем использовать возможности ktor-client-mock, добавим зависимость в dependencies:

testImplementation("io.ktor:ktor-client-mock:$ktor_version")

И опишем подстановку ответа при вызове get-метода к адресу jokeApi:

@Test
fun testSingleJoke() = runTest {
  val expectJoke = "What did the fish get on his math test? A sea plus."
  val client = HttpClient(MockEngine) {
    engine {
      addHandler { request ->
        when (request.url) {
          Url(jokeApi) -> {
            respond("""{
              "type": "single",
              "joke": "$exceptJoke"
            }""".trimMargin())
          }
          else -> error("Unhandled ${request.url}")
        }
      }
    }
  }
  val actualJoke = getJoke(client)
  assertEquals(expectJoke, actualJoke)
}

И также проверим двухстрочный ответ:

@Test
fun testTwoPartJoke() = runTest {
    val setup = "Why cant gandalf mark tests?"
    val delivery = "Because he always tells the students 'YOU… SHALL NOT PASS!'"
    val client = HttpClient(MockEngine) {
        engine {
            addHandler { request ->
                when (request.url) {
                    Url(jokeApi) -> {
                        respond("""{
                            "type": "twopart",
                            "setup": "$setup",
                            "delivery": "$delivery"
                            }""".trimMargin())
                    }
                    else -> error("Unhandled ${request.url}")
                }
            }
        }
    }
    val actualJoke = getJoke(client)
    assertEquals(setup+"\n"+delivery, actualJoke)
}

Но наша цель прежде всего - проверить корректность реакции бота на сообщения пользователя и для решения этой задачи нам будет нужно частично подменять поведение библиотеки для взаимодействия с API Telegram. Здесь нам может помочь использование mock и spy-объектов. Mock-объекты подменяют полностью интерфейс заданного класса и используются во всех случаях, когда создается экземпляр объекта (используется механизм замены через ClassLoader). Spy-объекты полезно использовать в ситуациях, когда нужно переопределить только часть интерфейса, но оставить остальную функциональность без изменений. Мы будем использовать библиотеку mockk, которая является развитием более известной библиотеки Mockito, но с поддержкой более удобного синтаксиса для Kotlin.

Прежде всего установим необходимые зависимости в build.gradle.kts:

val mockk_version by extra("1.12.4")

dependencies {
  //...здесь подставим все остальные зависимости...
  testImplementation("io.mockk:mockk:$mockk_version")
}

И сначала попробуем перехватить отправляемые из бота сообщения через использование тестовых двойников (spy- и mock-объектов):

fun testBot() = runTest {
    val bot = spyk<TelegramBot>()
    coEvery { bot.execute<Request<Message>>(any()) } returns mockk()
    val chatId = ChatId(0)

    bot.buildBehaviourWithFSM<BotState> {
        onStart(this, chatId, "ru")
        coVerify { bot.execute(SendTextMessage(chatId, "Добро пожаловать")) }
        onStart(this, chatId, "en")
        coVerify { bot.execute(SendTextMessage(chatId, "Welcome")) }
    }
}

В этом тесте создается тестовый двойник для класса-обработчика взаимодействия с Telegram API (TelegramBot) и декларируется, что любой вызов execute с произвольным параметром (в него приходит объект с описанием сообщения, которое отправляется в ответ клиенту) будет создавать пустой подставной объект. В mockk можно работать как с обычными методами (тогда используется выражение every { call(args) } returns value), так и с корутинами (соответственно coEvery { call(args) returns value). Далее создается контекст для обработки сообщений (buildBehaviourWithFSM) и в нем вызывается функция, которая должна сработать при отправке сообщения /start. Затем с использованием механизма проверки библиотеки mockk мы убеждаемся, что внутри выполняемого кода было обращение к методу execute из контекста выполнения бота с объектом, представляющим собой текстовое сообщение адресату, указанному в ChatId с текстом "Добро пожаловать" и "Welcome" (при использовании locale en).

Следующим этапом нужно проверить, произошло ли изменение состояния конечного автомата после обработки команды /start. Для этого мы создадим mock-объект для менеджера StatesManager и проверим факт вызова действия замены состояния.

@Test
fun testState() = runTest {
    val chatId = ChatId(0)
    val locale = Locale.forLanguageTag("ru")

  //тестовый двойник для библиотеки API Telegram
    val bot = spyk<TelegramBot>()
    //пропускаем обработку отправки сообщения (но запомним факт)
    coEvery { bot.execute<Request<Message>>(any()) } returns mockk()
    //будем отслеживать изменения состояние в FSM (Finite State Machine)
    val statesManager = spyk<StatesManager<BotState>>()
    //запускаем контекст (с нашим statesManager)
    bot.buildBehaviourWithFSM(statesManager = statesManager) {
        //имитируем действие пользователя (команда /start)
        onStart(this, chatId, "ru")
        //убедимся, что было изменение состояния
        coVerify { statesManager.startChain(StartedState(chatId, locale)) }
        //и что отправилось сообщение в ответ
        val message = SendTextMessage(chatId, "Добро пожаловать")
        coVerify { bot.execute(message) }
    }
}

Но конечно же наиболее интересный сценарий - подмена входящих сообщений от пользователя. Здесь нужно будет выполнить несколько изменений в нашем исходном приложении, чтобы можно было корректно сформировать привязки функций-обработчиков к mock-объекту бота. Создадим отдельную функцию инициализации контекста:

suspend fun botSetup(context: DefaultBehaviourContextWithFSM<BotState>) {
    context.apply {
        command("start") {
            onStart(this, it.chat.id, it.from?.asCommonUser()?.languageCode)
        }
        command("joke") {
            sendTextMessage(it.chat.id, getJoke(HttpClient(CIO))!!)
        }
    }
}

и вызовем ее внутри builder-функции для создания контекста:

suspend fun main() {
    val bot = telegramBot(token)
    bot.buildBehaviourWithFSMAndStartLongPolling<BotState> {
        botSetup(this)
    }.join()
}

При создании контекста бота для использования в тесте будем использовать возможность подмены входного потока сообщений на Flow<Update>, сформированный программно:

@Test
fun testStartCommand() = runTest 
		//создаем тестовой двойник для API Telegram и перехватываем любые исходящие сообщения
    val bot = spyk<TelegramBot>()
    coEvery { bot.execute<Request<Message>>(any()) } returns mockk()

    val chatId = ChatId(0)
    //создаем объект пользователя и возвращаем для него русский язык как основной
    val user = mockk<CommonUser>()
    every { user.languageCode } returns "ru"
    val locale = Locale.forLanguageTag("ru")

    //будем также отслеживать изменение состояния FSM (Finite State Machine)
    val statesManager = spyk<StatesManager<BotState>>()
    
    //создаем поток входящих сообщений и контекст бота с использованием потока как upstreamUpdates
    val flow = MutableSharedFlow<Update>()
    bot.buildBehaviourWithFSM(statesManager=statesManager, upstreamUpdatesFlow = flow) {
        botSetup(this)
    }
    //создаем сообщение от имени пользователя (используем структуру, аналогичной полученной из отладки выше)
    val msg = PrivateContentMessageImpl(0L, user, chat = PrivateChatImpl(chatId), 
                                        content = TextContent("/start", 
                                                              textSources=listOf(BotCommandTextSource(source="/start"))), 
                                        date= DateTime.now(), editDate = null, 
                                        hasProtectedContent = false, forwardInfo = null, 
                                        replyTo = null, replyMarkup = null, senderBot = null)
		//отправляем сообщение боту
		flow.emit(MessageUpdate(0L, msg))

    //и проверяем корректность ответа и перехода состояния
    coVerify { statesManager.startChain(StartedState(chatId, locale)) }
    val message = SendTextMessage(chatId, "Добро пожаловать")
    coVerify { bot.execute(message) }
}

Ну и в завершение сделаем проверку генерации случайной (на самом деле нет, поскольку будем использовать mock) шутки. Здесь мы столкнемся с проблемой, поскольку mockk не позволяет создать объект для имитации HttpClient(CIO), поэтому немного изменим сигнатуру метода botSetup и будем передавать туда экземпляр http-клиента.

suspend fun botSetup(context: DefaultBehaviourContextWithFSM<BotState>, httpClient: HttpClient) {
    context.apply {
        command("start") {
            onStart(this, it.chat.id, it.from?.asCommonUser()?.languageCode)
        }
        command("joke") {
            sendTextMessage(it.chat.id, getJoke(httpClient)!!)
        }
    }
}

suspend fun main() {
    val bot = telegramBot(token)
    bot.buildBehaviourWithFSMAndStartLongPolling<BotState> {
        botSetup(this, HttpClient(CIO))
    }.join()
}

И будем использовать MockEngine для подмены ответа http-запроса и spy-объект для Telegram API / StatesManager с подменой ответа асинхронных вызовов через mockk:

@Test
fun testJokeCommand() = runTest {
    val exceptJoke =
        "Programming is 10% science, 20% ingenuity, and 70% getting the ingenuity to work with the science."
    val client = HttpClient(MockEngine) {
        engine {
            addHandler { request ->
                when (request.url) {
                    Url(jokeApi) -> {
                        respond(
                            """{
                            "type": "single",
                            "joke": "$exceptJoke"
                            }""".trimMargin()
                        )
                    }
                    else -> error("Unhandled ${request.url}")
                }
            }
        }
    }

    val bot = spyk<TelegramBot>()
    coEvery { bot.execute<Request<Message>>(any()) } returns mockk()

    val chatId = ChatId(0)
    val user = mockk<CommonUser>()
    every { user.languageCode } returns "en"

    val statesManager = spyk<StatesManager<BotState>>()
    val flow = MutableSharedFlow<Update>()

    bot.buildBehaviourWithFSM(statesManager = statesManager, upstreamUpdatesFlow = flow) {
        botSetup(this, client)
    }
    val msg = PrivateContentMessageImpl(
        0L, user,
        chat = PrivateChatImpl(chatId), content = TextContent(
            "/joke",
            textSources = listOf(BotCommandTextSource(source = "/joke"))
        ),
        date = DateTime.now(), editDate = null, hasProtectedContent = false,
        forwardInfo = null, replyTo = null, replyMarkup = null, senderBot = null
    )
    flow.emit(MessageUpdate(0L, msg))

    coVerify { bot.execute(SendTextMessage(chatId, exceptJoke)) }
}

При создании mockk объектов можно использовать более сложные проверки вызовов функций и/или обращений к свойствам, применять правила сравнения для подбора подходящих значений аргументов (как с числами, там и со строками), создавать статические свойства и делать подмену конструкторов, использовать таймауты при валидации корутин. Более подробно возможности библиотеки можно изучить на официальной странице.

Также хочу пригласить всех желающих на бесплатный урок курса Kotlin QA Engineer, в рамках которого мои коллеги расскажут про основные особенности Kotlin и Java, а так же их применение в автотестировании.

Узнать подробнее о курсе.

Комментарии (1)


  1. DonAlPAtino
    01.06.2022 11:26

    А полные исходники примера можно где-нибудь посмотреть?