Alexaスキルのテストを自動化するASTD (後編)

はじめに

前編ではHello Worldスキルをサンプルとして、ASTDによるテストの自動化について簡単に見てきました。

後編ではASTDのテストデータの定義方法や、ASTDによるテストの実行方法について説明します。

テストの対象

テストの対象は、ユーザーとスキルの対話です。マルチターンの会話をテストすることができます。スキルの呼び出しは、Alexaサービスを通して行われるため、対話モデルの定義も含めて、スキルの動作を検証することができます。

テストデータの構造

テストデータは下記のような構造をしています。

TestData
    TestCase[]
        TestTurn[]
  • TestTurn – ユーザーとスキルとの一往復分の対話を表します。
  • TestCase – ユーザーとスキルの一連の対話による、1つのセッションを表します。複数のターンを含めることができます。
  • TestData – 複数のTestCaseをまとめます。

例としてHello Worldスキルのテストデータに当てはめると下図のようになります。会話のフローに応じて2つのテストケースが定義されており、それぞれ2つのテストターンを持っています。

同じくテスト結果に当てはめると下図のようになります。テストデータに対応して、ターンごとにスキルへの発話とスキルからの応答が示されています。また各ターンで受け取った応答の検証結果が示されています。

テスト結果には、ファイル名、テストケース、ターン番号が含まれています。このためエラーの発生時には、どのファイルの、どのテストケースの、何番目のターンでエラーが検出されたのか、容易に特定できるようになっています。

テストファイルの構成

テストデータはMochaによって読み込まれ実行されます。Mochaは複数のファイルをまとめて実行することも、個別のファイルを実行することもできます。

テストデータを複数のファイルに分けて定義しておけば、リグレッションテストで部分的にテストしたい場合や、事前条件としてスキルのアクセス権を変更してテストしたい場合など、対象を絞ったテストができます。

Mochaによるテストファイルの選択方法については、Mochaのドキュメントで確認してください。

テンプレート

テストデータは下記のテンプレートに従って作成します。testDataの中に実際のテストデータを記述します。

TypeScript版

import * as Astd from 'alexa-skill-test-driver';
import * as config from './config.json'

const testData: Astd.TestData = {
    // テストデータ
};

Astd.executeTest(testData, config);

JavaScript版

const Astd = require('alexa-skill-test-driver');
const config = require('./config.json');

const testData = {
    // テストデータ
};

Astd.executeTest(testData, config);

テストターンの定義

テストデータで最も基本となるテストターンの定義について説明します。

テストターンの定義例

Hello Worldのテストデータから、”Open Skill and Say Hello”というテストケースを例にとります。

このテストケースでは、まず最初にスキルのLaunchRequestHandlerが呼び出されます。このLaunchRequestHandlerでは、下記のコードでレスポンスを返しています。

// LaunchRequestHandlerのコード

const speakOutput = 'Welcome, you can say Hello or Help. Which would you like to try?';

return handlerInput.responseBuilder
    .speak(speakOutput)
    .reprompt(speakOutput)
    .getResponse();

このレスポンスを検証するためのテストターンの定義を下記に示します。

// テストターンの定義
{
    input: {
        speak: 'Open hello world',
        mode: 'FORCE_NEW_SESSION'
    },
    output: {
        outputSpeech: {
            type: 'SSML',
            ssml: 'Welcome, you can say Hello or Help. Which would you like to try?'
        },
        reprompt: {
            type: 'SSML',
            ssml: 'Welcome, you can say Hello or Help. Which would you like to try?'
        },
        shouldEndSession: false
    }
}

スキルへの入力 (input)

speak

ターンには対話の始まりとなるユーザーからの発話を定義します。ここでは”Open hello world”と呼びかけてスキルを開始しています。スキルのLaunchRequestHandlerが呼び出されます。

mode

どの様に対話を始めるのかをモードで指定します。最初のターンの場合は、”FORCE_NEW_SESSION”を指定します。2番目以降の継続ターンでは、”DEFAULT”を指定しますが、これは省略可能です。

スキルからの出力 (output)

outputSpeech

スキルからの発話を定義します。タイプのSSMLはSpeech Synthesis Markup Languageと呼ばれる形式で定義されていることを示します。実際のスキルからのレスポンスは、”<speak>Welcome, you can … </speak>”のように<speak>タグで囲まれていますが、テストデータではこれらのタグは不要です。

reprompt

ユーザーからの発話が無かったときに発話を促すプロンプトを定義します。書式はoutputSpeechと同じです。

shouldEndSession

対話セッションを終了するかどうかのフラグです。LaunchRequestHandlerのコードには明示されていませんが、repromptがあるときは対話が継続するので、スキルからのレスポンスには、このフラグがfalseで設定されています。

この他にHello Worldスキルでは使われていませんが、カードと呼ばれる出力項目も、スキルのレスポンスの中で良く使われます。そのようなスキルをテストする場合は、テストデータにカードのデータも定義します。

テストデータの省略

ASTDは、スキルからのレスポンスとテストデータを、項目ごとに比較して検証します。テストデータの型定義では、outputSpeech以外の項目は省略可能です。しかし省略された場合、もしレスポンスにその項目が含まれていると、エラーと判定します。逆も同様で、テストデータに定義された項目がレスポンスに無いと、エラーと判定します。

しかし開発の初期など、レスポンスの全ての項目を確定できていない場合もあります。この場合、テストデータに定義されていない項目の検証を、スキップすることができます。後述するASTDの実行オプションでskipUndefinedToBeをtrueに設定します。

テストターンの検証をスキップ

スキルからのレスポンスに対する検証全体を、スキップすることもできます。ASTDはSMAPIの呼び出しが成功して、何らかのレスポンスが返されると、そのテストターンは成功したものと見なします。

下記のように、テストターンのデータに”skipValidation: true”という項目を追加します。このとき、outputは定義してもしなくても構いません。もし定義されていた場合は無視します。

// テストターンの定義
{
    skipValidation: true,
    input: {
        speak: 'Open hello world',
        mode: 'FORCE_NEW_SESSION'
    }
}

検証全体をスキップしたい状況としては、例えばスキルからの応答が状況に応じて動的に決まるため、予めテストデータとして定義できない場合が考えられます。そのような場合は、ASTDでの検証はスキップして、別途開発者コンソールのAlexaシミュレータなどを使ったテストで、カバーすることになります。

ただしASTDは、テストデータをプログラム的に生成することが可能です。またテストの前処理を定義して、事前条件を整えることもできます。条件を特定することができれば、動的なレスポンスに対しても対応できるケースは多いと考えられます。

スロットのある対話モデルのテスト

ASTDはスロットのある対話モデルをテストできます。ただし対話モデルで、オートデリゲートが無効になっている場合に限ります。

例えば下記のような会話を行うダイアローグを考えます。飲み物の種類をスロットとして、欲しい飲み物をスキルに伝える、という想定です。

U: ルームサービスを開いて
A: 何かご用でしょうか?
U: 飲み物が欲しい。
A: コーヒー、紅茶、日本茶がご用意できます。どちらをご用意いたしましょうか?
U: コーヒー。
A: コーヒーですね、承知いたしました。

4番目のAlexaからの発話「コーヒー、紅茶、日本茶が …」は、ダイアローグモデルで定義されたプロンプトです。3番目のユーザーからの発話「飲み物が欲しい」に飲み物の種類が指定されていないため、Alexaサービスがユーザーに飲み物の種類を特定するように促しています。

ダイアローグデリゲートが有効な場合、4番目のプロンプトはASTDには送られてきません。

このようなダイアローグをASTDでテストするには、下記の2つの方法があります。

一度の発話でスロットを埋めてしまう

下記のような発話をテストデータとして使用してテストします。

U: ルームサービスを開いて
A: 何かご用でしょうか?
U: コーヒーが飲みたい。
A: コーヒーですね、承知いたしました。

この場合、ユーザーの発話に最初から飲み物の種類が含まれているので、スキルにはスロットが埋まった状態でリクエストが渡されます。これはスキルから見れば、オートデリゲートによってスロットが埋められたのと同じなので、スキルの単体テストとしてはこれでも十分と考えられます。

一時的にオートデリゲートを無効にする

対話モデルに定義されたプロンプトも含めてテストしたい場合は、一時的にオートデリゲートを無効にしてテストします。

ただしオートデリゲートを無効にすると、Alexaサービスはスロットを埋める前にスキルを呼び出します。元々オートデリゲートを有効にする前提の場合は、スキル側にはリクエストを受け取るハンドラがなく、エラーとなってしまいます。

これに対して下記のようなダミーのハンドラをスキルに追加して、元のインテントをそのままAlexaサービスに返してやります。するとAlexaサービスは、対話モデルに定義されたメッセージを使って、ユーザーにスロットを埋めるように求めます。つまり見かけ上は、オートデリゲートを有効にしたのと同じになります。(余計なスキルの呼び出しが発生する分、パフォーマンスには影響があります)

const DialogDelegateHandler = {
    canHandle(handlerInput) {
        const envelope = handlerInput.requestEnvelope;

        if (Alexa.getRequestType(envelope) === 'IntentRequest') {
            const dialogState = Alexa.getDialogState(envelope);

            if (dialogState === 'STARTED' || dialogState === 'IN_PROGRESS') {
                return true;
            }
        }

        return false;
    },
    handle(handlerInput) {
        const request = Alexa.getRequest(handlerInput.requestEnvelope);

        return handlerInput.responseBuilder
            .addDelegateDirective(request.intent)
            .getResponse();
    }
}

上記のコードではインテントの種類を確認していません。インテントによってオートデリゲートの有効・無効を切り替える場合は、インテントの種類を確認して、本来オートデリゲートが有効であるインテントにのみ、この処理を行ってください。

上記ハンドラからのデリゲーションに対するテストターンの定義を下記に示します。

// テストターンの定義
{
    input: {
        speak: '飲み物が欲しい'
    },
    output: {
        type: 'Directive',
        directivePrompts: [
            'コーヒー、紅茶、日本茶がご用意できます。どちらをご用意いたしましょうか?'
        ]
    }
}
    

output.type

‘Directive’を指定します。

output.directivePrompts

対話モデルに定義されているプロンプト (スロットの入力を促すメッセージ) を記述します。複数ある場合は、全て列挙してください。

オートデリゲートが有効ではテストできない理由

結論を言えば、オートデリゲートを有効にしたシミュレーションを、SMAPIがサポートしていないためです。

オートデリゲートが有効な状態でSMAPIを呼び出したところ、”Unexpected error occurred”というエラーが返ってきました。GitHub上のASK SDKのプロジェクトで、Bugとして報告して見ましたが、Amazonの担当者からは”expected behavoir”との返信がありました。(“unexpected”が”expected”とはこれ如何にですが …) SMAPIはあくまでスキルへの呼び出しをシミュレーションするもの、という位置づけとのことです。

Dialog.ElicitSlotのテスト

ASTDはDialog.ElicitSlotを使った対話をテストできます。

例えば下記のような会話を行うダイアローグを考えます。

U: ルームサービスを開いて
A: 何かご用でしょうか?
U: 飲み物が欲しい。
A: 今日はフレッシュオレンジジュースがおすすめです。如何でしょうか?
U: いいね
A: 有り難うございます。フレッシュオレンジジュースをお持ちします。

今日のおすすめは毎日変わるので、対話モデルに静的に定義せず、スキルの中で動的に定義して返す必要があります。このようなときに使われるのが、Dialog.ElicitSlotです。スキルからスロットとそのスロットを埋めるためのプロンプトをレスポンスとして返します。

例えば下記のようにスキルからレスポンスを返すものとします。getTodaysRecommendation()で「今日のおすすめ」の飲み物を計算し、それをスキルからの発話に埋め込んでいます。

const recommendation = getTodaysRecommendation();

return handlerInput.responseBuilder
    .speak(`今日は${recommendation}おすすめです。如何でしょうか?`)
    .reprompt('他にはコーヒー、紅茶、日本茶がご用意できます。')
    .addElicitSlotDirective({
        type: 'Dialog.ElicitSlot',
        slotToElicit: 'drink',
        updateIntent: {
            name: 'OrderDrinkIntent',
            confirmationStatus: 'NONE',
            slots: {
                drink: {
                    name: 'drink',
                    value: '',
                    confirmationStatus: 'NONE'
            }
        }
    })
    .getResponse();

これに対するテストターンのデータは下記のようになります。setupTodaysRecommendation()はターンの前処理を行う関数で、例えばスキルのDBにアクセスして、今日のおすすめにフレッシュオレンジジュースを設定するものとします。

// テストターンの定義
{
    prePorcessor: setupTodaysRecommentation, // スキルに今日のおすすめを設定する
    input: {
        speak: '飲み物が欲しい',
    },
    output: {
        type: 'Directive',
        outputSpeech: {
            type: 'SSML',
            ssml: '今日はフレッシュオレンジジュースがおすすめてす。如何でしょうか?'
        },
        reprompt: {
            type: 'SSML',
            ssml: '他にはコーヒー、紅茶、日本茶がご用意できます。'
        },
        shouldEndSession: false
    }
}

テストの前処理と後処理

テストでは事前条件や事後条件を設けて検証することがあります。

前述の「今日のおすすめ」の例では、今日のオススメとしてフレッシュオレンジジュースが設定されていることが、事前条件となります。(設定できること自体が、テスト対象となる場合もありますが、ここではあくまで今日のオススメを返す会話が行われることを、テスト対象としています。)

またルームサービスの例では、注文結果がどこかのDBに保存されるはずです。これが事後条件で、スキルの動作確認としては、この保存結果が正しいことを検証する必要があります。

ASTDはこのような事前・事後条件の設定・検証のために、テストケースやテストターンの実行前後に追加処理を定義できるようになっています。具体的にはテストケースやテストターンに、下記のように関数を設定することができます。

テストターンの場合

  • preProcessor: function() { … };
  • postProcessor: function(simulationResult: Astd.SimulationApiResponse) { … };

preProcessor, postProcessorは、会話のターンと同じMochaのitの中で実行されます。このため、ChaiなどのAssertionライブラリを使って、検証結果をAssertすることができます。

スキルからのレスポンスの検証

テストターンに対するpostProcessorには、SMAPIから返されるレスポンスが渡されます。このレスボンスの内容を検証することができます。

例えば下記では、レスポンスに含まれるセッションアトリビュートに、部屋番号が設定されているかどうかを検証しています。

import * as Astd from 'alexa-skill-test-driver';
import { assert } from 'chai';

function postprocessor(response: Astd.SimulationsApiResponse) {
    const sessionAttributes = Astd.getSessionAttributes(response);
    assert.exists(sessionAttributes.roomNumber, 'Room number is not set');
}

テストケースの定義

テストケースの定義例を下記に示します。

// テストケースの定義
{
    title: 'Open Skill and Say Hello',
    turns: [
        {
        // テストターンの定義
        }
    ]
}          

title

テストケースのタイトル (名称) を定義します。

turns

テストターンを定義します。

テストデータの定義

テストデータの定義例を下記に示します。

// テストデータの定義
{
    title: 'Test Hello World',
    locale: 'en-US',
    testCases: [
        {
        // テストケースの定義
        }
    }
}

title

テストのタイトル (名称) を定義します。

locale

発話に使用するロケール (言語) を指定します。日本語の場合は、’ja-JP’となります。

testCases

テストケースを定義します。

ASTDの実行

実行方法

Mochaを使ってテストを実行します。

package.jsonに起動スクリプトを定義して、npm経由で起動できます。

package.jsonに下記を追加します。

scripts: {
  "test": "mocha --timeout 15000"
}

コマンドラインから下記のように起動します。

cd lambda
npm test

良く使われるMochaのオプション

  オプション       説明
bailテストでエラーが発生すると、そこでテストを打ち切ります。
デフォルトでは、あるテストケースのテスト中に、途中のテストターンでエラーが発生すると、残りのテストターンをスキップして、次のテストケースに移ります。起動オプションでbailを指定すると、次のテストケースに移ることなく、実行を打ち切ります。
slow <ms>Mochaはテストの実行時間を出力します。スレッショルドに対するマージンで、緑、黄、赤で状況を表します。デフォルト値は75msです。そのままではASTDのテスト結果は全て赤 (Slow) になるため、適当なスレッショルドを設定する必要があります。
筆者は3秒ルールを考慮して3,000msを設定しています。50% (1,500ms) 以下で緑、50% (1,500ms) を超えると黄、100% (3,000ms) を超えると赤で表示されます。
timeout <ms>テスト実行時のタイムアウトを指定します。これは各ターンの実行時間に相当します。
デフォルトは2,000ms (2秒) です。
スキルとの対話には、3-4秒かかることが多いこと、リトライ (最大3回) が発生する場合があることから、15,000ms (15秒) 程度は必要です。

これらはコマンドラインの起動オプションとして指定するか、Mochaのオプションファイルにも定義できます。またテストデータの中で、TestCaseに対するbeforeやpreProcessorの処理で設定することもできます。

this.timeout(10000);
this.slow(3000);
this.bail(true);

ASTDの実行オプション

各テストは、テストデータのファイルの最後に記述された下記の関数により実行されます。

Astd.executeTestGroup(
  testData, 
  config,
  acceptUnexpectedDelegation, 
  dumpResponse,
  skipUndefinedToBe,
  skipUndefinedActual)

引数の説明を下記に示します。

引数説明
testDataTestDataとして定義されたテストデータの本体です。
configSMAPIにアクセスするための構成情報です。テンプレートでは、./config.jsonファイルから読み込んでいます。
acceptUnexpectedDelegationオプショナル (省略値はfalse)
trueに設定すると、予期しないDelegation要求を受け付けます。

後述するようにSMAPIから予期しないDelegation要求が返される場合があり、trueが設定されている場合は、エラーとせずに受け付けます。その場合は、outputSpeechに定義された発話テキストのみを、Delegation要求と一緒に返された発話テキストと比較します。またoutputSpeech以外の項目については検証を行いません。
構成ファイル (config.json) に定義することもできます。
dumpResponseオプショナル (省略値はfalse)
trueに設定すると、SMAPIから返されたレスポンスを表示します。
構成ファイル (config.json) に定義することもできます。
skipUndefinedToBeオプショナル (省略値はfalse)
trueに設定すると、テストデータに定義されていないoutputの項目の検証をスキップします。
構成ファイル (config.json) に定義することもできます。
skipUndefinedActualオプショナル (省略値はfalse)
trueに設定すると、スキルからのレスポンスに定義されていないoutputの項目の検証をスキップします。
構成ファイル (config.json) に定義することもできます。

ASTDの構成ファイル

構成ファイルには、SMAPIにアクセスするための構成情報を記述します。また構成ファイルには、ASTDの実行オプションを定義することもできます。実行オプションがテストデータと構成ファイルの両方に定義されている場合は、テストデータの実行オプションが優先されます。

テンプレートでは、同じディレクトリにあるconfig.jsonファイルを読み込んでいます。

構成ファイルの項目を下記に示します。

{
    "skill_id": "",
    "client_id": "",
    "client_secret": "",
    "refresh_token": "",
    "acceptUnexpectedDelegation": boolean | undefined,
    "dumpResponse": boolean | undefine,
    "skipUndefinedToBe": boolean | undefied,
    "skipUndefinedActual": boolean | undefined
}

上記のうちclient_id, client_secret, refresh_tokenについては、LWA (Login with Amazon) とASK CLIを使って取得します。詳細は下記を参照してください。

SMAPIで使用するアクセストークンの取得

ASK CLIをインストールして構成済みの場合は、これらは実はローカル環境からも取得可能です。こちらを参照してください。

構成ファイルの項目とその説明を下記に示します。

項目説明
skill_idアクセスするスキルのID
開発者コンソールのスキルリストから取得
client_idLWAから取得するクライアントID
またはASK CLIの構成情報から取得
client_secretLWAから取得するクライアントシークレット
またはASK CLIの構成情報から取得
refresh_tokenASK CLIで取得するアクセストークン
またはASK CLIの構成情報から取得
acceptUnexpectedDelegationオプショナル
ASTDの実行オプション」を参照
dumpResponseオプショナル
ASTDの実行オプション」を参照
skipUndefinedToBe オプショナル
ASTDの実行オプション」を参照
skipUndefinedActual オプショナル
ASTDの実行オプション」を参照

構成ファイルは下記の場所に作成してください。

TypeScript環境の場合

./src/test/config.json (TypeScriptによって./lambda/test/config.jsonにコピーされます。)

JavaScriptの場合

./lambda/test/config.json

実行中のエラー

ASTDによるテストの実行中に、SMAPIから予期しないレスポンスが返されたり、SMAPIの呼び出しが失敗することがあります。不定期に発生し再現性も低いため、原因の特定に至っていません。(現象的にSMAPIまたはバックエンドのAlexサービスに問題がありそうですが …)

それぞれの現象と対応方法について下記に述べます。

予期しないDelegation

スロットを含む対話のテスト中に、スロットの穴埋めがされていてスキルから通常のレスポンスを返しているにもかかわらず、Delegationを求めるレスポンスが返る場合があります。

ASTDの実行オプション (acceptUnexpectedDelegation) で、このDelegationを受け入れて、発話テキストのみを確認するようにして回避できます。

発話テキストがインテントにマッチしない

SMAPIの呼び出しが失敗し、下記のエラーメッセージが返ることがあります。

This utterance did not resolve to any intent in your skill. Please invoke your skill and try again with a different utterance or update your interaction model to include this utterance before testing again.

実音声だと「オレの滑舌が悪かったのか?」と思うところですが、入力はテキストですから不思議です。

SMAPIの呼び出しが失敗した場合、3回までリトライするようにしています。多くの場合は、このリトライ中に正常なレポンスが返り、テストとしては成功するようです。

状態の不整合

SMAPIの呼び出しが失敗し、下記のエラーメッセージが返ることがあります。

This requests conflicts with another one currently being processed.

前述のように、SMAPIの呼び出しが失敗した場合、3回までリトライするようにしていますが、このエラーの場合は回復しないようです。Mochaによるテストの実行からやり直すと成功します。少し時間を空けたほうが良い場合があります。

メッセージの内容から、ASTDがSMAPIを多重に呼び出している可能性も検討しました。ただ一発目の呼び出しでもこのエラーになる場合があり、必ずしも呼び出し方の問題ではなさそうです。

不明なエラー

SMAPIの呼び出しが失敗し、下記のエラーメッセージが返ることがあります。

An error occurred when we tried to process your request.
Rest assured, we’re already working on the problem and expect to resolve it shortly.

発生は極めてまれです。リトライ中に回復しない場合は、Mochaによるテストの実行からやり直すと回復します。”expect to resolve shortly”が本当であれば嬉しいですが …

おわりに

Alexaスキルのテストを自動化するASTDについて紹介しました。

筆者の場合、スキルのテストは、開発者コンソールのAlexaシミュレータや、ASK Toolkit (VS Code上のExtension) を使用して行っていました。しかし何れも手作業を伴うため、テストの実施は手間のかかる作業でした。なんとか自動化できないかと考えている時に、SMAPIのSimulation APIの存在を知り、このTest Driverを開発しました。

VUIもAIが組み込まれたり、よりダイナミックなものになりつつあります。ダイナミックに対話のフローが変わるようなシナリオが実現可能になると、テストの方法も更に工夫が必要になりそうです。そのうちテストドライバにもAIを組み込むことになるのかな、などと想像しています。(すでに誰かやっているでしょうけれど …)

参考

変更履歴

日付内容
2021/06/19記述内容の補足と改良
2021/06/10初版リリース
Alexa-Skill
スポンサーリンク