pytestでawsサービスの単体テストを書くときはmotoが便利という話

# はじめに
awsサービスを利用した単体テストを書く時にいいライブラリはないか探していた際に見つけた物をメモしておきます。
この記事は、[公式ドキュメント](http://docs.getmoto.org/en/latest/index.html)の書き方を紹介した後に軽いサンプルテストを書き残してあります。

また、全コードは[私のリポジトリ](https://github.com/abemaru/my_moto_unittest_demo)にあるので記述していない部分はそちらを参照してください。

# motoとは
motoはAWSサービスを利用した単体テストを簡単にモックアウトできるライブラリです。aws公式のライブラリではないのでAWSサービス全網羅というわけではありませんが、私が使っている範囲では不自由ありませんでした。[(motoのAWSサービス対応表)](https://github.com/spulec/moto/blob/master/IMPLEMENTATION_COVERAGE.md)

例としてs3にデータを投入する簡単なコードのテストを考えます。
```sample.py
import boto3

class MyModel(object):
    def __init__(self, name, value):
        self.name = name
        self.value = value

    def save(self):
        s3 = boto3.client('s3', region_name='us-east-1')
        s3.put_object(Bucket='mybucket', Key=self.name, Body=self.value)

```

[公式](http://docs.getmoto.org/en/latest/docs/getting_started.html)を見ると3つの書き方があるようです。
- Decorator
- Context Manager
- Raw

pytestと組み合わせた時には個人的に以下のように使えると思いました。

| 書き方 | pytestでの使い方 |
| --- | --- |
| decorator | テスト内でサービスを指定したい時 |
| context manager | fixtureなどで事前にdbやバケットを準備しておきたい時 |
| raw | サービスがない時のエラーハンドリング？(いい利用方法があれば逆に教えてください) |

以下書き方の紹介です。

## Decorator
`@mock_s3`のように書く方法です。
後述しますが、pytestのテストを実行する部分でバケットやテーブルを作成するときによさそうな書き方です。

s3サービスをmockする場合は、`@mock_s3`を関数の前に呼び出してあげましょう。デコレータを直前に呼び出した関数で使うs3サービスはmockされます。

```test_sample_1.py
import boto3
from moto import mock_s3
from mymodule import MyModel

@mock_s3
def test_my_model_save():
    conn = boto3.resource('s3', region_name='us-east-1')
    # We need to create the bucket since this is all in Moto's 'virtual' AWS account
    conn.create_bucket(Bucket='mybucket')

    model_instance = MyModel('steve', 'is awesome')
    model_instance.save()

    body = conn.Object('mybucket', 'steve').get()[
        'Body'].read().decode("utf-8")

    assert body == 'is awesome'
```

## Context Manager
Context Managerでは`with mock_s3():`でくくった部分内でs3サービスがモックされます。

後述しますが、pytestのfixture部分に書く時はcontext managerを使うのが便利そうです。

```test_sample_2.py
def test_my_model_save():
    with mock_s3():
        conn = boto3.resource('s3', region_name='us-east-1')
        conn.create_bucket(Bucket='mybucket')

        model_instance = MyModel('steve', 'is awesome')
        model_instance.save()

        body = conn.Object('mybucket', 'steve').get()[
            'Body'].read().decode("utf-8")

        assert body == 'is awesome'
```

## Raw
Rawの書き方はmockの開始と終了を手動で指定することができます。

`mock.stop()`を描き忘れないように注意しないといけないですが、開始と終了のタイミングを自由に指定できます。
```
def test_my_model_save():
    mock = mock_s3()
    mock.start()

    conn = boto3.resource('s3', region_name='us-east-1')
    conn.create_bucket(Bucket='mybucket')

    model_instance = MyModel('steve', 'is awesome')
    model_instance.save()

    body = conn.Object('mybucket', 'steve').get()[
        'Body'].read().decode("utf-8")

    assert body == 'is awesome'

    mock.stop()
```

# pytestで実践
公式の書き方がわかったので、dynamodbのテーブルが作成されていることを確認するテストをcontext managerとdecoratorで試します。
## 環境構築
筆者は`pipenv`を使った環境構築が楽で好きなので`pipenv`を使います。
```requirements.txt
boto3
moto
pytest
```
`requirements.txt`を準備して`pipenv install`を行います。
```terminal
% pipenv --python 3.9
% pipenv install -r requirements.txt
```
自分が書いたコードの階層構造は以下のようになっています。
```terminal
% tree
.
├── Pipfile
├── Pipfile.lock
├── README.md
├── requirements.txt
├── sample
│   ├── __init__.py
│   └── lambda_function.py
└── test
    ├── conftest.py
    ├── test_main.py
    └── utils.py
```

## conftestでdynamodbのテーブルを準備する
pytestのfixureという機能を使えばテスト実行のための前処理を外だしすることができます。
```conftest.py
import os
import pytest

import boto3
from moto import mock_dynamodb

def set_env():
    """公式推奨のenv設定
    万が一にも本番環境へ変更が当たらないようにする
    """
    os.environ['AWS_ACCESS_KEY_ID'] = 'test'
    os.environ['AWS_SECRET_ACCESS_KEY'] = 'test'
    os.environ['AWS_DEFAULT_REGION'] = 'ap-northeast-1'
    os.environ['AWS_SECURITY_TOKEN'] = 'test'
    os.environ['AWS_SESSION_TOKEN'] = 'test'


@pytest.fixture
def create_db_by_context_manager():
    set_env()

    with mock_dynamodb():
        dynamodb = boto3.client('dynamodb')
        table = dynamodb.create_table(
            TableName='test_table',
            KeySchema=[
                {
                    'AttributeName': 'id',
                    'KeyType': 'HASH'
                }
            ],
            AttributeDefinitions=[
                {
                    'AttributeName': 'id',
                    'AttributeType': 'S'
                },
            ],
            ProvisionedThroughput={
                'ReadCapacityUnits': 10,
                'WriteCapacityUnits': 10
            }
        )

        yield table
```
確認のため、fixtureで作ったtest_tableが作成されているかを確認します。

```test_main.py
...

def test_context_managerでtest_tableが作られているか確認する(create_db_by_context_manager):
    dynamodb_client = boto3.client('dynamodb')
    res = dynamodb_client.describe_table(TableName='test_table')
    assert get_table_name_from_res(res) == 'test_table'

...
```

## テスト内部でdynamodbを準備する
次はデコレータでテスト内部でdynamodbをモックすることを考えます。

dynamodbのmockを利用するテストで`@mock_dynamodb`を呼び出して使います。デコレータを使う場合は、デコレータを使った部分でテーブルを作る必要があります。
```test_main.py
@mock_dynamodb
def test_decoratorでtest_tableが作られているか確認する():
    create_table()
    dynamodb_client = boto3.client('dynamodb')
    res = dynamodb_client.describe_table(TableName='test_table')
    assert get_table_name_from_res(res)  == 'test_table'
```

# さいごに
motoを使えば簡単にawsサービスを使ったアプリケーションの単体テストができそうです。世の中便利なライブラリがたくさんあって助かります。