TypeScript を使って Alexa Custom Skills を作ろう 番外編 localstack

# はじめに

[TypeScript を使って Alexa Custom Skills を作ろう](https://qiita.com/daisukeArk/items/ac45f10d2fc8fe8bdc5a)で作成したスキルでは、ユーザが発話した内容に基いて何がAlexaから返されるかは、すべてAWS Lambda内の処理で完結しています。

しかし、実際にスキルを作り始めると以下のような要件が出てきます。
例えば、

* 既存システムのDynamoDBにあるデータを取得したい
* セッション情報を永続化したい

本投稿では、[実装 - 発話編](https://qiita.com/daisukeArk/items/e95028597daae6d3d110)で作成したニュースリポジトリクラスを変更し、DynamoDBからデータを取得するようにします。

# テストについての課題

本投稿で、ニュースリポジトリクラスをDynamoDBからデータを取得するように変更した場合、当然ですがDynamoDBにテーブルを作成しデータを登録する必要があります。

Amazon DynamoDBにそれらを登録をしアクセスすることにしてしまうとインターネット接続が必要となる為、気軽にローカルでテストをするということが難しくなってしまいます。

# 気軽にテストする為に考える

気軽にテストする為には、DynamoDBがローカル環境で実行されている必要があります。
その為の手段としてあるのが、

* DynamoDB Local
* localstack

のどちらかを利用することです。

本投稿では、リポジトリの修正と合わせてlocalstackを利用したテストの方法について、ご紹介します。

# やってみよう

## 事前準備

localstack環境構築の為には、事前に以下のインストールが必要です。
本投稿では、インストール方法については割愛いたします。

* docker
* docker-compose
* aws-cli

## localstack環境の構築
まずは、localstack環境を作ります。

[localstack/localstack - github](https://github.com/localstack/localstack)

### `docker-compose.yml`の作成

`Docker Compose`を利用しコンテナの管理を行う為、設定ファイルを準備します。

```bash:移動
$ cd ~/custom-skill-sample-to-convert/skill
```

```bash:docker-compose.ymlの作成
$ touch docker-compose.yml
```

githubの`docker-compose.yml`を参考に以下の通り設定します。

```yml:docker-compose.yml
version: '2.1'

services:
  localstack:
    container_name: typescript-localstack
    image: localstack/localstack
    ports:
      - "4567-4583:4567-4583"
      - "${PORT_WEB_UI-8080}:${PORT_WEB_UI-8080}"
    environment:
      - DATA_DIR=/tmp/localstack/data
      - LAMBDA_EXECUTOR=docker
    volumes:
      - "${TMPDIR:-/tmp/localstack}:/tmp/localstack"
      - "/var/run/docker.sock:/var/run/docker.sock"
      - "./__localstack__/data:/tmp/localstack/data:rw"
```

`localstack`では、以下のサービスのデータを永続化することが可能です。

* DynamoDB
* Elasticsearch
* Kinesis
* S3

永続化するにあたり、ローカルディレクトリをマウントします。
`volumes`の一番最後の行ですね。

では、起動してみましょう。

```bash:localstack起動
$ TMPDIR=/private$TMPDIR docker-compose up

...
typescript-localstack | Starting mock ES service (http port 4578)...
typescript-localstack | Starting mock Lambda service (http port 4574)...
typescript-localstack | Ready.
```

### AWS CLI プロファイル追加

`localstack` DynamoDBへはAWS CLIで操作を行います。
`localstack`用のプロファイルを追加します。

```bash:プロファイル追加
$ aws configure --profile localstack
AWS Access Key ID [None]: dummy
AWS Secret Access Key [None]: dummy
Default region name [None]: us-east-1
Default output format [None]: text
```

これでAWS CLIで`localstack`を操作する準備が出来ました。

### DynamoDB テーブル作成とデータ登録

では、早速作成していきましょう。
ここでは、以下のコマンドを利用します。

* create-table
* batch-write-item

```bash:ニューステーブル作成
aws dynamodb create-table \
--profile localstack \
--endpoint-url=http://localhost:4569 \
--table-name news \
--attribute-definitions AttributeName=sequenceId,AttributeType=N \
--key-schema AttributeName=sequenceId,KeyType=HASH \
--provisioned-throughput ReadCapacityUnits=1,WriteCapacityUnits=1
```

データ登録は、jsonファイルを作成し、ニューステーブルへロードします。

```bash:ニュースデータjsonファイル作成
$ touch news.json
```

コンテンツを少し変えています。

```json:news.json
{
  "news": [
    {
      "PutRequest": {
        "Item": {
          "sequenceId": {
            "N": "1"
          },
          "contents": {
            "S": "1番ですね"
          }
        }
      }
    },
    {
      "PutRequest": {
        "Item": {
          "sequenceId": {
            "N": "2"
          },
          "contents": {
            "S": "2番ですね"
          }
        }
      }
    },
    {
      "PutRequest": {
        "Item": {
          "sequenceId": {
            "N": "3"
          },
          "contents": {
            "S": "3番ですね"
          }
        }
      }
    }
  ]
}
```

```bash:データロード
aws dynamodb batch-write-item \
--profile localstack \
--endpoint-url=http://localhost:4569 \
--request-items file://news.json
```

準備はこれで完了です。

## ニュースリポジトリクラスの修正

では、ニュースリポジトリクラスを修正しDynamoDBからデータを取得するようにしましょう。

### AWS Configの設定

`localstack`のDynamoDBに接続する為にプロファイルに合わせて、configを設定します。
`index.ts`のimport文の下に以下を追記します。

```ts:index.ts
// ~~~省略~~~
import { languageStrings } from './utterances/language-strings';

if (!process.env.NODE_ENV) {
  // NODE_ENVが未定義の場合

  // AWSコンフィグ設定
  AWS.config.update({
    accessKeyId: 'dummy',
    secretAccessKey: 'dummy',
    dynamodb: {
      region: 'us-east-1',
      endpoint: 'http://localhost:4569'
    }
  });
}
```

### ニュースリポジトリクラスの修正

ニュースリポジトリクラスがDynamoDBへ接続するために必要な変更点は、

* AWS SDKをインポートする
* DynamoDBコンテキストへの依存解決する
* `getAsync`メソッドでDynamoDBをつつくようにする

になります。

```ts:news-repository.ts
import * as AWS from 'aws-sdk';

/**
 * ニュースリポジトリクラス
 */
export class NewsRepository {
  /**
   * DBコンテキスト
   */
  private dbContext: AWS.DynamoDB;

  /**
   * コンストラクタ
   */
  constructor(dbContext: AWS.DynamoDB) {
    this.dbContext = dbContext;
  }

  /**
   * 内容取得
   * @param sayNumber 数字
   * @returns 内容
   */
  public getAsync(sayNumber: string): Promise<string> {
    return new Promise(async (resolve, reject) => {
      // getItemパラメータ設定
      const params: AWS.DynamoDB.GetItemInput = {
        TableName: 'news',
        Key: {
          sequenceId: {
            N: sayNumber
          }
        }
      };

      let result: AWS.DynamoDB.GetItemOutput;

      try {
        // データ取得
        result = await this.dbContext.getItem(params).promise();
      } catch (err) {
        reject(err);
        return;
      }

      // アイテムが未定義であるか判定
      if (!result.Item) {
        // 未定義の場合リジェクト
        reject(`${sayNumber}番のデータは見つかりませんでした。`);
        return;
      }

      // 結果を文字列で返す
      resolve(String(result.Item.contents.S));
    });
  }
}
```

### ニュースリポジトリクラス呼出元の修正

インスタンス作成時にDBコンテキストを受け取るように変更したため、合わせて修正を行います。

* src/utterances/first-utterance.ts
* src/tests/utterances/first-utterance.test.ts

```ts:src/utterances/first-utterance.ts(37行目)
this.repository = (repository) ? repository : new NewsRepository(new AWS.DynamoDB());
```

```ts:src/tests/utterances/first-utterance.test.ts(34行目)
const repository = new NewsRepository(new AWS.DynamoDB());
```

### ニュースリポジトリクラス単体テスト

実際にDynamoDBから値が取得できるかを評価しましょう。

```bash:移動
$ cd ~/custom-skill-sample-to-convert/skill/lambda/custom
```

```bash:テストファイル作成
$ mkdir -p ./src/tests/models

$ touch ./src/tests/models/news-repository.test.ts
```

```ts:news-repository.test.ts
import * as AWS from 'aws-sdk';
import * as Chai from 'chai';
import * as Mocha from 'mocha';
import { NewsRepository } from '../../models/news-repository';

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

// AWSコンフィグ設定
AWS.config.update({
  accessKeyId: 'dummy',
  secretAccessKey: 'dummy',
  dynamodb: {
    region: 'us-east-1',
    endpoint: 'http://localhost:4569'
  }
});

/**
 * テスト
 */
describe('news-repositoryクラスのテスト', () => {
  // ターゲットインスタンス作成
  const target = new NewsRepository(new AWS.DynamoDB());

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

    it('想定した発話が返されること', async () => {
      // 発話内容取得
      const result = await target.getAsync('1');

      // アサーション
      assert.equal(result, '1番ですね');
    });
  });
});
```

```bash:トランスパイル
$ tsc
```

```bash:テスト実行&確認
$ $(npm bin)/mocha ./dist/tests/models/news-repository.test.js

news-repositoryクラスのテスト
    メッセージが正しく取得できること
      ✓ インスタンスが作成されていること
      ✓ 想定した発話が返されること (72ms)


  2 passing (79ms)
```

無事に２件パスしました。

# まとめ

`localstack`を利用することで、AWSサービスとの連携が必要なスキル開発も気軽にローカルでテストが可能になりました。
今回はDynamoDBのみを利用しましたが、他サービスも利用することで出来ることの幅が広がりますね！

今回の投稿のソースは以下にあります。
[localstackを使って単体テスト](https://github.com/daisukeArk/custom-skill-sample-to-convert/tree/dev/localstack)

# 参考

* [Docker for Macをインストールしてみた - Qiita](https://qiita.com/scrummasudar/items/750aa52f4e0e747eed68)
* [AtlassianのLocalStackを使ってみてなんとなく理解するまでのお話 - Qiita](https://qiita.com/kojiisd/items/8d7b98fb7f3c8f871f96)
* [[localstack] ローカル環境にAWSサービスのモックを作り開発をする - Qiita](https://qiita.com/Yuki_BB3/items/106a88c6f27690f922cc)