アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

VirtualAlexaによるテストドリブンなAlexaスキル開発

morishitaです。

「いこーよのおでかけナビ」の開発において、最も役立ったライブラリVirtualAlexaについて紹介したいと思います。

いこーよのおでかけナビ

いこーよのおでかけナビ

  • 発売日: 2019/08/27
  • メディア: アプリ

開発環境

このエントリでは次の環境を前提とします。

  • Node.js v8.10.0
  • Typescript 2.9以上 (3.0.0以上でも問題ないです)

Alexaスキル実装の辛み

Alexaスキルの開発コンソールにはAlexaシミュレータというツールが含まれていて、実機に近いE2Eテストが可能となっています。 Alexaシミュレータを利用したテスト/デバッグの開発では次のサイクルを繰り返すことになります。

f:id:HeRo:20180902154051p:plain

便利は便利なのですが、繰り返すと次が辛くなってきます。

  • Lambdaのビルド+デプロイに時間がかかる
  • 実行後の確認が面倒
    • どこでエラーが発生したのかわかりにくい
    • Clowd Watch Logsでデバッグログを探すのが…
  • UIの操作も面倒
  • デバッガも使えないし…

ということで、ローカルで動かしてテストしたいなーと思い始めます。

最初に考えるのは次のようにテストしやすいLambdaのモジュール分割を行い、 LambdaやAlexaに依存しないビジネスロジックだけでもローカルでじっくりテストしようということです。

f:id:HeRo:20180831093054p:plain

ただ、このやり方だと、テストの範囲が部分的になりますし、ビジネスロジック以外のところにバグがあれば 前述のAlexaシミュレータでのサイクルに戻ります。

やはり、ローカルでリクエストハンドラを実行したいと思い始めます。 Lambdaをローカルで動作させる方法はあるのですが、多いのがAPI GatewayのLambdaをローカルでテスト する方法で、実際にはローカルで動くAPI Gatewayの模擬環境にHTTPリクエストを送って動かすというものです。

そのために、API Gatewayを設定するのもなー、やったとしてもAlexaが送ってくるJSONを組み立てるのは 自前でやらないといけないしなー、面倒だなーと思っていた私を救ってくれたのは VirtualAlexaでした。

VirtualAlexa ってなに?

f:id:HeRo:20180831083018p:plain

VirtualAlexaBespoken社によって開発されている AlexaスキルのバックエンドとなるLambda関数をローカルで動かしてテストするためのライブラリです。 これを使えば難なくローカルで実行しテストできます。

次のような特徴があります。

  • ユーザの発話やインテントを指定してリクエストハンドラを動かすことができる。
    • つまり、Alexaから送られてくるJSONを作ってくれる
  • リクエストをLambdaに渡す前に書き換えられるフィルタが便利
    • 直前の対話の状態に依存したテストも書きやすい
  • DynamoDBやAddressAPIに依存したテストも可能
    • モックが含まれている
  • Display Interfaceもサポート
    • Echo Spot向けのレスポンスもテストできる

インストール

ここからはテスティングフレームワークJestとの組み合わせでVirtualAlexaを利用する方法を説明します。

必要なモジュールを次のコマンドでモジュールのインストールを行います。

$ npm install -D typescript jest ts-jest virtual-alexa @types/jest @types/node ask-cli

Typescriptの設定は次の通り。

{
  "compilerOptions": {
    "sourceMap": true,
    "target": "es6",
    "lib": [
      "esnext"
    ],
    "moduleResolution": "node"
  },
  "exclude": [
    "node_modules"
  ]
}

そして、Jestの設定は次の通り。Typescript のトランスパイルはts-jestで行います。

{
  "globals": {
    "ts-jest": { "tsConfigFile": "./tsconfig.json" }
  },
  "transform": {
    "^.+\\.tsx?$": "ts-jest"
  },
  "testEnvironment": "node",
  "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
  "moduleFileExtensions": [
    "ts",
    "tsx",
    "js",
    "jsx",
    "json"
  ]
}

全体のファイル構成はこんな感じ。

.
├── /__tests__          # テストコードのディレクトリ
├── index.ts            # lambdaのハンドラーを実装している
├── /interaction_models # 対話モデルを保存する。後述。
├── jest.json 
├── /node_modules
├── package-lock.json
├── package.json
├── /src                # ソース
└── tsconfig.json

テスト対象のサンプルスキル

テスト対象として次のような簡単なやり取りを行うスキルを考えます。

f:id:HeRo:20180901233854p:plain
スキルの会話

FeelingIntent

「今日の気分は絶好調」というフレーズは次の様に定義した FeelingIntentで受けます。

f:id:HeRo:20180901234107p:plain
FeelintIntent

このインテントで使っている feeling スロットはカスタムスロットで次の様に定義しています。 ポイントはIDを設定しているところ。このIDはリクエストハンドラで取得できます。

f:id:HeRo:20180901234713p:plain

テスト対象コード

上記のスキルのバックエンド側のコードを説明します。

まずは、このスキルのエンドポイントとなるLambdaのハンドラー関数index.ts。 後述するFeelingIntentHandlerと、標準インテントのリクエストハンドラを設定しています。

import { HandlerInput, SkillBuilders } from "ask-sdk";
import { Handler } from "aws-lambda";

import CancelAndStopIntentHandler from "./src/handlers/CancelAndStopIntentHandler";
import ErrorHandler from "./src/handlers/ErrorHandler";
import FeelingIntentHandler from "./src/handlers/FeelingIntentHandler";
import HelpIntentHandler from "./src/handlers/HelpIntentHandler";
import LaunchRequestHandler from "./src/handlers/LaunchRequestHandler";

export const handler: Handler = SkillBuilders.standard()
    .addRequestHandlers(
      LaunchRequestHandler,
      FeelingIntentHandler,
      HelpIntentHandler,
      CancelAndStopIntentHandler,
    )
    .addErrorHandlers(ErrorHandler)
    // .withTableName(process.env.DYNAMODB_TABLE) // uncomment if you use dynamodb
    // .withAutoCreateTable(true)
    .lambda();

そしてテスト対象のFeelingIntentHandlerのコードは次の通りです。 注目ポイントはカスタムスロットのIDで処理を分岐させているところです。

import { HandlerInput, RequestHandler } from "ask-sdk";
import { IntentRequest, Response, Slot } from "ask-sdk-model";
import * as Speech from "ssml-builder";

const FeelingIntentHandler: RequestHandler = {
  canHandle(handlerInput: HandlerInput): boolean {
    return handlerInput.requestEnvelope.request.type === "IntentRequest"
        && handlerInput.requestEnvelope.request.intent.name === "FeelingIntent";
  },
  handle(handlerInput: HandlerInput): Response {
    const request = handlerInput.requestEnvelope.request as IntentRequest;
    const slot = request.intent.slots && request.intent.slots.feeling;
    let speech: string;
    const reprompt: string = "今日の気分を教えてください";
    if (slot.value) {
      const feelingId = slot.resolutions.resolutionsPerAuthority[0].values[0] &&
        slot.resolutions.resolutionsPerAuthority[0].values[0].value.id;
      switch (feelingId) {
        case "0":
          speech = "まあ、元気だして。くよくよせずに行きましょう。";
          break;
        case "5":
          speech = "いつもどおりで行きましょう。";
          break;
        case "8":
          speech = "そんなときは思い切って行動しましょう。";
          break;
        case "10":
          speech = "素晴らしい!張り切っていきましょう!";
          break;
        default:
          speech = reprompt;
          break;
      }
    } else {
      speech = "そんな日もありますよね";
    }

    return handlerInput.responseBuilder
      .speak(speech)
      .reprompt(reprompt)
      .getResponse();
  },
};

export default FeelingIntentHandler;

Jest + VirtualAlexa によるテストコード

そして、本題のテストコードは次の通り。

import {SkillResponse, VirtualAlexa} from "virtual-alexa";
import { handler } from "../../index";

// ポイント(1) VirtualAlexaのインスタンス生成
const alexa = VirtualAlexa.Builder()
  .handler(handler) // Lambdaハンドラを指定
  .interactionModelFile("./interaction_models/model_ja-JP.json") // 対話モデルを指定
  .create();

describe("FeelingIntent", () => {
  it ("よくわかんない", async () => {
    // ポイント(2) スロットのないサンプル発話のテスト
    const response = await alexa.utter("よくわかんない") as SkillResponse;
    expect(response.prompt()).toContain("そんな日もありますよね");
  });

  it ("気分は絶好調", async () => {
    // ポイント(3) スロットを含むサンプル発話のテスト
    const response = await alexa.intend("FeelingIntent", { feeling: "絶好調"}) as SkillResponse;
    expect(response.prompt()).toContain("素晴らしい!");
  });

  it ("気分は最悪", async () => {
    // ポイント(4) スロットのシノニムによるテスト
    const response = await alexa.intend("FeelingIntent", { feeling: "最悪"}) as SkillResponse;
    expect(response.prompt()).toContain("元気だして。");
  });

  it ("気分はイマイチ", async () => {
    // ポイント(5) カスタムスロットにない値のテスト
    const response = await alexa.intend("FeelingIntent", { feeling: "イマイチ"}) as SkillResponse;
    expect(response.prompt()).toContain("今日の気分を教えてください");
  });
});

ポイント(1)VirtualAlexaのインスタンス生成

インスタンス生成時に、テスト対象のスキルのLambda関数と対話モデルのJSONを読み込ませています。 この対話モデルのJSONは ask-cliの api get-modelコマンドで取得できるJSONをそのままファイルに保存したものです。

ポイント(2)スロットのないサンプル発話のテスト

まずはスロットを含まないフレーズのテスト。この場合は VirtualAlexa#utterメソッドでテストできます。 response にはAlexaに送るレスポンスのJSONがそのまま入ってくるので、 そこからresponse.prompt()outputSpeechなどを取り出して内容をチェックすることでテストできます。

ポイント(3)スロットを含むサンプル発話のテスト

次に、スロットを含む発話のテスト。流石にフレーズそのままを解釈してくれはしないので、 VirtualAlexa#intendメソッドにインテント名とスロットの値を設定してテストします。 responseからoutputSpeechを取り出して期待するセリフが入っているかを確認します。 ここで注目してほしいのは、テスト対象の FeelingIntentHandlerでは "絶好調"という スロットの値を取り出すのではなく、スロットの値に設定されたIDを取り出して処理を分岐させていたというところ。

一方、テストではIDは渡さず、スロットの値として"絶好調"という文字列を渡しています。 インスタンス生成時に対話モデルを読み込んでいるので、VirtualAlexaがスロットとIDの対応がわかっておりテストが可能になっています。

ポイント(4)スロットのシノニムによるテスト

スロットの値としてシノニムの文字列を与えた場合もちゃんとテストできます。 これも、VirtualAlexaが対話モデルを元にリクエストを生成してくれているからテストできるのだとわかります。

ポイント(5)カスタムスロットにない値のテスト

カスタムスロットに定義していない言葉をスロット値として渡すと、当然ながらIDとの対応が取れずもう一度聞き直す流れとなりますが、それもテストできます。

その他

filter

1つ前のやり取りを受けて、返答を変えるなど、ステートフルな実装が必要になるケースがあります。 例えば、セッション属性の値に応じて応答を変更するには、VirtualALexa#intentなどリクエストを発生させるメソッドを実行する前にセッション属性の値を設定したくなります。 そのようなテストも filterメソッドで設定すれば可能となります。

virtual-alexaのインスタンスを生成した後、filterメソッドにリクエストを書き換えるコールバック関数を渡します。 コールバック関数にはrequestEnvelopeに相当するオブジェクトが渡されるので、 その中でセッション属性を設定すれば、セッション属性に応じたハンドラの動作をテストできます。

次の様に、beforeEachなどでfilterメソッドを呼びます。

describe("SomeHandler", () => {
  let alexa;

  beforeEach(() => {
    const alexa = VirtualAlexa.Builder()
      .locale("ja-JP")
      .handler(handler)
      .interactionModelFile(modelJson)
      .applicationID(process.env.APP_ID)
      .create();
    alexa.filter((requestEnvelope) => {  // <= filter
      requestEnvelope.session.attributes = { someAttributes: "value" }  
    });
  });
  it ("doSomething", async () => {
    const response = await alexa.intend("SomeHandler") as SkillResponse;
    // response をアサーションするコード
  });
});

一旦、filterを設定すると、そのvirtual-alexaインスタンスでintendutterなどリクエストを発生させるメソッドを呼ぶ度に filterに渡したコールバック関数が実行されるので注意が必要です。

DynamoDBのモック

VirtualAlexaにはDynamoDBのモックも含まれています。

VirtualAlexaのインスタンスを生成した後、alexa.dynamoDB().mock()を実行することでモックされるようになります。

const alexa = VirtualAlexa.Builder()
  .handler(handler)
  .interactionModelFile(modelJson)
  .create();
alexa.dynamoDB().mock(); // <= DynamoDBのモック化

多分、不具合だと思うのだけど、次のように環境変数にAWSのリージョンを設定しておかないと接続時にエラーとなりました。

process.env.AWS_REGION = 'ap-northeast-1';

いこナビではDBのテーブル名など環境変数で渡したい値と一緒に設定するコードをファイルに纏めて、 setupFilesjest.jsonに追加してテストの初期化時に設定しています。

モック生成後は普通にDynamoDBにアクセスすればモックにつながるので、 初期データを BeforeEachで作ればDBの状態に応じた挙動のテストも可能です。

Displayインタフェース関連のテスト

VirtualAlexaはDisplayインタフェース特有の実装のテストにも対応しています。 次の様にすると、リクエストにDisplayインタフェースサポートが追加されます。

const alexa = VirtualAlexa.Builder()
      .locale("ja-JP")
      .handler(handler)
      .interactionModelFile(modelJson)
      .applicationID(process.env.APP_ID)
      .create();
alexa.context().device().displaySupported(true); // <= Dipslayインターフェースサポートを追加

後は、intendutterでリクエストを実行すれば、Dipslayインタフェースサポートされている場合のテストを行えます。

また、ListTemplateやアクションリンクを実装すると、Display.ElementSelectedリクエストを受ける必要がありますが、そのテストもサポートしています。

次のようにselectElementを実行するとDisplay.ElementSelectedリクエストがハンドラに送られます。 引数にはtokenの値を渡します。

const response = await alexa.selectElement('token') as SkillResponse;

デバッガも使える

VSCodeのデバッグ機能からテストを実行すれば、ブレークポイントで止めてステップ実行も可能です! バグを追う際の強い味方になってくれます。

f:id:HeRo:20180905234652p:plain

実機テストも重要

VirtualAlexaを使えば、Alexaスキルをテストドリブンに開発しやすく、捗ります。

しかし、VirtualAlexa でテストできるのは次の2点に過ぎません。

  • 対話モデルが仕様どおり定義されているか否か
  • その対話モデルに対応するLambda関数がプログラムとして意図した入力に対し意図した出力をするかどうか。

これらの前段にあるAlexaがユーザの発話を意図通り聞き取り、適切なインテントにルーティングするかはテストできません。

現状では、思ったように聞き取ってくれなかったり、別のインテントにリクエストを送ってきたりすることは往々にして起こります。 Alexaシミュレータでもある程度テストは可能なのですが、マイクの特性まではシミュレートできないのでリリース前には 実機テストしておくのが安心です。

また、Alexaがしゃべるセリフの区切りやイントネーションに違和感を感じることもあります。 実機での動作確認を通してそれらを検出し次の対応が必要になります。

  • サンプル発話を増やす
  • Alexaが聞き取りやすい発話にユーザを誘導する
  • Alexaに喋らせる言い回しを変更する。

まとめ

  • virtual-alexa を使えば、Alexaスキル用のLambda関数をローカルで簡単に実行できます。
  • Jestと組み合わせて使えば自動テストが実装できます。
  • 対話モデルも含めたテストが可能です。
  • DynamoDBを使うスキルもモックでテストが可能です。
  • 実機テストも重要

最後に

アクトインディではテストしてこそプロダクト、 テストドリブン開発大好き!というエンジニアを募集しています。