morishitaです。
「いこーよのおでかけナビ」の開発において、最も役立ったライブラリVirtualAlexaについて紹介したいと思います。
開発環境
このエントリでは次の環境を前提とします。
- Node.js v8.10.0
- Typescript 2.9以上 (3.0.0以上でも問題ないです)
Alexaスキル実装の辛み
Alexaスキルの開発コンソールにはAlexaシミュレータというツールが含まれていて、実機に近いE2Eテストが可能となっています。 Alexaシミュレータを利用したテスト/デバッグの開発では次のサイクルを繰り返すことになります。
便利は便利なのですが、繰り返すと次が辛くなってきます。
- Lambdaのビルド+デプロイに時間がかかる
- 実行後の確認が面倒
- どこでエラーが発生したのかわかりにくい
- Clowd Watch Logsでデバッグログを探すのが…
- UIの操作も面倒
- デバッガも使えないし…
ということで、ローカルで動かしてテストしたいなーと思い始めます。
最初に考えるのは次のようにテストしやすいLambdaのモジュール分割を行い、 LambdaやAlexaに依存しないビジネスロジックだけでもローカルでじっくりテストしようということです。
ただ、このやり方だと、テストの範囲が部分的になりますし、ビジネスロジック以外のところにバグがあれば 前述のAlexaシミュレータでのサイクルに戻ります。
やはり、ローカルでリクエストハンドラを実行したいと思い始めます。 Lambdaをローカルで動作させる方法はあるのですが、多いのがAPI GatewayのLambdaをローカルでテスト する方法で、実際にはローカルで動くAPI Gatewayの模擬環境にHTTPリクエストを送って動かすというものです。
そのために、API Gatewayを設定するのもなー、やったとしてもAlexaが送ってくるJSONを組み立てるのは 自前でやらないといけないしなー、面倒だなーと思っていた私を救ってくれたのは VirtualAlexaでした。
VirtualAlexa ってなに?
VirtualAlexa は Bespoken社によって開発されている 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
テスト対象のサンプルスキル
テスト対象として次のような簡単なやり取りを行うスキルを考えます。
FeelingIntent
「今日の気分は絶好調」というフレーズは次の様に定義した FeelingIntentで受けます。
このインテントで使っている feeling スロットはカスタムスロットで次の様に定義しています。 ポイントはIDを設定しているところ。このIDはリクエストハンドラで取得できます。
テスト対象コード
上記のスキルのバックエンド側のコードを説明します。
まずは、このスキルのエンドポイントとなる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
インスタンスでintend
、utter
などリクエストを発生させるメソッドを呼ぶ度に
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のテーブル名など環境変数で渡したい値と一緒に設定するコードをファイルに纏めて、
setupFiles
をjest.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インターフェースサポートを追加
後は、intend
やutter
でリクエストを実行すれば、Dipslayインタフェースサポートされている場合のテストを行えます。
また、ListTemplateやアクションリンクを実装すると、Display.ElementSelected
リクエストを受ける必要がありますが、そのテストもサポートしています。
次のようにselectElement
を実行するとDisplay.ElementSelected
リクエストがハンドラに送られます。
引数にはtokenの値を渡します。
const response = await alexa.selectElement('token') as SkillResponse;
デバッガも使える
VSCodeのデバッグ機能からテストを実行すれば、ブレークポイントで止めてステップ実行も可能です! バグを追う際の強い味方になってくれます。
実機テストも重要
VirtualAlexaを使えば、Alexaスキルをテストドリブンに開発しやすく、捗ります。
しかし、VirtualAlexa でテストできるのは次の2点に過ぎません。
- 対話モデルが仕様どおり定義されているか否か
- その対話モデルに対応するLambda関数がプログラムとして意図した入力に対し意図した出力をするかどうか。
これらの前段にあるAlexaがユーザの発話を意図通り聞き取り、適切なインテントにルーティングするかはテストできません。
現状では、思ったように聞き取ってくれなかったり、別のインテントにリクエストを送ってきたりすることは往々にして起こります。 Alexaシミュレータでもある程度テストは可能なのですが、マイクの特性まではシミュレートできないのでリリース前には 実機テストしておくのが安心です。
また、Alexaがしゃべるセリフの区切りやイントネーションに違和感を感じることもあります。 実機での動作確認を通してそれらを検出し次の対応が必要になります。
- サンプル発話を増やす
- Alexaが聞き取りやすい発話にユーザを誘導する
- Alexaに喋らせる言い回しを変更する。
まとめ
- virtual-alexa を使えば、Alexaスキル用のLambda関数をローカルで簡単に実行できます。
- Jestと組み合わせて使えば自動テストが実装できます。
- 対話モデルも含めたテストが可能です。
- DynamoDBを使うスキルもモックでテストが可能です。
- 実機テストも重要
最後に
アクトインディではテストしてこそプロダクト、 テストドリブン開発大好き!というエンジニアを募集しています。