Dynamo DB ベースのアプリケーションをmotoとfactory_boyを用いてユニットテストする

[boto3](https://github.com/boto/boto3) は AWS SDK の Python パッケージで、Dynamo DB などの AWS のサービスを呼び出すのに使います。

この boto3 には [moto](https://github.com/spulec/moto) という mock パッケージがあります。
moto を使うと、テストを実行するとき Dynamo DB のスタブにテスト用のデータ(fixtures)を 返させることができるので、テーブルが存在していなくてもテストケースを通すことができます。

本稿では、moto を使って Dynamo DB に依存せずにアプリケーションをテストする方法を紹介します。
さらに、[factory_boy](https://github.com/FactoryBoy/factory_boy) を使って fixtures の組み立てを共通化する方法も紹介します。
factory_boy は fixtures の組み立てを担う Factory クラスを備えたパッケージです。

なお、本稿ではアプリケーションとして Flaskベースの API Server を、テストフレームワークには pytest を例にとって説明します。

## moto を用いて POST リクエストをテストする

まずは、テスト対象とする API Server について説明します 。
このAPIはチャット用であり、次のふたつのエンドポイントがあるとします:

- POST /chats
- GET /chats/_chat_id_

このうち、`POST /chats` のテストケースは pytest を用いると次のとおりに書けます:

```Python3
# tests/test_application.py

def test_post_chats(mocked_client):
    # fixtures
    messages = [{
        'message_id': '9f64eaf2-ecbe-41cb-ab88-b27234598fde',
        'message': 'Have ever been to NYC?',
        'message_date': '2018-12-05T13:26:28.175895',
        'username': 'ksr' 
    }, {
        'message_id': '5f9ec6c4-44f7-43c9-bbf9-ed622a2712dd',
        'message': "Yes, I was born there",
        'message_date': '2018-12-05T13:27:47.132019',
        'username': 'bro' 
    }]
    res = mocked_client.post('/chats', json=messages)
    assert len(res.json) == 2
```

このテストケースをパスさせるには、Dynamo DB と `chats` テーブルをセットアップしなければなりません。
しかし、`POST` のテストケースには Dynamo DB への副作用があるので、開発環境もしくはテスト環境を用意する必要があります。
しかも、AWS に接続できない CI環境などではテストをパスさせることができない恐れもあります。

そこで、moto を用いて テストのさいには Dynamo DB にテーブルが登録されているよう boto3 の振る舞いを切り替えます。
moto パッケージの `mock_dynamodb2()` を使って Dynamo DB をスタブ化した上で、テスト用のテーブルをセットアップしておきます。
同じコンテキストのなかで `yield` を呼んでおき、スタブ化したコンテキストでテストケースを実行させます。
そうすると、テストのさいに boto3 がスタブ化した Dynamo DB を参照するようになります。

さきほどのテストケースに用いるコンテキストを `conftest.py` に追加します:

```Python3
# tests/conftest.py

@pytest.fixture()
def client():
    return application.app.test_client()

@pytest.fixture()
def mocked_client(client):
    # use stub for dynamo db
    with mock_dynamodb2():
        conn = boto3.resource('dynamodb')
        # create a table for the stub
        table = conn.create_table(
            TableName="chats",
            KeySchema=[{'AttributeName':'message_id','KeyType':'HASH'}],
            AttributeDefinitions=[{'AttributeName':'message_id','AttributeType':'S'}],
            ProvisionedThroughput={'ReadCapacityUnits':5,'WriteCapacityUnits':5})
        # run a testcase in stubbed context
        yield client
```

いっぽう、さきほどのテストケースはあたらしい `mock_client` を引数にとるよう修正するだけです。
実行すると テストケースはパスするようになりました:

```
(venv) dynamodb-app $ pytest tests/ -v
============================= test session starts ==============================

tests/test_application.py::test_post_chats PASSED                        [100%]
```

## moto を用いて GET リクエストをテストする

つぎに、`GET /chats/_chat_id_` のテストケースは pytest を用いると次のとおりに書けます:

```Python3
# tests/test_application.py

def test_get_messages_given_id(client):
    res = mocked_client_having_messages_in_chat_literally.get('/chats/ff86c522-a08e-4a2c-a222-80e62c9c059b')
    assert len(res.json) == 2
```

このテストケースをパスさせるには、`chats` テーブルに `id` が `ff86c522-a08e-4a2c-a222-80e62c9c059b` となるようアイテムを登録しておかなればなりません。
ここでは、スタブ化したテーブルにアイテムを登録しておくことでテストケースをパスさせます。

`GET` のときと同様、テストケースに用いるコンテキストを `conftest.py` に追加します:
違いは、スタブ化したコンテキストのなかでテーブルに `id: ff86c522-a08e-4a2c-a222-80e62c9c059b` として複数のアイテムを登録することです。

```Python3
# tests/conftest.py

@pytest.fixture()
def mocked_client_having_messages_in_chat_literally(client):
    # use stub for dynamo db
    with mock_dynamodb2():
        conn = boto3.resource('dynamodb')
        # create a table for the stub
        table = conn.create_table(
            TableName="chats",
            KeySchema=[{'AttributeName':'message_id','KeyType':'HASH'}],
            AttributeDefinitions=[{'AttributeName':'message_id','AttributeType':'S'}],
            ProvisionedThroughput={'ReadCapacityUnits':5,'WriteCapacityUnits':5})
        # build and insert items into the table
        chat = [{
            'id': 'ff86c522-a08e-4a2c-a222-80e62c9c059b',
            'message_id': '0f64eaf2-fcbe-a1cb-0b88-c27234598fde',
            'message': 'What is the most important thing?',
            'message_date': '2018-12-05T13:28:18.175895',
            'username': 'ksr' 
        }, {
            'id': 'ff86c522-a08e-4a2c-a222-80e62c9c059b',
            'message_id': '199ec6c4-fff7-12c9-0bf9-a1622a2712dd',
            'message': "Security",
            'message_date': '2018-12-05T13:29:37.132019',
            'username': 'bro' 
        }]

        _ = [table.put_item(Item=message) for message in chat]
        # run a testcase in stubbed context
        yield client
```


そのあと テストケースを `mocked_client_having_messages_in_chat_literally` が引数とするよう修正すると、パスします:

```
(venv) dynamodb-app $ pytest tests/ -v
============================= test session starts ==============================

tests/test_application.py:test_get_messages_given_id PASSED                        [100%]
```

moto を用いてテーブルをスタブ化すると、開発環境のテーブルに接続する場合と比べてテストが壊れにくくなります。
別の開発者がテーブルを操作した副作用でテストが通らなくなることがなくなるからです。

## factory_boy を用いて fixture を組み立てる

moto を用いると Dynamo DB に依存せずにテストケースを通すことができますが、
テストケースごとにテスト用のデータが異なると fixtures を組み立てるコードはそれに比例してバリエーションが増えます。

そこで、テスト用のデータを組み立てるさいの共通処理をひとつのクラスにまとめることで、
組み立てにかかるテストケースごとのコードを小さくします。
共通化すれば、テーブルが変更されたさいの影響がテストケースに及ぶのを限定できるメリットもあります。

実装にあたっては、fixture replacement のメジャーなパッケージである factory_boy を利用します。
独自の Factory クラスを実装するには 同パッケージの `Factory` を継承します。
このとき 内部クラス `Meta` の `model` フィールドとして組み立て対象のクラスを定義します。

factory_boy を用いると `ChatFactory` は次のとおり書けます:

```Python3
# tests/factory.py

class ChatFactory(factory.Factory):
    class Meta:
        model = dataclasses.make_dataclass('Chat', ['id', 'message_id', 'message', 'message_date', 'username'])

    id = factory.Faker('uuid4')
    message_id = factory.Faker('uuid4')
    message = factory.Faker('sentence', locale='ja_JP')
    message_date = factory.LazyFunction(datetime.datetime.utcnow().isoformat)
    username = factory.Faker('user_name')

    @classmethod
    def dict_factory(cls, create=False, extra=None):
        # https://github.com/FactoryBoy/factory_boy/blob/master/factory/base.py#L443
        declarations = cls._meta.pre_declarations.as_dict()
        declarations.update(extra or {})
        return factory.make_factory(dict, **declarations)
```

factory_boy を用いるメリットには、fixtures の属性として擬似データが利用できることがあります。
具体的に、`ChatFactory` では `id` と `message_id` の値に uuid の擬似データを利用しています。
利用できる擬似データの詳細は、[ドキュメント](https://factoryboy.readthedocs.io/en/latest/index.html) と [Fakerのコード](https://github.com/joke2k/faker/tree/master/faker/providers) を参照してください。

クラスメソッドの `dict_factory()` は `dict` を組み立てる factory オブジェクトを生成します。
これを、Dynamo DB のテーブルにアイテムを登録するさいの辞書オブジェクトの組み立てに使います。
なお、[レシピ](https://factoryboy.readthedocs.io/en/latest/recipes.html#converting-a-factory-s-output-to-a-dict)に記載ある方法だと `Sequence` による値の生成に問題があったため、`BaseFactory` の クラスメソッド `attributes()` を転用しています。

続いて、`ChatFactory` を用いるようコンテキストのセットアップを修正します:

```Python3
# tests/conftest.py

@pytest.fixture()
def mocked_client_having_messages_in_chat(client):
    # use stub for dynamo db
    with mock_dynamodb2():
        # ..
        # build and insert items into the table
        chats = tests.factory.ChatFactory.dict_factory().build_batch(5, id='ff86c522-a08e-4a2c-a222-80e62c9c059b')
        _ = [table.put_item(Item=chat) for chat in chats]
        # run a testcase in stubbed context
        yield client
```

まず、`dict_factory()`によって 辞書オブジェクトの Factory を取得します。
この Factory に `chats` のアイテムを登録するための辞書を生成させ、スタブ化したテーブルに登録します。
辞書オブジェクトを生成するさいには `id`の値を `build_batch()` に与えて、所望の fixtures を組み立てています。

最後に、テストケースを `mocked_client_having_messages_in_chat` が引数とするよう修正すると、パスします:

```
(venv) dynamodb-app $ pytest tests/ -v
============================= test session starts ==============================

tests/test_application.py:test_get_messages_given_id PASSED                        [100%]
```

## 制限事項

moto を導入するにあたっては制限事項がいくつかあります: 

- boto3 の API のうち[すべてをサポートしていない](https://github.com/spulec/moto/blob/master/IMPLEMENTATION_COVERAGE.md)
- moto によりスタブ化したコンテキスト内では [requests パッケージの API に副作用がある](https://github.com/spulec/moto/issues/1567)

## まとめ

本稿では、moto を用いて Dynamo DB をスタブ化しユニットテストする方法をサンプルコードとともに説明しました。
制限事項にアプリケーションコードが引っかからないのであれば、localstack など他のアプローチと比べても所要時間や費用の面で有利な点があると思います。

## おまけ

fixtures を組み立てるさいに その属性をテストケースのほうから指定する方法をサンプルコードに載せておきました。
テストケースで指定できると `conftest.py` を変更したさいにテストがその影響を受けにくくなって良いと思います。

## サンプルコード

[kumapo/testing-dynamodb-app-with-moto-and-factoryboy](https://github.com/kumapo/testing-dynamodb-app-with-moto-and-factoryboy)