はじめに
Alexaスキルを実装するときにもテストしながら開発したい。
でも、実機で声を出しながら実装するのは流石に効率が悪い。
Alexaシミュレータを使うのも繰り返していると辛くなってくる。
コードを変更するたびに Lambda関数をデプロイするのも辛いし、Alexaシミュレータに発話を入力するのも面倒。
なにより、エラーになったときに発生箇所がわかりにくく、デバッグログを仕込んではCloudWatchLogsで確認するのが辛い。
ローカルでテストしながら開発できないものか。。。
ということで Jest + virtual-alexa でAlexaスキルをテストする方法を紹介する。
開発環境
最初に私の開発環境について、記述する。
AlexaスキルのバックエンドはLambdaを利用している。ランタイムはNode8.10。ローカルのテストにおいてもNode8.10を利用している。
- TypeScript 2.9
- Jest 23.4
- virtual-alexa 0.6.61
- ask-cli 1.4.1
Typescript でなくても構わないが、エディタによるコード補完など便利な面が多いので、
Babelでトランスパイルするくらいなら、Typescriptの利用をおすすめする。
セットアップ
モジュールのインストールを行う。
npm init
等でプロジェクトの初期化を行ったら、次のコマンドで
前節の4つと関連するモジュールをインストールする。
$ 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__ # テストコード
├── /config
├── index.ts # lambdaのハンドラーを実装している
├── /interaction_models # 対話モデルを保存する。後述。
├── jest.json
├── /node_modules
├── package-lock.json
├── package.json
├── /src # ソース
└── tsconfig.json
テスト対象のスキル
次のような簡単なやり取りを行うスキルをテストする例で説明する。
「今日の気分は絶好調」というフレーズは次の様に定義した FeelingIntentで受ける。
このインテントで使っている feeling スロットはカスタムスロットで次の様に定義している。
ポイントはIDを設定しているところ。このIDはインテントハンドラーで取得することができる。
テスト対象コード
まずは、このスキルのエンドポイントとなるLambdaのハンドラー関数。
後述する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 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
インスタンスでintend
、utter
などリクエストを発生させるメソッドを呼ぶと、ハンドラにリクエストを渡す前に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のテーブル名など環境変数で渡したい値と一緒に設定するコードをファイルに纏めて、setupFiles
をjest.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に喋らせる言い回しを変更するなどの工夫が最終的に必要になる。
-
npm install
でインストールすると 0.6.6がインストールされるが、npm update
すると0.6.11 になる。どうしてだろう? ↩