Node.js
TypeScript
Alexa
AlexaSkillsKit

TypeScript を使って Alexa Custom Skills を作ろう (6) 単体テスト

前回

前回(実装 - 発話)の投稿では、発話の外部モジュール化を行い、合わせてTypeScriptのinterfaceasync/awaitを活用しました。
いよいよ、本連載は最後になります。残りは単体テストについてです。

単体テストの前に、Alexa Custum Skillsでのテストについて、考える

スキルのテスト方法

スキルをテストするには、以下の選択肢があります。

  1. 実機でテスト
  2. Echosim.io(ウェブ上のシミュレータ)でテスト
  3. Amazon 開発者コンソールのサービスシミュレータでテスト
  4. ASK CLIのsimulateコマンドでテスト(日本語は対応してません。)

考察と思い

どれを選んでも、以下の条件があるので気軽にテストが難しい。

  • インターネット接続が必要
  • AWS Lambdaのデプロイが必要
  • 複数人で開発するときに、スキルが1つしかないと誰かに書き換えられてしまうかもしれない

以上の条件があると、当然効率も下がりますし品質にも影響が出るでしょう。

私としては、実機などで確認をする前にローカル内で気軽にテストがしたいのです。

ローカルでスキルをテストする為に

調べてみると、alexa-conversationというフレームワークが見つかりました。
このフレームワークは、スキルのテストを対話形式で書くことができ、さらにローカルで確認ができるまさに欲しかったものでした。

alexa-conversation
mochaというJavaScriptの単体テストフレームワークに依存しています。

ユーザの発話で呼び出されるインテントに対して、Alexaスキルの結果をテストすることが可能になります。
ローカルで一通りの機能が問題無いことを担保することで、Lambdaへのデプロイ頻度が下がることが期待できると思います。

本投稿の趣旨から外れてしまう為、alexa-conversationについてはここまでとなります。

単体テストについて

では、本題の単体テストについて考えていきましょう。
本投稿では、単体テストうんぬんとかではなく、何を使ってどうテストしたかをご紹介したいと思います。
もちろん、ご紹介する方法以外でも出来ますが参考として見て頂ければ幸いです。

本連載では、機能をいくつかの小さな単位で実装を行ってきました。
この投稿では、発話クラスを単体テストします。

テストフレームワークの検討

Node.js(JavaScript)の単体テストフレームワークはいくつか選択肢があるようです。
今回は、あまり深く考えずalexa-conversationでも登場したmochaを利用します。

そして、テストの結果を確認する為に必要なライブラリは以下を選択しました。

  • chai アサーション
  • sinon スタブ
  • moq.ts モック

テストしようぜ

まずは、ライブラリのインストールから

移動
$ cd ~/custom-skill-sample-to-convert/skill/lambda/custom
mocha,chai,sinonのインストール
$ npm install --save-dev mocha chai sinon moq.ts
型定義ファイルの取得
$ npm install --save-dev @types/mocha @types/chai @types/sinon

テスト作成前の準備

移動
$ cd ~/custom-skill-sample-to-convert/skill/lambda/custom/src
テストディレクトリの作成
$ mkdir -p ./tests/utterances
テストファイルの作成
$ touch ./tests/utterances/first-utterance.test.ts

発話クラスについて

前回作成した発話クラスは、コンテキストだけではなくニュースリポジトリ(NewsRepository)にも依存した作りとなっています。
このままでは、単体テストがしにくいので依存解決しましょう。

変更前

コンテキストのみが依存解決されており、リポジトリはコンストラクタ内でインスタンスを作成しています。

utterances/first-utterance.ts(変更前)
/**
 * コンストラクタ
 * @param context Alexaハンドラコンテキスト
 */
constructor(context: Alexa.Handler<any>) {
  super(context);

  // リポジトリ作成
  this.repository = new NewsRepository();
}

ここを単体テスト時は、リポジトリを依存解決できるようにしましょう。

変更

コンストラクタをオーバロードして、リポジトリを渡すようにします。

utterances/first-utterance.ts(変更後)
/**
 * コンストラクタ
 * @param context Alexaハンドラコンテキスト
 */
constructor(context: Alexa.Handler<any>);
/**
 * コンストラクタ
 * @param context Alexaハンドラコンテキスト
 * @param repository ニュースリポジトリ
 */
constructor(context: Alexa.Handler<any>, repository: NewsRepository);
/**
 * コンストラクタ
 * @param context Alexaハンドラコンテキスト
 * @param repository ニュースリポジトリ
 */
constructor(context: Alexa.Handler<any>, repository?: NewsRepository) {
  super(context);

  // リポジトリ作成
  this.repository = (repository) ? repository : new NewsRepository();
}

これで単体テストを書く準備ができました。

単体テストを書く

では、単体テストを書いていきましょう。
さっそく、ソースを貼ってしまいます。

tests/utterances/first-utterance.test.ts
import * as Mocha from 'mocha';
import * as Chai from 'chai';
import * as Sinon from 'sinon';

import * as Alexa from 'alexa-sdk';
import { It, Mock } from 'moq.ts';
import { IFirstUtteranceCondition } from '../../conditions/first-utterance-condition';
import { NewsRepository } from '../../models/news-repository';
import { FirstUtterance } from '../../utterances/first-utterance';
import { languageStrings } from '../../utterances/language-strings';

const assert = Chai.assert;
const should = Chai.should();

// コンテキストモック
const contextMock = new Mock<Alexa.Handler<any>>('test')
  // 初回の発話
  .setup((sender) => sender.t(It.Is((token: string) => token === 'ASK_ANSWER_NUMBER'), It.IsAny()))
  .callback((token: string, args: any[]) => {
    return `${languageStrings['ja-JP'].translation.ASK_ANSWER_NUMBER}`.replace('%s', args.toString());
  })

  // 追加の発話
  .setup((sender) => sender.t(It.Is((token: string) => token === 'ASK_ANSWER_NUMBER_REPROMPT')))
  .returns(`${languageStrings['ja-JP'].translation.ASK_ANSWER_NUMBER_REPROMPT}`)
  ;

/**
 * テスト
 */
describe('first-utteranceクラスのテスト',() => {
  // ニュースリポジトリスタブ
  const repository = new NewsRepository();

  // スタブ作成
  const repositoryStubGetAsync = Sinon.stub(repository, 'getAsync');

  // 戻り値を設定
  repositoryStubGetAsync.returns('テストだよ');

  // ターゲットインスタンス作成
  const target = new FirstUtterance(contextMock.object(), repository);

  /**
   * テストケース
   */
  describe('メッセージが正しく取得できること',() => {
    // アサーション
    it('インスタンスが作成されていること', function () {
      should.not.equal(target, null);
      should.not.equal(target, undefined);
    });

    // アサーション
    it('想定した発話が返されること', async () => {
      // 発話条件設定
      const condition:IFirstUtteranceCondition = {
        sayNumber: '1'
      };

      // 発話内容取得
      const result = await target.respond(condition);

      assert.equal(result.speech, 'テストだよ。他に聞きたい数字はありますか?');
      assert.equal(result.repromptSpeech, '数字を言ってください。');
    });
  });
});

各処理が何をしているかは、コメントを見て頂ければと思います。
ポイントとしては、

  • コンテキストは、テスト専用のオブジェクトとしてモックで定義
  • リポジトリは、発話クラス内部で呼び出された際にgetAsyncが特定の結果を返すようにスタブで置き換え

以上のことにより、発話クラスが単体で問題無く動作することを評価します。

単体テストを実行する

作成した単体テストを実行しましょう。

トランスパイル

移動
$ cd ~/custom-skill-sample-to-convert/skill/lambda/custom
トランスパイル
$ tsc

テスト実行

テスト実行
$ $(npm bin)/mocha ./dist/tests/utterances/first-utterance.test.js

実行すると、こんな結果が返ってきます。

結果
first-utteranceクラスのテスト
    メッセージが正しく取得できること
      ✓ インスタンスが作成されていること
      ✓ 想定した発話が返されること


  2 passing (18ms)

問題無く、テスト終了しました。

まとめ

この投稿では、発話クラスの単体テストを行いました。
今回の投稿でのテストケースは一部です。
これ以外にもリポジトリで例外が発生した場合に、想定される挙動となっているかなどの評価も必要です。

単体テストを書くことにより、この処理では何をやっているかなどが理解しやすくなります。
また、要件を満たしているかなども見つけやすくなり、品質も担保できるでしょう。

今回の投稿のソースは以下にあります。
単体テスト

さいごに

全7回に渡って、投稿してきた「TypeScript を使って Alexa Custom Skills を作ろう」は、これで終了です。
本連載が皆さんのカスタムスキル作成のお役に立てれば幸いです。

お詫び

実装編の投稿でtslintをインストールしました。
この機能でコード検証も行う予定だったのですが、Visual Studio Codeで拡張機能が無効になっていました。。。
なので、有効にするとかなりエラーとなるかと思います。
申し訳ありません。

ブランチを一旦作り直すなどして、随時修正していきます。
修正いたしました。

変換元サンプルスキルソース(github)
TypeScript変換
ハンドラ外部モジュール化
ヘルプインテント外部モジュール化
発話外部モジュール化
単体テスト

番外編

番外編としては、以下のものを検討しています。(時期は未定です。。。)