16
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

テストドリブンなAlexaスキルの開発

Last updated at Posted at 2018-07-26

はじめに

Alexaスキルを実装するときにもテストしながら開発したい。

でも、実機で声を出しながら実装するのは流石に効率が悪い。
Alexaシミュレータを使うのも繰り返していると辛くなってくる。
コードを変更するたびに Lambda関数をデプロイするのも辛いし、Alexaシミュレータに発話を入力するのも面倒。
なにより、エラーになったときに発生箇所がわかりにくく、デバッグログを仕込んではCloudWatchLogsで確認するのが辛い。

ローカルでテストしながら開発できないものか。。。

ということで Jest + virtual-alexa でAlexaスキルをテストする方法を紹介する。

開発環境

最初に私の開発環境について、記述する。
AlexaスキルのバックエンドはLambdaを利用している。ランタイムはNode8.10。ローカルのテストにおいてもNode8.10を利用している。

Typescript でなくても構わないが、エディタによるコード補完など便利な面が多いので、
Babelでトランスパイルするくらいなら、Typescriptの利用をおすすめする。

セットアップ

モジュールのインストールを行う。
npm init等でプロジェクトの初期化を行ったら、次のコマンドで
前節の4つと関連するモジュールをインストールする。

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

Typescriptの設定は次の通り。

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

そして、Jestの設定は次の通り。Typescriptのトランスパイルはts-jestでおこなう。

jest.json
{
  "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__          # テストコード
├── /config
├── index.ts            # lambdaのハンドラーを実装している
├── /interaction_models # 対話モデルを保存する。後述。
├── jest.json 
├── /node_modules
├── package-lock.json
├── package.json
├── /src                # ソース
└── tsconfig.json

テスト対象のスキル

次のような簡単なやり取りを行うスキルをテストする例で説明する。

image.png

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

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

slot.png

テスト対象コード

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

index.ts
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 HelloIntentHandler from "./src/handlers/HelloIntentHandler";
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のコードは次の通り。
ssml-builderというライブラリでアウトプットスピーチを作っているが、SSML付きのテキストを生成しているだけで、本題からそれるのでそこはスルーで。
注目ポイントはカスタムスロットの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 = new Speech().say("今日の気分を教えてください").ssml();
    if (slot.value) {
      const feelingId = slot.resolutions.resolutionsPerAuthority[0].values[0] &&
        slot.resolutions.resolutionsPerAuthority[0].values[0].value.id;
      switch (feelingId) {
        case "0":
          speech = new Speech().say("まあ、元気だして。くよくよせずに行きましょう。").ssml();
          break;
        case "5":
          speech = new Speech().say("いつもどおりで行きましょう。").ssml();
          break;
        case "8":
          speech = new Speech().say("そんなときは思い切って行動しましょう。").ssml();
          break;
        case "10":
          speech = new Speech().emphasis("strong", "素晴らしい!").say("張り切っていきましょう!").ssml();
          break;
        default:
          speech = reprompt;
          break;
      }
    } else {
      speech = "そんな日もありますよね";
    }

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

export default FeelingIntentHandler;

テスト

VirtualAlexa について

VirtualAlexa は Bespoken社によって開発されているAlexaをローカルで動かしてテストするためのライブラリ。
(Alexa以外でも)Lambdaの関数をローカルで実行するには、EventやContexを用意するのが結構面倒なのだが、VirtualAlexaはAlexaから送信されてくるデータに相当するデータを生成してLambdaを実行してくれる。

リクエストをLambdaにわたす前に書き換えられるフィルタや、DynamoDBやAddressAPIのモックを含んでおり、一通りのスキルの実行ができる。(List APIのモックもサポートする予定の様子。)

Display InterfaceもサポートしているのでEcho Spot向けのスキルのテストにも使えそうだ。

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 にはLambdaからのレスポンスのJSONがそのまま入ってくるので、そこからoutputSpeechなどを取り出して内容をチェックすることでテストできる。

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

次に、スロットを含む発話のテスト。流石にフレーズそのままを解釈してくれはしないので、VirtualAlexa#intendメソッドにインテント名とスロットの値を設定してテストする。
responseからoutputSpeechを取り出して期待するセリフが入っているかを確認する。
ここで注目してほしいのは、テスト対象の FeelingIntentHandlerでは "絶好調"というスロットの値を取り出すのではなく、スロットの値に設定されたIDを取り出して処理を分岐させていた。
一方、テストではIDは渡さず、スロットの値として"絶好調"という文字列を渡している。
インスタンス生成時に対話モデルを読み込んでいるので、VirtualAlexaがスロットとIDの対応がわかっているのでテストが可能になっている。

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

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

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

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

その他

filter

一つ前のやり取りを受けて、返答を変えるなど、ステートフルな実装が必要になるケースがある。
例えば、セッション属性の値に応じて応答を変更するには、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();

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

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

他にも、DBのテーブル名など環境変数で渡したい値と一緒に設定するコードをファイルに纏めて、setupFilesjest.json追加して、テスト開始時に設定してしまうのが良いと思う。

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

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

virtual-alexaは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インターフェースサポートを追加

後は、intendでもutterでもリクエストを実行すれば、Dipslayインターフェースサポートされている場合のテストを行うことが可能だ。

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

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

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

まとめ

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

最後に重要なこと

実機での動作確認は重要だということだ。

virtual-alexa でテストできるのは次の2点に過ぎない。

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

これらの前段にあるAlexaがユーザの発話を意図通り聞き取り、適切なインテントにルーティングするかはテストできない。
現状では、思ったように聞き取ってくれなかったり、別のインテントにリクエストを送ってきたりすることは往々にして起こる。
Alexaがしゃべるセリフの区切りやイントネーションに違和感を感じることもある。
実機での動作確認を通してこれらを検出し、サンプル発話を増やしたり、Alexaが聞き取りやすい発話にユーザを誘導したり、Alexaに喋らせる言い回しを変更するなどの工夫が最終的に必要になる。

  1. npm install でインストールすると 0.6.6がインストールされるが、npm updateすると0.6.11 になる。どうしてだろう?

16
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?