ローカル環境でLambda+S3のテストをする

## 概要

sam local & [localstack][5] を使ってローカルでLambdaのテストする環境を構築したときの手順とハマったところをまとめたものです。おもに[[2]]を参考にして構築しました。

SAMのリソースの作成方法についてはここではあまり触れていません。

---

**注**: SAM local で Lambda を実行するよりも、**local から直接テストを実行するほうが開発サイクルが早い**です。これから Lambda Function のテストの実装を考えている方はこちらの記事を参照してください。

* [Lambda Function の開発について悩んだところをまとめてみた][lambda]
[lambda]: https://qiita.com/billthelizard/items/be87a7662c34fd5c634e

---

## 構築の要件

* Lambda Function の中で S3 を参照している部分をローカル環境でテストしたい
  * テストサイクルが早くなることを期待

### Stack

* AWS SAM
  * SAM CLI, version 0.10.0
* localstack

## 手順

### 必要な資材のインストール

公式ページの説明にしたがってインストールします。

* aws-sam-cli
* docker for Mac

### localstack の起動

```yaml:docker-compose.yml
version: '3'
services:
  localstack:
    image: localstack/localstack
    ports:
      - 4567-4578:4567-4578
      - 8080:8080
```

dockerコンテナの起動

```bash
docker-compose up -d
```


### profileの設定

locastack の場合も、aws-cli を呼び出すときに profile の設定がないとコケます[[1]]。

```~/.aws/credentials
[localstack]
aws_access_key_id = dummy
aws_secret_access_key = dummy
```

```~/.aws/config
[profile localstack]
region = us-east-1
output = text
```

### Localstack上の S3 に Bucket/Object を作成する

```message.txt
Hi, there!
```

```bash
# bucket作成
aws s3 --endpoint-url=http://localhost:4572 mb s3://test-bucket --profile=localstack

# オブジェクト作成
aws s3 --endpoint-url=http://localhost:4572 cp message.txt s3://test-bucket --profile=localstack
```

### docker networkの確認

`sam local` 実行時に docker network を指定しないと、SAM Local => localstackに疎通ができません。 [[2]]

```
$ docker network ls
NETWORK ID          NAME                 DRIVER              SCOPE
...
b2fbba06747a        localstack_default   bridge              local
...
```

### 環境変数をSAMのテンプレートで指定する

`sam local` 実行時に `--env-vars` で環境変数を指定しても、 **SAMテンプレートに環境変数が指定されていないと有効になりません。**

```.env.json
{
  "S3ReadFunction": {
    "IS_LOCAL_STACK" = "true",
    "S3_BUCKET": "test-bucket"
  }
}
```

```yaml:template.yaml
Globals:
  Function:
    Environment:
      Variables:
        IS_LOCAL_STACK: 'false'
        S3_BUCKET: 'production-bucket'
```

### S3のエンドポイントに localstack のエンドポイントを指定する

環境変数の値に応じて、実物のS3を参照するかlocalstack の S3を参照するかを分岐するコードを書きます。
ただし、[後述](#%E5%8F%82%E8%80%83-%E6%9C%AC%E7%95%AA%E3%81%AE%E3%82%B3%E3%83%BC%E3%83%89%E3%81%AB%E3%83%86%E3%82%B9%E3%83%88%E3%82%B3%E3%83%BC%E3%83%89%E3%81%A8%E3%81%AE%E5%88%86%E5%B2%90%E3%83%AD%E3%82%B8%E3%83%83%E3%82%AF%E3%82%92%E6%9B%B8%E3%81%8B%E3%81%AA%E3%81%84)のように、 **Productionのコードでこのやり方は良くないやり方です**。

```js:app.js
const AWS = require('aws-sdk');

// localstack
const config = {
 endpoint: (process.env.IS_LOCAL_STACK === "true"? "http://localstack:4572": undefined),
 s3ForcePathStyle: process.env.IS_LOCAL_STACK === "true",
}
const s3 = new AWS.S3(config);
const { S3_BUCKET } = process.env;

// handlerのコード
exports.lambdaHandler = async (event, context) => {
  try {
    const params = {
      Bucket: S3_BUCKET,
      Key: event.Key
    };
    const ret = await s3.getObject(params).promise();
    const message = ret.Body.toString();
    console.log(message);
  } catch (err) {
    console.log(err);
    return err;
  }
};
```

### ビルド

```
sam build --use-container
```

### sam local 実行

sam local 上に起動した lambda function を起動します

```bash
sam local invoke S3ReadFunction --docker-network b2fbba06747a --env-vars env.json --profile=localstack
```

手動で次のように入力します

```:標準入力
{"Key": "message.txt"}<Enter>
<Ctrl-D>
```

```:出力結果
...
2019-02-01T09:34:34.903Z	3d01ad34-1760-1c18-4042-4088ba5fffa1	Hi, there!
...
```

## 参考: 本番のコードにテストコードとの分岐ロジックを書かない

プロダクションのコードにテストコードとの分岐のロジックを書くのは良くないやり方です。
上記の例のように、S3 などの外部リソースは **依存性を外部から注入する** やり方でテストを書きましょう[[3]]。

プロダクションのコードとテストコードでS3の実物とlocalstackのエンドポイントを渡すようにします。

#### 本番のコード

```js:service/getS3Object.js

module.exports = async ({s3, event, callback}) => {
    const params = {
      Bucket: event.Bucket,
      Key: event.Key
    };
    const ret = await s3.getObject(params).promise();
    const message = ret.Body.toString();
    return message;
}
```

```js:app.js
const AWS = require('aws-sdk');
const s3 = new AWS.S3();
const getS3Object = require('./service/getS3Object');

// handlerのコード
exports.lambdaHandler = async (event, context, callback) => {
  try {
    const message = await getS3Object({s3, event, callback});
    console.log(message);
  } catch (err) {
    console.log(err);
    return err;
  }
};
```

#### テストコード

```js:test/getS3ObjectTest.js
const AWS = require('aws-sdk');
const config = {
  endpoint: 'http://localstack:4572',
  s3ForcePathStyle: 'true',
}
const s3 = new AWS.S3(config); // use localstack-version's S3
const getS3Object = require('./service/getS3Object');
const assert = require('power-assert');

// event を作る
event = {
  Bucket: 'test-bucket',
  Key: 'message.txt'
}

// S3 bucket を作る
// ...（省略）...
// S3 object をコピーする
// ...（省略）...

// getS3Object のテスト
it ("you can get proper s3 object", async () => {
    const message = await getS3Object({s3, event, callback});
    assert(message === 'Hi, There');
});

// S3 object を削除する
// ...（省略）...
// S3 bucket を削除する
// ...（省略）...
```

## 終わりに

SAM Local と localstack を使って Docker Container 上に起動した lambda function から local stack 上に起動した S3 に疎通することができました。

ただ、結局のところ以下のような手順を毎回実行するので、開発サイクルの高速化はさほどのぞめませんでした（AWSコンソール上でソースコード修正 => テスト実行のほうがむしろ早いかも）。

1. docker image のビルド (`sam build --use-container`)
2. docker container の起動 (`sam local invoke`)

CI化したときにS3などの外部リソースを含むテストをテスト環境で一貫して行えるのは良いと思いました。

## 参考資料

* [1] [LocalStackをつかってローカルでLambdaを実行してみた][1]
* [2] [AWS SAM Local と LocalStack を使って ローカルでAWS Lambdaのコードを動かす][2]
* [3] [Testable Lambda: Working Effectively with Legacy Lambda, p.37][3]
* [4] [dwyl/aws-sdk-mock][4]
* [5] [localstack/localstack][5]

[1]: https://dev.classmethod.jp/cloud/aws/localstack-lambda/
[2]: https://dev.classmethod.jp/etc/sam-local-with-localstack/
[3]: https://speakerdeck.com/twada/testable-lambda-working-effectively-with-legacy-lambda?slide=37
[4]: https://github.com/dwyl/aws-sdk-mock
[5]: https://github.com/localstack/localstack