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 を例にとって説明します。
まずは、テスト対象とする API Server について説明します 。
このAPIはチャット用であり、次のふたつのエンドポイントがあるとします:
このテストケースをパスさせるには、Dynamo DB と
しかし、
しかも、AWS に接続できない CI環境などではテストをパスさせることができない恐れもあります。
そこで、moto を用いて テストのさいには Dynamo DB にテーブルが登録されているよう boto3 の振る舞いを切り替えます。
moto パッケージの
同じコンテキストのなかで
そうすると、テストのさいに boto3 がスタブ化した Dynamo DB を参照するようになります。
さきほどのテストケースに用いるコンテキストを
いっぽう、さきほどのテストケースはあたらしい
実行すると テストケースはパスするようになりました:
つぎに、
このテストケースをパスさせるには、
ここでは、スタブ化したテーブルにアイテムを登録しておくことでテストケースをパスさせます。
違いは、スタブ化したコンテキストのなかでテーブルに
そのあと テストケースを
moto を用いてテーブルをスタブ化すると、開発環境のテーブルに接続する場合と比べてテストが壊れにくくなります。
別の開発者がテーブルを操作した副作用でテストが通らなくなることがなくなるからです。
moto を用いると Dynamo DB に依存せずにテストケースを通すことができますが、
テストケースごとにテスト用のデータが異なると fixtures を組み立てるコードはそれに比例してバリエーションが増えます。
そこで、テスト用のデータを組み立てるさいの共通処理をひとつのクラスにまとめることで、
組み立てにかかるテストケースごとのコードを小さくします。
共通化すれば、テーブルが変更されたさいの影響がテストケースに及ぶのを限定できるメリットもあります。
実装にあたっては、fixture replacement のメジャーなパッケージである factory_boy を利用します。
独自の Factory クラスを実装するには 同パッケージの
このとき 内部クラス
factory_boy を用いると
factory_boy を用いるメリットには、fixtures の属性として擬似データが利用できることがあります。
具体的に、
利用できる擬似データの詳細は、ドキュメント と Fakerのコード を参照してください。
クラスメソッドの
これを、Dynamo DB のテーブルにアイテムを登録するさいの辞書オブジェクトの組み立てに使います。
なお、レシピに記載ある方法だと
続いて、
まず、
この Factory に
辞書オブジェクトを生成するさいには
最後に、テストケースを
moto を導入するにあたっては制限事項がいくつかあります:
本稿では、moto を用いて Dynamo DB をスタブ化しユニットテストする方法をサンプルコードとともに説明しました。
制限事項にアプリケーションコードが引っかからないのであれば、localstack など他のアプローチと比べても所要時間や費用の面で有利な点があると思います。
fixtures を組み立てるさいに その属性をテストケースのほうから指定する方法をサンプルコードに載せておきました。
テストケースで指定できると
kumapo/testing-dynamodb-app-with-moto-and-factoryboy
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
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
テーブルに id
が ff86c522-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%]
別の開発者がテーブルを操作した副作用でテストが通らなくなることがなくなるからです。
factory_boy を用いて fixture を組み立てる
moto を用いると Dynamo DB に依存せずにテストケースを通すことができますが、テストケースごとにテスト用のデータが異なると fixtures を組み立てるコードはそれに比例してバリエーションが増えます。
そこで、テスト用のデータを組み立てるさいの共通処理をひとつのクラスにまとめることで、
組み立てにかかるテストケースごとのコードを小さくします。
共通化すれば、テーブルが変更されたさいの影響がテストケースに及ぶのを限定できるメリットもあります。
実装にあたっては、fixture replacement のメジャーなパッケージである factory_boy を利用します。
独自の Factory クラスを実装するには 同パッケージの
Factory
を継承します。このとき 内部クラス
Meta
の model
フィールドとして組み立て対象のクラスを定義します。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)
具体的に、
ChatFactory
では id
と message_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 を導入するにあたっては制限事項がいくつかあります: - boto3 の API のうちすべてをサポートしていない
- moto によりスタブ化したコンテスト内では requests パッケージの API に副作用がある
まとめ
本稿では、moto を用いて Dynamo DB をスタブ化しユニットテストする方法をサンプルコードとともに説明しました。制限事項にアプリケーションコードが引っかからないのであれば、localstack など他のアプローチと比べても所要時間や費用の面で有利な点があると思います。
おまけ
fixtures を組み立てるさいに その属性をテストケースのほうから指定する方法をサンプルコードに載せておきました。テストケースで指定できると
conftest.py
を変更したさいにテストがその影響を受けにくくなって良いと思います。
コメント
コメントを投稿