pytest と moto で優勝する

記事タイトルとURLをコピーする

はじめに

こんにちは、技術4課の保田(ほだ)です。

皆様は現在話題沸騰中の映画 TENET (テネット)をもう観に行かれましたでしょうか?最高に最高ですので、皆様も三密にご注意のうえ是非とも観ていただきたいです。そして考察を語り合いましょう。

というわけで今日は pytest と moto で優勝していきたいと思います。

具体的には DynamoDB への操作を moto でモックして、 pytest で単体テストするサンプルをご紹介します。

前提知識

文章が無限に増えることは避けたいため、以下の知識をお持ちのことを前提とさせていただきます。

  • Python (の基本的な書き方)
  • boto3
  • 単体テストの概念

導入

AWS Lambda を作成する際にテストコードを書くことはとても大事ですよね。 Lambda では往々にして boto3 で様々な AWS リソースを操作する処理(つまり外部の API を叩く処理)が登場します。 そのようなコードに対して単体テストを実施する際には、基本的に外部の API を mock することになります。

今回は DynamoDB にクエリを発行してアイテムを取得するシンプルな処理を mock してみます。

pytest

Python の標準ライブラリに unittest というものがあり、これ自体割と高機能で基本的なことはだいたいできるのですが、それ以外にイカしたツールとして pytest というものがあります。 ハチャメチャに高機能なので慣れるまでは何をやっているか分からなくて少し苦労しますが、すぐに慣れますので今回は pytest を使っていきます。

Lambda の単体テストで利用する場合はデプロイパッケージに含める必要はないので、例えば Pipenv を使っている際は --dev オプションを付けて一緒にデプロイするライブラリと区別しましょう。

$ pipenv install pytest --dev

実行するときはこんな感じです( # 以降はコメントです)

$ pytest  # test_... と名のついたモジュール内の test_... と名のついたテストメソッドを全部実行
$ pytest tests/test_main.py  # モジュール名を指定する
$ pytest tests/test_main.py::test_get_item  # モジュールと関数名を指定

moto

moto というライブラリを使えば boto3 のいろんな操作をまるっと mock することが出来ます。 AWS のサービスや boto3 のメソッドをすべてサポートしているわけではないので、いつも使えるという訳ではないですが、だいたいはサポートされています。

基本的な使い方は GitHub のREADME にもある通り、 boto3 のメソッドを使用している関数に @mock_s3@mock_sns のようにデコレータをくっつけるだけで、 API 操作の向き先を mock の方に切り替えてくれます。 ですが、今回は実装上の都合でデコレータではなく with 文を使って書きます。

こちらもインストールする際は --dev オプションを付けてインストールします。

$ pipenv install moto --dev

状況設定

次のようなディレクトリ構成を想定します。

- src/
    - main.py
- tests/
    - __init__.py
    - test_main.py
    - test_data.yml

前述の用に Pipenv を使用して仮想環境を切った場合は Pipfile や Pipfile.lock が作成されていたり、 Serverless Framework でデプロイをするプロジェクトの場合は serverless.yml が作成されていることもあるかと思いますが、一旦省きます。

テスト対象のコード

手抜きの極みですが、以下のコードをテストしたいとします。

import boto3
import os
 
from boto3.dynamodb.conditions import Key
 
 
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['TABLE_NAME'])
 
 
def handler(event, context):
    id = event['body']['id']
    response = table.query(
        KeyConditionExpression=Key('id').eq(id)
    )
    return response.get('Items')
 

id という変数を event の引数から取り出していますが、これは API Gateway の Lambda プロキシ統合を使うとこんな感じになるのでそのイメージで書いています。とは言えあくまで適当なのであまり気にしないでください。

テストメソッドを書く

test_main.py にテストメソッドを書いていきます。

import pytest
 

@pytest.mark.parametrize(
    "event, expected",
    [
        (
            {'body': {'id': '0001'}},
            [
                {
                    'id': '0001',
                    'name': 'Tanaka',
                    'age': 100,
                    'profile': 'human'
                }
            ]
        ),
        (
            {'body': {'id': '0005'}},
            []
        )
    ]
)
def test_handler(table, event, expected):
    from src.main import handler
    assert expected == handler(event, context=None)
 

ゴツめのデコレータが付属しているのでイカツさがありますが、テストメソッド内でやっていることは単純です。

def test_handler(table, event, expected):
    from src.main import handler
    assert expected == handler(event, context=None)

table, event, expected という引数が渡されています。

event が handler 関数に渡す引数で、 expected が handler 関数の期待する戻り値です。 table という引数はこのあとご説明しますが、フィクスチャーとして定義した DynamoDB の mock オブジェクトです。

気になるデコレータですが、これがまさに pytest で追加された機能となります。

@pytest.mark.parametrize(
    "event, expected",
    [
        (
            {'body': {'id': '0001'}},
            [
                {
                    'id': '0001',
                    'name': 'Tanaka',
                    'age': 100,
                    'profile': 'human'
                }
            ]
        ),
        (
            {'body': {'id': '0005'}},
            []
        )
    ]
)

意味はこのようになっています。

@pytest.mark.parametrize(
    "変数名一覧",
    [
        (変数の具体的な値一覧),
        (変数の具体的な値一覧)
    ]
)
def test_hoge(変数名一覧):
    do_something()

これを実行すると 変数の具体的な値一覧 がリストの要素分だけ代入されます。 仮にこのリストの一つ目の要素を代入したテストに失敗したとしても、そこでテストの実行は継続されます。

具体例がないと分かりづらい可能性もあるので、もう少し詳しくご説明します。 例えば下のように書いてしまうと arg_list の一つ目の要素でテストが失敗すると、そこで処理が止まってしまいます。 そうすると、二つ目の要素については実行されないので、テストが通るの通らないのかが分からなくなってしまいます。

def test_hoge(変数名一覧):
    arg_list = [
        (変数の具体的な値一覧),
        (変数の具体的な値一覧)
    ]
    for item in arg_list:
        do_something(item)

先ほどご説明した @pytest.mark.parametrize() では、この辺りをちゃんと考慮してくれていて、一つのテストケースで予期しない結果が起きても全ケース実行してくれます。嬉しいですね。

ですので、今回の例で言いますと、次の二つのケースが実行されます。

event = {'body': {'id': '0001'}}
expected = [
    {
        'id': '0001',
        'name': 'Tanaka',
        'age': 100,
        'profile': 'human'
    }
]
event = {'body': {'id': '0005'}}
expected = []

フィクスチャーを書く

今回は非常にシンプルなテストなので、あまり考慮しなくても問題ないですが、ある程度の規模になってきた際にテスト用の AWS リソース(ここでは mock )の作成やそこにテストデータを投入したりする処理は毎回テストメソッドの中で書くのではなく、共通化・切り出しをしてフィクスチャーとして定義しておくのが便利です。

pytest では組み込みのフィクスチャーがたくさん用意されており、何気なく pytest を実行しているときに裏で読み込んでくれています。 そしてもちろんフィクスチャーはユーザーが好きに追加できますので、今回のケースでは DynamoDB の mock を用意する処理がありますので、これをフィクスチャー化してみます。

先ほどの test_main.py に追記します。

import boto3
import pytest
import os
import yaml
 
from moto import mock_dynamodb2
 
 
@pytest.fixture(autouse=True)
def set_envs(monkeypatch):
    with open('tests/test_data.yml', 'r', encoding='utf-8') as fp:
        envs = yaml.safe_load(fp)['environment']
 
    for k, v in envs.items():
        monkeypatch.setenv(k, str(v))
 
 
@pytest.fixture()
def table():
    with mock_dynamodb2():
        with open('tests/test_data.yml', 'r', encoding='utf-8') as fp:
            test_data = yaml.safe_load(fp)['DynamoDB']
 
        table_config = test_data['Table']
        dynamodb = boto3.resource('dynamodb')
        # テスト用テーブルの作成
        dynamodb.create_table(
            TableName=os.environ['TABLE_NAME'],
            AttributeDefinitions=table_config['AttributeDefinitions'],
            KeySchema=table_config['KeySchema']
        )
        # テスト用データの格納
        table = dynamodb.Table(os.environ['TABLE_NAME'])
        with table.batch_writer() as batch:
            for item in test_data['Items']:
                batch.put_item(Item=item)
 
        yield table
 

定義した関数に @pytest.fixture() というデコレータを付与すればこれをフィクスチャーとして扱えるようになります。

少し後でご説明しますと言った、テストメソッドの table という引数はこのフィクスチャーをテスト実行時に呼び出すという意味になります。 set_envs のときのように @pytest.fixture(autouse=True) と書けば、テストメソッド側で明示的に引数に指定しなくても自動的にフィクスチャーを実行してくるようになります。

Lambda ではたいてい環境変数を読み込む処理が発生しますが、ここの前準備を set_envs() 関数で実行しています。 また、引数に指定してある monkeypatch は組み込みのフィクスチャーで、これ単体でもかなり遊べますが、今回は setenv という一時的に環境変数をセットするメソッドを使っています。 やっていることとしては test_data.yml というファイルにあらかじめ書いておいたテスト用の環境変数一覧を for 文でモリモリ書き出しています。

ちなみに読み込んでいる test_data.yml の中身は以下のようになっています。(この test_data.yml を使う書き方自体は pytest の標準的な書き方、という訳ではないです)

environment:
  TABLE_NAME: sample-table
DynamoDB:
  Table:
    AttributeDefinitions:
      - AttributeName: id
        AttributeType: S
    KeySchema:
      - AttributeName: id
        KeyType: HASH
  Items:
    - id: '0001'
      name: Tanaka
      age: 100
      profile: human
    - id: '0002'
      name: Suzuki
      age: 1000
      profile: god

environment というキーに TABLE_NAME という変数が定義されていますので、これを set_envs() で環境変数にセットします。

moto だ…!

そしてここでようやく moto が登場します。 コンテキストマネージャとして moto のメソッド mock_dynamodb2() を呼び出しています。 テスト対象のメソッドにこの mock_dynamodb2() をかぶせることで、テストメソッド内の DynamoDB への API 操作の向き先を mock に向けているようなイメージです。

@pytest.fixture()
def table():
    with mock_dynamodb2():
        with open('tests/test_data.yml', 'r', encoding='utf-8') as fp:
            test_data = yaml.safe_load(fp)['DynamoDB']
 
        table_config = test_data['Table']
        dynamodb = boto3.resource('dynamodb')
        # テスト用テーブルの作成
        dynamodb.create_table(
            TableName=os.environ['TABLE_NAME'],
            AttributeDefinitions=table_config['AttributeDefinitions'],
            KeySchema=table_config['KeySchema']
        )
        # テスト用データの格納
        table = dynamodb.Table(os.environ['TABLE_NAME'])
        with table.batch_writer() as batch:
            for item in test_data['Items']:
                batch.put_item(Item=item)
 
        yield table
 

まず、先ほどの test_data.yml からテーブル定義に必要なパラメータの一覧を読み込みます。 そして DynamoDB のリソースを生成し読み込んだパラメータでもって create_table() メソッドを実行してテーブルを作成しますが、実際にはリソースは作成されず、 moto の方でいい感じにあたかもテーブルが存在するかのような状況を用意してくれます。

テーブルを作成したら、次は boto3 の batch_writer を使って test_data.yml に定義したテスト用データをポイポイ格納しています。

なので、このフィクスチャーが実行されることでこんな感じのデータが格納されることになります。

[
  {
    "id": "0001",
    "name": "Tanaka",
    "age": 100,
    "profile": "human"
  },
  {
    "id": "0002",
    "name": "Suzuki",
    "age": 1000,
    "profile": "god"
  }
]

冒頭で、テストメソッドに @mock_dynamodb2() のようにデコレータを付ける形もあるけど実装上の都合で with 文を使うと言っていたのはこの辺りの事情があります。 別の関数として切り出したかったから、というわけです。

テストを実行してみる

今回はテスト対象のコードが単純すぎるがゆえに、テストデータとして moto で用意したデータと期待値が一致するかどうかというちょっと趣旨がずれた感じになってしまいますが、あまり気にせず動作確認してみます。

プロジェクトのルートディレクトリでテストを実行します。

$ pytest
==================================================== test session starts ==================================================== 
platform win32 -- Python 3.8.2, pytest-6.1.0, py-1.9.0, pluggy-0.13.1
rootdir: C:\Users\aaa\moto-sample
collected 2 items                                                                                                             

tests\test_main.py ..                                                                                                  [100%] 

===================================================== 2 passed in 1.24s =====================================================

成功しますね!

せっかくなので失敗するパターンも試してみます。 デコレータに書いた引数と期待値を少し変えます。

{'body': {'id': '0002'}},  # 0001 → 0002 にした
[
    {
        'id': '0001',
        'name': 'Tanaka',
        'age': 100,
        'profile': 'human'
    }
]

テストデータとして用意したものでは id0002 の以下のデータですのできっと期待した結果と合わない、ということになるはずです。

{
  "id": "0002",
  "name": "Suzuki",
  "age": 1000,
  "profile": "god"
}

実行します。

$ pytest
==================================================== test session starts ====================================================
platform win32 -- Python 3.8.2, pytest-6.1.0, py-1.9.0, pluggy-0.13.1
rootdir: C:\Users\aaa\moto-sample
collected 2 items

tests\test_main.py F.                                                                                                  [100%]

========================================================= FAILURES ==========================================================
______________________________________________ test_handler[event0-expected0] _______________________________________________ 

table = dynamodb.Table(name='sample-table'), event = {'body': {'id': '0002'}}
expected = [{'age': 100, 'id': '0001', 'name': 'Tanaka', 'profile': 'human'}]

    @pytest.mark.parametrize(
        "event, expected",
        [
            (
                {
                    'body': {'id': '0002'}
                },
                [
                    {
                        'id': '0001',
                        'name': 'Tanaka',
                        'age': 100,
                        'profile': 'human'
                    }
                ]
            ),
            (
                {
                    'body': {'id': '0005'}
                },
                []
            )
        ]
    )
    def test_handler(table, event, expected):
        from src.main import handler
>       assert expected == handler(event, context=None)
E       AssertionError: assert [{'age': 100,...le': 'human'}] == [{'age': Deci...file': 'god'}]
E         At index 0 diff: {'id': '0001', 'name': 'Tanaka', 'age': 100, 'profile': 'human'} != {'id': '0002', 'name': 'Suzuki', 'age': Decimal('1000'), 'profile': 'god'}
E         Use -v to get the full diff

tests\test_main.py:67: AssertionError
================================================== short test summary info ================================================== 
FAILED tests/test_main.py::test_handler[event0-expected0] - AssertionError: assert [{'age': 100,...le': 'human'}] == [{'age...
================================================ 1 failed, 1 passed in 1.35s ================================================

期待値と合わない、という AssertionError が発生しています。 実際には色がついてもう少し見やすいとはいえ少し出力がごチャットしていますが、 pytest コマンドにはいろんなオプションがあり、イイ感じに選べばもっとオシャレにできます。

ちなみに conftest.py という名前のモジュールを作ってそこにフィクスチャーを書いておけば、 pytest コマンド実行時に、モジュール内にフィクスチャーが定義されていない場合で conftest.py と名のついたモジュールを自動で探して読み込んでくれます。 ここら辺の暗黙(?)の挙動が色々あるのが慣れないと少し翻弄されてしまって難しいところでもありますが…。

おまけ

最近知ったのですが、環境変数の読み込み時に Pipenv でイイ感じにできることを知りました。

.env の自動読み込み

.env ファイルを 作成しておけば、 pipenv shell を実行したときに自動で検出して読み込んで環境変数に書き出してくれるようです。

$ pipenv shell
Loading .env environment variables…
Launching subshell in virtual environment…

ですので、今回のフィクスチャーの一つ set_envs() が無くても .env ファイルに以下の内容を書いておけば、環境変数の TABLE_NAME がセットされます。

TABLE_NAME=sample-table

.env はたいてい個人の環境に依存する、 GitHub などのリポジトリには後悔したくない値などを書き出しておくために使うことが多いので使い方としては少し微妙かもしれませんが、単体で記事にするほどの分量でも無かったのでここにおまけとして書かせていただきました。

まとめ

テストコードってあらかじめ枠が整備されていればあとはある程度機械的に追加していけますが、一から書くとなると結構大変だったりします。 この記事が誰かの参考になれば幸いです。

参考