С чего всё началось?

Ещё на этапе собеседования в текущую команду передо мной поставили вопрос:

Применима ли автоматизация тестирования при проверке функционала чат‑ботов?

Не решав подобные задачи ни разу, я с полной уверенностью сказал "Да!", и начал изучать текущее решение. Собственно:

Текущее решение

В компании есть текстовые чат-боты (Telegram, WhatsApp, Viber) и голосовой бот-помощник, с которым сталкивается большинство людей, когда-либо звонивших на номер 8 (800) и прочие централизованные номера корпораций.

Процессы в текстовых чат-ботах были налажены - бот отвечал на вопросы по пунктам, либо переводил диалог на оператора. Но вот в приложении голосового бота всё было гораздо интереснее:

  1. Озвучивание нашего текста

  2. Транскрибация запроса клиента

  3. Сопоставление запроса с интентами в DialogFlow

  4. Получение информации с бэка

Количество интентов велико, правки и дополнения вносятся часто, вследствие чего было принято решение подключать автоматизацию именно здесь.

Способы тестирования

На самом деле, варианта всего два: тестирование с использованием голосовых команд, либо тестирование интентов напрямую в DialogFlow, но дьявол кроется в деталях. Разберем эти пункты отдельно.

Тестирование голосом

MicSwap или Virtual Audio Cable - приложения на Windows для эмуляции микрофона, с помощью которых (чисто теоретически) можно проигрывать голосовые дорожки на виртуальный микрофон при тестировании голосового бота через, например, софтфон. Вариант был отложен "на подумать" по следующим причинам:

  1. Необходим сервис генерации фраз клиентов

  2. При использовании статичного набора фраз нужно место для их хранения

  3. Необходимы дополнительные проверки правильности распознавания и транскрибации голоса, предварительное решение - параллельный мониторинг логов, либо локальное распознавание.

Тестирование через DialogFlow

В части ручного тестирования DialogFlow предусмотрел всё, что вам нужно. Всё, в данном случае - это симулятор в консоли, который позволяет вести диалог и сразу видеть всю необходимую информацию - реакцию, метаданные, интент, контексты.

Попробуй, я настаиваю. (с)
Попробуй, я настаиваю. (с)

Вся магия при ручном тестировании происходит именно здесь - агенты для прода, стейджа, тестового стенда, и консоль для проверок свежих правок по нашим диалогам.

Поскольку API тесты на момент анализа задачи я не писал, было принято решение банально автоматизировать использование данной консоли от лица самого себя.

Автотесты v1.0 (UI)

Появилось первое решение - закипела работа. Были выбраны самые критичные для бизнеса интенты, набросаны методы для авторизации и работы с консолью, создан отдельный класс для настройки тестового клиента, на основе данных о котором DialogFlow будет давать нам ответы.

@allure.step("Написать: {phrase}")
def write(self, phrase:str):
    """Отправка сообщения в текстовое поле - эмуляция фразы клиента"""
    input = self.driver.find_element(By.CSS_SELECTOR, '#test-client-query-input')
    input.send_keys(phrase)
    input.send_keys(Keys.ENTER)

@allure.step("Проверить текст ответа")
def checkResponse(self, text:str):
    """Проверить текст ответа на реплику от DF."""
    response = self.driver.find_element(By.CSS_SELECTOR, 'div[title="Text Response"] span').get_attribute('innerHTML') #span
    assert text in response, 'Бот ответил:\n' + response + '\nБот должен был ответить:\n' + text

Отдельно стоит упомянуть ситуацию, когда текст отображается в <span> - контейнере: для его экстракции нужно использовать метод get_attribute('innerHTML').

Первый тест готов, диалоги пишутся напрямую в скрипте. Параметры клиента задаются в нём же. При написании второго теста начинают чаще одолевать мысли "как бы вынести все данные для теста из самого скрипта, чтобы уменьшить количество кода и его дублей, и чтобы было удобно? Решение красиво и банально - весь диалог вынести в отдельный текстовый файл...

!2
Здраствуйте, я голосовой помощник! Cкажите что вас интересует.
сколько осталось топлива
Правильно ли я поняла, что вам нужно узнать остаток бензина в баке?
да
Остаток топлива в баке 15 литров.

...дописать метод для работы с этим файлом...

def executeTest(self,
                allure_header,
                path_to_phrases_file="tests/regress/dialogs/dialog.txt"):
        @allure.step(allure_header)
        def executeTestWithHeader():
            with open(path_to_phrases_file, encoding="utf-8") as f:
                phrases = f.readlines()
                f.close()
            self.DF_login()
            clientIndex = 0
            botIndex = 1
            while clientIndex < phrases.__len__():
                self.write(phrases[clientIndex][:-1])
                self.checkResponse(phrases[botIndex][:-1])
                clientIndex = clientIndex + 2
                botIndex = botIndex + 2
        executeTestWithHeader()

...указав индексы для фраз клиента и бота, не забывая передавать строки, полученные из файла со срезом последнего символа (\n), и вуаля - наш тест, состоящий из одного метода, для выполнения которого нужен всего лишь заголовок теста для отчёта в Allure и диалог в текстовом файле, готов.

Автотесты v2.0 (API)

Мы меняем версии как перчатки только в статье, поэтому не стоит пугаться столь мажорных числовых перемен. Вот почему мы выполняем условный инкремент.

Тесты прекрасно работают локально, с боевыми данными один кейс проходит за ~90 секунд и для небольшого набора тестов это приемлемый результат. Главная проблема выяснилась на этапе построения пайплайна в GitLab - тесты упорно не хотели там проходить. Причина была проста до безобразия: политика безопасности Google для аккаунтов.

В связи с большой удалённостью (физической и сетевой) друг от друга инфраструктуры и моего рабочего места, учетной записи требовалась двухфакторная авторизация - подтверждение телефона, проверочный код, выбор чисел на экране телефона и тому подобное. Казалось бы, можно было бы повысить степень доверия, проставив дополнительные ожидания и успеть прокликать всю верификацию, но коллеги из инфры дали ценный совет:

Переделывай на API. Такие тесты всегда проще и короче.

Предвкушая освоение новой области, были начаты поиски подходящих вариантов и внезапно выяснилось, что у DialogFlow и вправду есть API.

Используя ещё одну прекрасную, но заблокированную большим братом инструкцию настраиваем наши тесты. Пишем инициализацию класса, в котором задаем переменную с ключом доступа к API и создаем сессию.

from google.cloud import dialogflow_v2beta1 as dialogflow
import os
import random

class Events:

    def __init__(self):
        os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = 'API/credentials/voicebot.json'
        self.input = dialogflow.TextInput()
        self.input.language_code = 'Russian-ru'
        self.DIALOGFLOW_PROJECT_ID = 'voicebot'
        self.DIALOGFLOW_LANGUAGE_CODE = 'Russian — ru'
        self.SESSION_ID = 'autotest_' + str(random.randint(1,999999))
        self.session_client = dialogflow.SessionsClient()
        self.session = self.session_client.session_path(self.DIALOGFLOW_PROJECT_ID, self.SESSION_ID)

Переписываем методы для работы с сообщениями и добавляем проверку интента:

@allure.step("Написать: {phrase}")
def write(self, phrase:str):
    """Эмуляция фразы клиента"""
    self.phrase = phrase
    print(self.phrase)
    self.text_input = dialogflow.TextInput(text=self.phrase, language_code=self.DIALOGFLOW_LANGUAGE_CODE)
    self.query_input = dialogflow.QueryInput(text=self.text_input)

@allure.step("Проверить текст ответа бота и интент")
def checkResponse(self, text:str, intent:str):
    """Проверить текст ответа бота и интент"""
    self.response = self.session_client.detect_intent(session=self.session, query_input=self.query_input).query_result
    self.response_text = str(self.response.fulfillment_messages)
    self.response_intent = str(self.response.intent.display_name)
    index1 = self.response_text.find('"')
    self.response_text = self.response_text[index1+1:]
    index2 = self.response_text.find('"')
    self.response_text = self.response_text[:index2]
    assert text in self.response_text, '\nБот ответил: ' + self.response_text + '\nБот должен был ответить: ' + text
    assert intent in self.response_intent, '\nКлиент сказал: ' + self.phrase + '\nОтвет бота: ' + self.response_text + '\nИнтент диалога в DF: ' + self.response_intent + '\nОжидался интент: ' + intent

def executeTest(self,
                allure_header:str,
                intent:str="dialog"):
    @allure.step(allure_header)
    def executeTestWithHeader():
        dialog_path = 'tests/regress/dialogs/' + intent + '.txt'
        intent_path = 'tests/regress/intents/' + intent + '.txt'
        with open(dialog_path, encoding="utf-8") as f:
            phrases = f.readlines()
            f.close()
        with open(intent_path, encoding="utf-8") as i:
            intents = i.readlines()
            i.close()
        clientIndex = 0
        botIndex = 1
        intentIndex = 0
        while clientIndex < phrases.__len__():
            self.write(phrases[clientIndex][:-1])
            self.checkResponse(phrases[botIndex][:-1], intents[intentIndex][:-1])
            clientIndex = clientIndex + 2
            botIndex = botIndex + 2
            intentIndex = intentIndex + 1
    executeTestWithHeader()

По аналогии с файлом диалога добавляем файл с построчным списком интентов, по которым мы планируем проходить:

ivr - begin
ivr - how_much_fuel
how_much_fuel - response

Пишем простейший тест:

from helpers.Events import Events

def test_fuel():

    e = Events()
    e.executeTest("Проверка остатка топлива"
                  ,"dialog")

И получаем наш профит:

  1. Не используем UI там, где его можно не использовать

  2. Получаем тесты с авторизацией через токен приложения - запускаем где угодно

  3. Уменьшаем количество кода и делаем его более лаконичным

  4. Сокращаем время прогона с ~90с до ~10c на тест

Заключение

На данный момент тесты используются для проверки малого регрессионного набора, основное же их назначение ввиду отсутствия большого регресса - проверка новых интентов, поскольку сейчас идет большая переделка всей структуры бота для разбиения областей взаимодействия с клиентом. Как раз в связи с этим было принято решение сделать написание новых тестов максимально простым - нужно добавить файл скрипта Python, два текстовых файла с диалогом и списком интентов, после чего тест уже можно использовать для проверок.

Вступаю в ряды любителей тестов через API, призываю вас поступить так же и желаю успехов в освоении новых областей знаний!

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