[Alexa] Virtual Alexa + Mocha を使ってスキルの会話を自動テストする

はじめに

Alexaのスキル開発をしていると、実機やテストシミュレータを使ったユーザーテストだけではだんだんと辛くなってくるケースがあります。
できれば、スキルで行われる会話についてもローカルで自動テストをしたいところです。

今回は、Virtual Alexaというツールを使ってAlexaスキルの会話テストをローカルで行う方法をご紹介します。

Virtual Alexaとは

Bespoken Toolsなどを提供するBespoken社による、Alexaスキルのテストやデバッグを行えるライブラリです。

NPM - virtual-alexa

Virtual Alexaを使うことで、バックエンドLambdaのハンドラについてのテストやダイアログモデルのテストを簡単に書くことできます。

検証環境

  • Node.js 8.10
  • ASK-SDK 2.5.0
  • Yarn 1.13.0
  • Virtual Alexa 0.7.2
  • Mocha 6.0.2
  • Chai 4.2.0

今回のコードは以下に置いています。

https://github.com/amotz/virtual-alexa-sample

スキルの準備

テストを行うために、飲み物を注文する簡単なスキルを準備します。

対話モデルは以下のような感じになります。
Virtual Alexaでは対話モデルのJSONファイルを参照するため、開発者コンソールやCLI経由で対話モデルのJSONファイルを取得し、ローカルに置いておきます。

{
    "interactionModel": {
        "languageModel": {
            "invocationName": "喫茶店",
            "intents": [
                {
                    "name": "AMAZON.CancelIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.HelpIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.StopIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.NavigateHomeIntent",
                    "samples": []
                },
                {
                    "name": "OrderIntent",
                    "slots": [
                        {
                            "name": "drink",
                            "type": "Drink",
                            "samples": [
                                "{drink}"
                            ]
                        },
                        {
                            "name": "amount",
                            "type": "AMAZON.NUMBER",
                            "samples": [
                                "{amount}"
                            ]
                        }
                    ],
                    "samples": [
                        "{drink}",
                        "{drink} お願いします",
                        "{drink} を {amount} 個ください",
                        "{drink} を {amount}",
                        "オーダー",
                        "注文をお願いします"
                    ]
                }
            ],
            "types": [
                {
                    "name": "Drink",
                    "values": [
                        {
                            "name": {
                                "value": "クリームソーダ"
                            }
                        },
                        {
                            "name": {
                                "value": "コーラ"
                            }
                        },
                        {
                            "name": {
                                "value": "水"
                            }
                        },
                        {
                            "name": {
                                "value": "ココア"
                            }
                        },
                        {
                            "name": {
                                "value": "紅茶"
                            }
                        },
                        {
                            "name": {
                                "value": "コーヒー"
                            }
                        }
                    ]
                }
            ]
        },
        "dialog": {
            "intents": [
                {
                    "name": "OrderIntent",
                    "delegationStrategy": "ALWAYS",
                    "confirmationRequired": false,
                    "prompts": {},
                    "slots": [
                        {
                            "name": "drink",
                            "type": "Drink",
                            "confirmationRequired": false,
                            "elicitationRequired": true,
                            "prompts": {
                                "elicitation": "Elicit.Slot.1410164654225.923409109574"
                            },
                            "validations": [
                                {
                                    "type": "isNotInSet",
                                    "prompt": "Slot.Validation.1410164654225.923409109574.234254136290",
                                    "values": [
                                        "クリームソーダ"
                                    ]
                                },
                                {
                                    "type": "hasEntityResolutionMatch",
                                    "prompt": "Slot.Validation.798652510671.792419756936.43314887853"
                                }
                            ]
                        },
                        {
                            "name": "amount",
                            "type": "AMAZON.NUMBER",
                            "confirmationRequired": false,
                            "elicitationRequired": true,
                            "prompts": {
                                "elicitation": "Elicit.Slot.1410164654225.245831103035"
                            },
                            "validations": [
                                {
                                    "type": "isGreaterThanOrEqualTo",
                                    "prompt": "Slot.Validation.204705012182.856565758777.521580440836",
                                    "value": "1"
                                }
                            ]
                        }
                    ]
                }
            ],
            "delegationStrategy": "ALWAYS"
        },
        "prompts": [
            {
                "id": "Elicit.Slot.1410164654225.923409109574",
                "variations": [
                    {
                        "type": "PlainText",
                        "value": "ご注文の商品はいかがいたしましょう?"
                    }
                ]
            },
            {
                "id": "Slot.Validation.1410164654225.923409109574.234254136290",
                "variations": [
                    {
                        "type": "PlainText",
                        "value": "ごめんなさい。 {drink} は夏季限定なんです。他の商品でお願いします。"
                    }
                ]
            },
            {
                "id": "Elicit.Slot.1410164654225.245831103035",
                "variations": [
                    {
                        "type": "PlainText",
                        "value": "{drink} をいくつでしょうか?"
                    }
                ]
            },
            {
                "id": "Confirm.Intent.1410164654225",
                "variations": [
                    {
                        "type": "PlainText",
                        "value": "{drink} を {amount} 個でよろしいですか?"
                    }
                ]
            },
            {
                "id": "Slot.Validation.204705012182.856565758777.521580440836",
                "variations": [
                    {
                        "type": "PlainText",
                        "value": "すみません。うまく聞き取れませんでした。いくつご注文ですか?"
                    }
                ]
            },
            {
                "id": "Slot.Validation.798652510671.792419756936.43314887853",
                "variations": [
                    {
                        "type": "PlainText",
                        "value": "ごめんなさい。うちには {drink} はないんです。他の商品でお願いします。"
                    }
                ]
            }
        ]
    }
}

バックエンドのLambda(Node.js 8.10)は以下のような感じです。
Virtual AlexaはASK-SDKモジュールも必要とするので、事前にインストールしておきましょう。

'use strict';

const Alexa = require('ask-sdk-core');

const LaunchRequestHandler = {
  canHandle(handlerInput) {
    return handlerInput.requestEnvelope.request.type === 'LaunchRequest';
  },
  handle(handlerInput) {
    const speechText = '喫茶店へようこそ。ご注文は何になさいますか?';
    return handlerInput.responseBuilder
      .speak(speechText)
      .reprompt(speechText)
      .getResponse();
  }
};
const OrderIntentHandler = {
  canHandle(handlerInput) {
    return handlerInput.requestEnvelope.request.type === 'IntentRequest'
      && handlerInput.requestEnvelope.request.intent.name === 'OrderIntent';
  },
  handle(handlerInput) {
    const intent = handlerInput.requestEnvelope.request.intent;
    const drink = intent.slots.drink.value;
    const amount = intent.slots.amount.value;

    let speechOutput = drink + 'を' + amount + 'つですね。少々お待ちください。';

    return handlerInput.responseBuilder
      .speak(speechOutput)
      .withShouldEndSession(true)
      .getResponse();
  }
};
const HelpIntentHandler = {
  canHandle(handlerInput) {
    return handlerInput.requestEnvelope.request.type === 'IntentRequest'
      && handlerInput.requestEnvelope.request.intent.name === 'AMAZON.HelpIntent';
  },
  handle(handlerInput) {
    const speechText = 'このスキルでは、飲み物をオーダーすることができます。ご注文はいかが致しましょう?';

    return handlerInput.responseBuilder
      .speak(speechText)
      .reprompt(speechText)
      .getResponse();
  }
};
const CancelAndStopIntentHandler = {
  canHandle(handlerInput) {
    return handlerInput.requestEnvelope.request.type === 'IntentRequest'
      && (handlerInput.requestEnvelope.request.intent.name === 'AMAZON.CancelIntent'
        || handlerInput.requestEnvelope.request.intent.name === 'AMAZON.StopIntent');
  },
  handle(handlerInput) {
    const speechText = 'ご利用ありがとうございました。';
    return handlerInput.responseBuilder
      .speak(speechText)
      .getResponse();
  }
};
const SessionEndedRequestHandler = {
  canHandle(handlerInput) {
    return handlerInput.requestEnvelope.request.type === 'SessionEndedRequest';
  },
  handle(handlerInput) {
    return handlerInput.responseBuilder.getResponse();
  }
};

const FallbackHandler = {
  canHandle(handlerInput) { return true; },
  handle(handlerInput) {
    const speechText = 'ごめんなさい。うまく聞き取れなかったのでもう一度お願いします。';

    return h.responseBuilder
      .speak(speechText)
      .reprompt(speechText)
      .getResponse();
  },
};

const ErrorHandler = {
  canHandle() { return true; },
  handle(handlerInput, error) {
    console.log(`~~~~ Error handled: ${error.message}`);
    const speechText = 'すみません。なんだかうまくいかないみたいです。しばらくしてからまた呼んでください。';

    return handlerInput.responseBuilder
      .speak(speechText)
      .reprompt(speechText)
      .getResponse();
  }
};

exports.handler = Alexa.SkillBuilders.custom()
  .addRequestHandlers(
    LaunchRequestHandler,
    OrderIntentHandler,
    HelpIntentHandler,
    CancelAndStopIntentHandler,
    SessionEndedRequestHandler,
    FallbackHandler
  )
  .addErrorHandlers(
    ErrorHandler)
  .lambda();

Virtual Alexaのインストール

それでは、実際にVirtual Alexaを導入していきます。
今回はテストフレームワークにMocha、アサーションにはChaiを使います。

以下を実行してインストール。

$ yarn add --dev virtual-alexa mocha chai

テストコード

環境が整ったので、テストコードを書いてみます。

'use strict';

const va = require("virtual-alexa");
const expect = require('chai').expect;

const model = "./models/ja-JP.json";
const handler = "./lambda/custom/index.js";

const alexa = va.VirtualAlexa.Builder()
  .handler(handler)
  .interactionModelFile(model)
  .create();

describe('スキルのE2E会話テスト', () => {
  it('スキル起動時のウェルカムメッセージ', async () => {
    const response = await alexa.launch();
    expect(response.prompt()).to.include('喫茶店へようこそ。ご注文は何になさいますか?');
  });
  it('スキルのヘルプ', async () => {
    const response = await alexa.intend('AMAZON.HelpIntent');
    expect(response.prompt()).to.include('このスキルでは、飲み物をオーダーすることができます。ご注文はいかが致しましょう?');
  });
  it('ダイアログモデルによる飲み物のオーダー', async () => {
    const request = await alexa.request()
      .intent("OrderIntent")
      .slot("drink", "コーヒー")
      .slot("amount", 2)
      .dialogState("COMPLETED")
    const response = await request.send();
    expect(response.prompt()).to.include('コーヒーを2つですね。少々お待ちください。');
  });
});

まずVirtualAlexaのインスタンス生成時にLambdaハンドラのjsファイルと対話モデルのJSONファイルを指定しています。

スキル起動時のウェルカムメッセージテストでは、スキルを起動したときにどのような発話がされるか、をテストしています。
また、スキルのヘルプテストでは、受け取ったインテントがAMAZON.HelpIntentだった場合にどのような発話がされるか、をテストしています。

ダイアログモデルによる飲み物のオーダーテストでは、ダイアログモデルのテストを行っています。
Virtual Alexaのrequestメソッドでインテントやスロット、ダイアログステートを直接指定することができるので、ダイアログモデルのテストも可能です。
ここでは、ダイアログがスロット値を収集後completedになった場合にどのような発話がされるか、をテストしています。

テストの実行

コードが書けたので、テストを実行してみます。

$ mocha test/e2e.test.js

  スキルのE2E会話テスト
    ✓ スキル起動時のウェルカムメッセージ
    ✓ スキルのヘルプ
    ✓ ダイアログモデルによる飲み物のオーダー

  3 passing (51ms)

すべてのテストが無事にパスし、オールグリーンになっていますね。

おわりに

Virtual Alexaを使ってAlexaスキルの会話テストを自動化する方法をご紹介しました。
導入のハードルも低く、既に開発中のスキルにも適用しやすいですね。
今回は簡単なスキルの例でしたが、Virtual AlexaではDynamoDBやAddressAPIのモックを利用することもできます。

効率的なスキル開発の参考になれば幸いです。