С чего всё началось?
Ещё на этапе собеседования в текущую команду передо мной поставили вопрос:
Применима ли автоматизация тестирования при проверке функционала чат‑ботов?
Не решав подобные задачи ни разу, я с полной уверенностью сказал "Да!", и начал изучать текущее решение. Собственно:
Текущее решение
В компании есть текстовые чат-боты (Telegram, WhatsApp, Viber) и голосовой бот-помощник, с которым сталкивается большинство людей, когда-либо звонивших на номер 8 (800) и прочие централизованные номера корпораций.
Процессы в текстовых чат-ботах были налажены - бот отвечал на вопросы по пунктам, либо переводил диалог на оператора. Но вот в приложении голосового бота всё было гораздо интереснее:
Озвучивание нашего текста
Транскрибация запроса клиента
Сопоставление запроса с интентами в DialogFlow
Получение информации с бэка
Количество интентов велико, правки и дополнения вносятся часто, вследствие чего было принято решение подключать автоматизацию именно здесь.
Способы тестирования
На самом деле, варианта всего два: тестирование с использованием голосовых команд, либо тестирование интентов напрямую в DialogFlow, но дьявол кроется в деталях. Разберем эти пункты отдельно.
Тестирование голосом
MicSwap или Virtual Audio Cable - приложения на Windows для эмуляции микрофона, с помощью которых (чисто теоретически) можно проигрывать голосовые дорожки на виртуальный микрофон при тестировании голосового бота через, например, софтфон. Вариант был отложен "на подумать" по следующим причинам:
Необходим сервис генерации фраз клиентов
При использовании статичного набора фраз нужно место для их хранения
Необходимы дополнительные проверки правильности распознавания и транскрибации голоса, предварительное решение - параллельный мониторинг логов, либо локальное распознавание.
Тестирование через 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")
И получаем наш профит:
Не используем UI там, где его можно не использовать
Получаем тесты с авторизацией через токен приложения - запускаем где угодно
Уменьшаем количество кода и делаем его более лаконичным
Сокращаем время прогона с ~90с до ~10c на тест
Заключение
На данный момент тесты используются для проверки малого регрессионного набора, основное же их назначение ввиду отсутствия большого регресса - проверка новых интентов, поскольку сейчас идет большая переделка всей структуры бота для разбиения областей взаимодействия с клиентом. Как раз в связи с этим было принято решение сделать написание новых тестов максимально простым - нужно добавить файл скрипта Python, два текстовых файла с диалогом и списком интентов, после чего тест уже можно использовать для проверок.
Вступаю в ряды любителей тестов через API, призываю вас поступить так же и желаю успехов в освоении новых областей знаний!