
Прошел почти год с того момента, как появилась возможность создавать свои навыки для Алисы — голосового помощника от Яндекса. В каталог ежедневно прибывают новые навыки, а их общее число составляет несколько сотен. К сожалению, общение с некоторыми навыками мягко говоря "не складывается". Навык или зацикливается на одной и той же фразе или вообще сломан и не отвечает.
В этой статье я рассмотрю написание функциональных автоматизированных тестов для навыка на 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. Можно склонировать репозиторий и поэкспериментировать.
Спасибо за внимание!