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

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

boto3 は AWS SDK の Python パッケージで、アプリケーションから Dynamo DB といった AWS のサービスを呼び出すときに使います。

この boto3 には moto という mock パッケージがあります。

moto を使うと テストを実行するさいにテスト用のデータ(fixtures)を AWS SDK に戻り値として返させることができるので、Dynamo DB にデータを登録せずにテストを通すことができます。

あわせて、factory_boy というパッケージを使うことで Factory オブジェクトに fixtures を組み立てさせることができます。

本稿では、moto を使って Dynamo DB に依存せずにアプリケーションをテストする方法を紹介します。

さらに、factory_boy を使って fixtures の組み立てを担うコードを管理しやすく保つ方法も紹介します。

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


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

まずは、テスト対象とする API Server について説明します 。

このAPIはチャット用であり、次のふたつのエンドポイントがあるとします:

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

# 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 に追加します:

# 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 を用いると次のとおりに書けます:

# 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 テーブルに idff86c522-a08e-4a2c-a222-80e62c9c059b となるようアイテムを登録しておかなればなりません。

ここでは、スタブ化したテーブルにアイテムを登録しておくことでテストケースをパスさせます。

GET のときと同様、テストケースに用いるコンテキストを conftest.py に追加します:

違いは、スタブ化したコンテキストのなかでテーブルに id: ff86c522-a08e-4a2c-a222-80e62c9c059b として複数のアイテムを登録することです。

# 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 を継承します。

このとき 内部クラス Metamodel フィールドとして組み立て対象のクラスを定義します。

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

# 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 では idmessage_id の値に uuid の擬似データを利用しています。

利用できる擬似データの詳細は、ドキュメントFakerのコード を参照してください。

クラスメソッドの dict_factory()dict を組み立てる factory オブジェクトを生成します。

これを、Dynamo DB のテーブルにアイテムを登録するさいの辞書オブジェクトの組み立てに使います。

なお、レシピに記載ある方法だと Sequence による値の生成に問題があったため、BaseFactory の クラスメソッド attributes() を転用しています。

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

# 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 を導入するにあたっては制限事項がいくつかあります:


まとめ

本稿では、moto を用いて Dynamo DB をスタブ化しユニットテストする方法をサンプルコードとともに説明しました。

制限事項にアプリケーションコードが引っかからないのであれば、localstack など他のアプローチと比べても所要時間や費用の面で有利な点があると思います。


おまけ

fixtures を組み立てるさいに その属性をテストケースのほうから指定する方法をサンプルコードに載せておきました。

テストケースで指定できると conftest.py を変更したさいにテストがその影響を受けにくくなって良いと思います。


サンプルコード

kumapo/testing-dynamodb-app-with-moto-and-factoryboy

コメント

このブログの人気の投稿

投稿時間:2021-06-17 05:05:34 RSSフィード2021-06-17 05:00 分まとめ(1274件)

投稿時間:2021-06-20 02:06:12 RSSフィード2021-06-20 02:00 分まとめ(3871件)

投稿時間:2020-12-01 09:41:49 RSSフィード2020-12-01 09:00 分まとめ(69件)