Python
AWS
Flask
unittest
DynamoDB
AWS #2Day 15

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

boto3 は AWS SDK の Python パッケージで、Dynamo DB などの AWS のサービスを呼び出すのに使います。

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

moto を使うと、テストを実行するとき Dynamo DB のスタブにテスト用のデータ(fixtures)を 返させることができるので、テーブルが存在していなくてもテストケースを通すことができます。

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

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

# 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