Прошел почти год с того момента, как появилась возможность создавать свои навыки для Алисы — голосового помощника от Яндекса. В каталог ежедневно прибывают новые навыки, а их общее число составляет несколько сотен. К сожалению, общение с некоторыми навыками мягко говоря "не складывается". Навык или зацикливается на одной и той же фразе или вообще сломан и не отвечает.
В этой статье я рассмотрю написание функциональных автоматизированных тестов для навыка на Node.js. Наличие таких тестов позволяет создавать более качественные навыки и дает уверенность в их работоспособности.
Существующие инструменты тестирования
Навык для Алисы — это веб-сервер, который умеет отвечать на POST запросы в определенном формате. На данный момент существует несколько инструментов, куда можно передать URL навыка и проверить его работу:
- Официальная консоль разработчика, где можно тестировать навыки текстом и смотреть запросы/ответы
- Симулятор Яндекс Станции от Aimylogic, поддерживает голос
- Open-source проект dialogs.popstas.ru для локального тестирования навыков
Особенность этих инструментов в том, что они предлагают некоторый UI для ручного тестирования навыка. Я же хочу в лучших традициях непрерывной интеграции
запускать команду в консоли, автоматически проверять все сценарии и только потом выкладывать новую версию.
В то же время, не хочется углубляться в юнит-тестирование отдельных модулей навыка. Протокол запросов/ответов зафиксирован в документации, и именно на этом уровне лучше тестировать. Тогда, даже полностью переписав внутреннюю архитектуру, менять тесты не придется. То есть по сути это функциональные тесты.
Готовой библиотеки под Node.js для такой задачи я не нашел, поэтому напишем свое :)
Навык для тестов
Возьмем официальный пример навыка из репозитория Яндекса на GitHub. Это навык "Попугай", который просто повторяет все, что сказал пользователь. Построен на базе фреймворка micro и содержит всего несколько строчек кода:
// server.js
const micro = require('micro');
const {json} = micro;
module.exports = micro(async req => {
const {request, session, version} = await json(req);
return {
version,
session,
response: {
text: request.original_utterance || 'Hello!',
end_session: false,
},
};
});
При первом заходе навык получит от пользователя пустое сообщение (original_utterance) и ответит "Hello!"
. В остальных случаях просто скопирует сообщение пользователя в поле response.text
.
Я обернул оригинальный код примера с GitHub в функцию micro()
, чтобы экспорт возвращал http-сервер, который мы и будем использовать в тестах.
Тест-план
Итак, чтобы покрыть тестами такой навык, необходимо следующее:
- Поднять сервер с навыком на локальном порту
- Проверить два кейса:
- Пользователь заходит в навык, навык должен ответить "Hello!"
- Пользователь отправляет сообщение в навык, навык должен ответить тем же сообщением
- Остановить сервер с навыком и показать отчет
Автоматизировав эти проверки, можно будет запускать их перед каждым коммитом и быть уверенным, что ничего не сломалось.
Напишем код теста согласно плану, используя синтаксис для mocha. Допустим, что у нас уже есть некоторый класс User
, который умеет делать все что нужно:
// test.js
const assert = require('assert');
before(done => {
// запускаем сервер навыка
server.listen(PORT, done);
});
it('should get hello on enter', async () => {
// создаем пользователя для навыка
const user = new User(`http://localhost:${PORT}`);
// заходим в навык и сохраняем ответ
const response = await user.enter();
// проверяем текст ответа
assert.equal(response.text, 'Hello!');
});
after(done => {
// останавливаем сервер
server.close(done);
});
Осталось написать класс User
и можно будет запускать тест.
Виртуальный пользователь
Главное, что должен уметь тестовый пользователь — отправлять POST запросы на урл навыка с данными в нужном формате. Формат запроса описан в документации. Сейчас нам не нужны все поля, поэтому я оставил только необходимые, чтобы не раздувать код примера. Класс User
с комментариями:
// user.js
const fetch = require('node-fetch');
module.exports = class User {
/**
* Конструктор
* @param {String} webhookUrl
*/
constructor(webhookUrl) {
this._webhookUrl = webhookUrl;
}
/**
* Вход пользователя в навык
*/
async enter() {
const headers = {
'Accept': 'application/json',
'Content-Type': 'application/json'
};
// при заходе в навык, сообщение - пустая строка
const body = this._buildRequest('');
const response = await fetch(this._webhookUrl, {
method: 'post',
headers,
body: JSON.stringify(body),
});
const json = await response.json();
return json.response;
}
/**
* Сборка тела запроса с заданным сообщением пользователя
* @param {String} message
*/
_buildRequest(message) {
return {
request: {
command: message,
original_utterance: message,
type: 'SimpleUtterance',
},
session: {
new: true,
user_id: 'user-1',
session_id: 'session-1'
},
version: '1.0'
}
}
};
Запуск
Для запуска осталось импортировать классы пользователя и сервера в файл с тестами, а также выставить значение порта, на котором поднимется сервер:
// test.js
...
const server = require('./server');
const User = require('./user');
const PORT = 3456;
...
Устанавливаем все необходимые зависимости:
npm install micro node-fetch mocha
И запускаем тест:
$ mocha test.js
✓ should get hello on enter
1 passing (34ms)
Все хорошо, тест пройден!
Но прежде чем идти дальше, нужно убедиться, что тест действительно работает. Для этого заменим в ответе навыка "Hello!" на "Привет!" и запустим еще раз:
$ mocha test.js
0 passing (487ms)
1 failing
1) should get hello on enter:
AssertionError [ERR_ASSERTION]: 'Привет!' == 'Hello!'
+ expected - actual
-Привет!
+Hello!
Тест показал ошибку — как и должно быть.
Вот теперь точно первый кейс считаем покрытым.
Учим пользователя общаться
Остался второй кейс, когда пользователь отправляет в навык сообщение и должен получить это же сообщение обратно. Чтобы пользователь смог "общаться", я добавил в класс User
метод say(message)
. Также сделал небольшой рефакторинг: вынес отправку http-запросов в отдельный метод и использовал его внутри enter()
и say(message)
:
// user.js
const fetch = require('node-fetch');
module.exports = class User {
/**
* Конструктор
* @param {String} webhookUrl
*/
constructor(webhookUrl) {
this._webhookUrl = webhookUrl;
}
/**
* Вход пользователя в навык
*/
async enter() {
// при заходе в навык, сообщение - пустая строка
const body = this._buildRequest('');
return this._sendRequest(body);
}
/**
* Отправка сообщения в навык
* @param {String} message
*/
async say(message) {
const body = this._buildRequest(message);
return this._sendRequest(body);
}
/**
* Отправка http-запроса
* @param {Object} body тело запроса
*/
async _sendRequest(body) {
const headers = {
'Accept': 'application/json',
'Content-Type': 'application/json'
};
const response = await fetch(this._webhookUrl, {
method: 'post',
headers,
body: JSON.stringify(body),
});
const json = await response.json();
return json.response;
}
// ...
};
Тестирующий код для второго кейса выглядит так:
it('should reply the same message', async () => {
// создаем пользователя
const user = new User(`http://localhost:${PORT}`);
// заходим в навык
await user.enter();
// отправляем сообщение
const response = await user.say('что ты умеешь?');
// проверяем текст ответа
assert.equal(response.text, 'что ты умеешь?');
});
Запускаем еще раз, и видим что оба теста пройдены:
$ mocha test.js
✓ should get hello on enter
✓ should reply the same message
2 passing (37ms)
Дальнейшие шаги
Таким же образом можно добавлять более сложные сценарии в навык, покрывая их тестами. Это даст уверенность, что новые изменения не ломают старые.
Созданную инфраструктуру тестов также можно улучшить:
- доработать класс
User
, чтобы можно было менять остальные поля в запросе (например поставить флажок, что у пользователя нет экрана) - подключить code-coverage (например nyc)
- повесить все проверки на pre-commit/pre-push хуки (например с помощью husky)
У меня несколько навыков, поэтому я вынес класс тестового пользователя в отдельный пакет alice-tester, возможно кому-то пригодится.
Полный рабочий код примера из статьи я также выложил на GitHub. Можно склонировать репозиторий и поэкспериментировать.
Спасибо за внимание!