pytest×motoでモックEC2を使ったテストをしてみた

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

はじめに

pytest × moto を勉強したので、アウトプットのために書きました。

やること

EC2 のインスタンス ID からプライベート IP アドレスを取得するシンプルな関数を、モックEC2をデプロイしてテストします。

前提条件

Pipenv による仮想化

本記事では、Pipenv による仮想化の前提で執筆します。
なお、仮想化までの手順は割愛させていただきます。

ディレクトリ構成

今回は以下構成でディレクトリを用意してください。

.
├── Pipfile
├── Pipfile.lock
├── app
│   ├── __init__.py
│   └── app.py
└── tests
    ├── __init__.py
    └── test_describe_ec2.py

①Pipfile, Pipfile.lock

Pipenv で仮想化した際に自動生成されるファイルです。Pipenv を使わない場合は必須ではありません。

Pipfile の中身は、本記事の コード > Pipfile より取得してください。

②*/__init__.py

各ディレクトリに __init__.py の空ファイルを置いてください。
これは、各ディレクトリをパッケージとして Python に扱わせるために必要です。

ファイルを含むディレクトリをパッケージとして Python に扱わせるには、ファイル __init__.py が必要です。
引用元: https://docs.python.org/ja/3/tutorial/modules.html

③app/app.py

テストしたいコードが格納されます。
今回は EC2 のインスタンス ID からプライベート IP アドレスを取得するシンプルな関数が一つあります。

中身は、本記事の コード > app.py より取得してください。

④tests/test_describe_ec2.py

テストコードが格納されます。

中身は、本記事の コード > test_describe_ec2.py より取得してください。

※テストコードを格納するファイルは、 "test_" など特定のファイル名規則である必要があります。ファイル名が正しくないと pytest がファイルを見つけられません。

In those directories, search for test_.py or _test.py files, imported by their test package name. https://docs.pytest.org/en/latest/explanation/goodpractices.html#test-discovery

モジュールのインストール

本記事の コード > Pipfile にあるモジュール(dev-package も含む)をすべてインストールした状態で、コード実行してください。

(Optional)環境変数の設定

何かの間違いで、実 AWS 環境の認証情報を使ってテストをおこなってしまう可能性があるので以下実行することを推奨します。

$ export AWS_ACCESS_KEY_ID='testing'
$ export AWS_SECRET_ACCESS_KEY='testing'
$ export AWS_SECURITY_TOKEN='testing'
$ export AWS_SESSION_TOKEN='testing'

Ensure that your tests have dummy environment variables set up:
引用元: http://docs.getmoto.org/en/latest/docs/getting_started.html#moto-usage

コード

Pipefile

[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

[packages]
boto3 = "*"

[dev-packages]
moto = "*"
pytest = "*"

[requires]
python_version = "3.7"

app.py

import boto3
import logging

logging.basicConfig(level="INFO")
logger = logging.getLogger(__name__)

ec2=boto3.client('ec2', region_name='ap-northeast-1')

def describe_pip(instance_id):
    logger.info(instance_id)
    # インスタンスIDからPrivateIPAddressを取得
    pip = ec2.describe_instances(InstanceIds=[instance_id])['Reservations'][0]['Instances'][0]['PrivateIpAddress']

    return pip

test_describe_ec2.py

import boto3
from moto import mock_ec2
import logging

logger = logging.getLogger(__name__)

@mock_ec2
def test_describe_pip():
    from app.app import describe_pip

    # モックEC2の立ち上げ
    ec2_client = boto3.client('ec2', region_name='ap-northeast-1')
    ec2 = ec2_client.run_instances(ImageId="hoge", MinCount=1, MaxCount=1)

    instance_id=ec2["Instances"][0]["InstanceId"]
    instance_pip=ec2["Instances"][0]["PrivateIpAddress"]
    logger.info("instance_pip:" + instance_pip)

    # appファイルを実行
    assert describe_pip(instance_id) == instance_pip

pytest の実行

ルートディレクトリにて、以下コマンドを実行することで可能です。

$ pytest

$ pytest --log-cli-level=INFO //ログ出力したいときはこちらで実行


//e.g.
$ tree
.
├── Pipfile
├── Pipfile.lock
├── app
│   ├── __init__.py
│   └── app.py
└── tests
    ├── __init__.py
    └── test_describe_ec2.py

2 directories, 6 files

$ pytest
...[中略]
================================================================= 2 passed, 3 warnings in 1.21s ================================================================

test_describe_ec2.py の解説

test_describe_ec2.py のポイントとなる箇所を解説します。

@mock_ec2
def test_describe_pip():

@で始まる記述は "デコレ―タ" となります。
詳細は省きますが、デコレ―タは、デコレ―タを適用した関数の振る舞いを外から書き換えることが可能です。

今回は mock_ec2 デコレ―タを テスト関数( test_describe_pip() )に適用することで、その振る舞いを書き換えます。
具体的には、テスト関数内の boto3 の API コールが moto ライブラリに用意されたモックオブジェクトを呼び出すように書き換えます。

With a decorator wrapping, all the calls to S3 are automatically mocked out.
引用元: http://docs.getmoto.org/en/latest/docs/getting_started.html#




    ec2_client = boto3.client('ec2', region_name='ap-northeast-1')
    ec2 = ec2_client.run_instances(ImageId="hoge", MinCount=1, MaxCount=1)

boto3 の run_instances により、EC2 を構築します。ここは通常時と同じように定義します。

なお、ImageId は moto が自動で使えるものを用意してくれるので、何を入力しても構いません。

ただし、Warnning が出るので嫌な場合は、
https://github.com/spulec/moto/blob/master/moto/ec2/resources/amis.json から値を選んでください。

PendingDeprecationWarning: Could not find AMI with image-id:hoge, in the near future this will cause an error.
  Use ec2_backend.describe_images() to find suitable image for your test
    PendingDeprecationWarning,




    assert describe_pip(instance_id) == instance_pip

assert 関数は、引数の式を評価して、テストの成否を判断します。

pytest allows you to use the standard python assert for verifying expectations and values in Python tests. 引用元: https://docs.pytest.org/en/6.2.x/assert.html

ここでは、モックEC2のプライベートIPアドレスを評価しています。両辺がイコールであればテストが成功します。
なお、左辺の "describe_pip(instance_id)" が app.py 内の describe_pip() を呼び出しています。


import logging
...[中略]
    logger.info("instance_pip:" + instance_pip)

テストコード内でログ出力を定義することで、pytest の出力結果に任意のログを含ませることが可能です。

ただし INFO ログレベルで結果出力するには --log-cli-level=INFO オプション付きで Pytest コマンドを実行する必要があります。

pytest captures log messages of level WARNING or above automatically and displays them in their own section for each failed test in the same manner as captured stdout and stderr.
...[中略]
You can specify the logging level for which log records with equal or higher level are printed to the console by passing --log-cli-level.
引用元: https://docs.pytest.org/en/6.2.x/logging.html

with ステートメントで書き換える

moto はデコレーターだけでなく、with ステートメントで実行することも可能です。

Same as the Decorator, every call inside the with statement is mocked out.
引用元: http://docs.getmoto.org/en/latest/docs/getting_started.html#

デコレ―タ形式と with ステートメント形式の違いは モックアウトのスコープ にあります。

mock_ec2 のケースだとそれぞれ、

  • デコレ―タ形式: @mock_ec2 が "関数" 内 の 「ec2 に対する boto3 の呼び出し」をモックします
  • with ステートメント: with mock_ec2 が "with ステートメントスコープ" 内の「ec2 に対する boto3 の呼び出し」をモックします




今回作成したテストコードを with ステートメントで書き換えてみました。

test_describe_ec2_ver_with.py

import pytest
import boto3
from moto import mock_ec2
import logging

logger = logging.getLogger(__name__)

@pytest.fixture(autouse=True)
def ec2():
    with mock_ec2():
        # モックEC2の立ち上げ
        ec2_client = boto3.client('ec2',region_name='ap-northeast-1')
        ec2 = ec2_client.run_instances(ImageId="hoge", MinCount=1, MaxCount=1)

        yield ec2

def test_describe_pip(ec2):
    from app.app import describe_pip
    instance_id=ec2["Instances"][0]["InstanceId"]
    instance_pip=ec2["Instances"][0]["PrivateIpAddress"]
    logger.info("instance_pip:" + instance_pip)

    # appファイルを実行
    assert describe_pip(instance_id) == instance_pip

コードの解説

コードのポイントを解説します。

import pytest
...[中略]
@pytest.fixture(autouse=True)
def ec2():

先ほどとは異なり @pytest.fixture デコレ―タを定義します。

こちらも勉強不足で細かいことは言えませんが、 テスト関数が実行される前にテストで必要な諸々のセットアップとクリーンアップをやってくれるデコレータとなります。

autouse は、デコレ―トされた関数の暗黙的な自動実行を制御するパラメータです。True の場合、開発者が明示的にテストしたい関数を指定する必要はなく、pytest がよしなにテストを実行してくれます。

“Autouse” fixtures are a convenient way to make all tests automatically request them. 参考: https://docs.pytest.org/en/6.2.x/fixture.html




@pytest.fixture(autouse=True)
def ec2():
    with mock_ec2():
    ...[中略]
        yield ec2

...[中略]
def test_describe_pip(ec2):

with ステートメントを使用して、モックを呼び出します。

テスト関数(test_describe_pip())は、第一引数にフィクスチャされた関数を指定する必要があります。
これにより、以下の処理が走ります。

  1. ec2() が呼び出される
  2. yield により、test_describe_pip() に引数が渡される。
  3. test_describe_pip() が実行される
  4. test_describe_pip()完了したら ec2() に処理が戻り、その後完了する。

なぜ return ではなく yield を使うのかですが、yield は関数を一時的に停止するためです。
モック EC2 は with ステートメント内でしか利用できないので、with ステートメントを中断することなく、test_describe_pip() を呼び出す必要があります。

ちなみに、当然 yield のインテントを間違えると、モック EC2 が使えなくなることがあります。ぼくはこの間違いに気づかず、小 1 時間無駄にしたので、皆さんの気を付けてください

おわりに

初めてテストコードを学んだので、デコレ―タ周りはまだ全然分かりませんでしたが、最低限の入り口には立てた気がします。
対策前進で勉強したので、次は本で体系的に勉強してみようと思います。

最後になりますが、本記事を執筆する上で弊社ブログの以下記事を参考にしました。ぜひこちらも見てみてください!

blog.serverworks.co.jp

ご覧いただきありがとうございました。

菅谷 歩 (記事一覧)