👏

Python の単体テストで AWS のモックをサクッと導入したいなら moto を使え!

2023/02/04に公開

はじめに

こんにちは。@hayata-yamamoto です。
Pythonista の皆さんは、AWS 関連の開発をする際にこんなことを感じたことはありませんか?

AWS のリソースを自分の環境に立てるのマジでだるい

とはいえ、ローカル含む、自分の作業環境でアプリケーション開発するためには、AWS のモックサービスを使わざるを得ない状況も多いのではないでしょうか。そう言った場合、docker-compose.yml などを書き、seed データの追加用シェルなどを書く、 docker-compose up -d で起動して、開発時やテスト時に使う。そんな運用をされている方もいると思います。

このやり方はオーソドックスで、定番であるものの、準備がとにかくめんどくさい という欠点を抱えていると感じています。すでに、別のどこかで使ったコードを持っている、docker-compose を書くのが好きだ、など積極的な動機があれば問題ないですが、それ以外の場合は正直内心「めんどくさいけど、品質のために仕方ないからやるかぁ」と思ってしまいます。(少なくとも私はそうです)

弊社では、Python を使って、Serverless Framework でデプロイされる多数の Lambda 関数を実装しています。DynamoDB や OpenSearch, S3 など外部のリソースと API を通じてコミュニケーションすることも多くなっています。データの読み出しや書き込みの処理を単体テストする必要があります。また、バッチなどを書くときも同様に、手元でテスト用のデータベースを作成しテストをすることがあります。スタートアップゆえ、少なく、限られたエンジニアで毎日開発をしていく中では、AWS のモックアップをサクッと容易できるツールが求められていました。その中で、我々が利用しているのが、今回紹介する moto です。

moto について

moto は

A library that allows you to easily mock out tests based on AWS infrastructure.

とあるように、テスト用途で使用する AWS のインフラを容易に構築するためのライブラリです。AWS のリソース全てを網羅はしていません[1]が、DynamoDB や S3, SQS など Lambda と一緒に使われやすいリソースについてはカバーをしています。docker-compose を書くことなく、単体テストに AWS リソースのモックアップを追加できます。やることは、モックで使用したいリソース名を pip install の時に extra で指定するだけ。

# EC2, S3 のモックをインストールする時の例
pip install moto[ec2,s3]

# 全てのモックをインストールする時
pip install moto[all]

moto の使い方

moto の利用方法は 3 つあります。

  • Decorator
  • Context Manager
  • Raw

使い方は公式に例がいくつか出ています。
https://github.com/spulec/moto#in-a-nutshell

それぞれの用法の良し悪しを私なりにまとめるとこんな感じになります。

用法 良い点 悪い点
Decorator 書く量が少ない モックの起動・停止を柔軟に決めるのが難しい
Context Manager with で細かくスコープを分けられ、終了処理を書く必要がない。 モックの数が多くなると工夫が必要。インデントが深くなりやすい
Raw 起動と停止の自由度が高い。fixture や setUp,tearDown 関数などと合わせて使える モックの起動と停止のタイミングが複雑になりやすい。停止処理を忘れる

ざっくりいうとこんな感じで決めるといいです。

以下では、各用法の簡単な解説とちょっとした Tips を紹介します。

Decorator

Decorator での書き方は、以下のようになります[2]

@mock_s3
def test_my_model_save():
    # Create Bucket so that test can run
    conn = boto3.resource('s3', region_name='us-east-1')
    conn.create_bucket(Bucket='mybucket')
    model_instance = MyModel('steve', 'is awesome')
    model_instance.save()
    body = conn.Object('mybucket', 'steve').get()['Body'].read().decode()

    assert body == 'is awesome'

デコレーターで、テスト自体をラップしてしまって、そのスコープ内では常にモックが使えるようにする書き方です。デコレーターを複数重ねることで、複数のモックアップを同時に起動できます。unittest.mock をデコレーターで使っている人には、馴染みの深い書き方かもしれません。

複数渡すとき
@mock_s3
@mock_sqs
@mock_ssm
def test_my_model_save():
    pass

この書き方を利用するのは以下のような状況が考えられそうです。

Context Manager

Context Manager での書き方は、以下のようになります[3]

def test_my_model_save():
    with mock_s3():
        conn = boto3.resource('s3', region_name='us-east-1')
        conn.create_bucket(Bucket='mybucket')
        model_instance = MyModel('steve', 'is awesome')
        model_instance.save()
        body = conn.Object('mybucket', 'steve').get()['Body'].read().decode()

        assert body == 'is awesome'

個人的には一番好きな書き方です。with でスコープを明示的に区切り、そのスコープ内ではモックアップが起動されるようにした書き方です。with を抜けるとモックアップが停止するため、意図しないタイミングでモックが起動することを避けることができます。複数のモックアップを起動するときは、カンマ区切りで渡す方法と、contextlib.ExitStack[4] を使う方法があります。

カンマ区切りで渡す
def test_my_model_save():
    with mock_s3(), mock_sqs(), mock_ssm():
        ....
contextlib.ExitStack を使う
def test_my_model_save():
    with ExitStack() as stack:
        stack.enter_context(mock_s3())
        stack.enter_context(mock_sqs())
        stack.enter_context(mock_ssm())

Context Manager の記法は以下の時に優れていそうです。

Raw

moto を自分で起動し、停止する場合は以下のように書きます[5]

def test_my_model_save():
    mock = mock_s3()
    mock.start()

    conn = boto3.resource('s3', region_name='us-east-1')
    conn.create_bucket(Bucket='mybucket')

    model_instance = MyModel('steve', 'is awesome')
    model_instance.save()

    assert conn.Object('mybucket', 'steve').get()['Body'].read().decode() == 'is awesome'

    mock.stop()

start, stop を用いて、任意のタイミングでモックの起動・停止を行う書き方です。定番の使われ方は、unittest.TestCasesetUptearDown を書く時でしょう。[6]

unittest での利用例
import unittest
from moto import mock_s3
import boto3

def func_to_test(bucket_name, key, content):
    s3 = boto3.resource('s3')
    object = s3.Object(bucket_name, key)
    object.put(Body=content)

class MyTest(unittest.TestCase):
    mock_s3 = mock_s3()
    bucket_name = 'test-bucket'
    def setUp(self):
        self.mock_s3.start()

        # you can use boto3.client('s3') if you prefer
        s3 = boto3.resource('s3')
        bucket = s3.Bucket(self.bucket_name)
        bucket.create(
            CreateBucketConfiguration={
                'LocationConstraint': 'af-south-1'
            })

    def tearDown(self):
        self.mock_s3.stop()

    def test(self):
        content = b"abc"
        key = '/path/to/obj'

        # run the file which uploads to S3
        func_to_test(self.bucket_name, key, content)

        # check the file was uploaded as expected
        s3 = boto3.resource('s3')
        object = s3.Object(self.bucket_name, key)
        actual = object.get()['Body'].read()
        self.assertEqual(actual, content)

複数のモックを起動したいときは純粋に、必要分だけ start, stop を書くだけです。

複数渡すとき
def test_my_model_save():
    mock1 = mock_s3()
    mock1.start()

    mock2 = mock_sqs()
    mock2.start()

    mock3 = mock_ssm()
    mock3.start()

    ....

    mock1.stop()
    mock2.stop()
    mock3.stop()

この用法は、以下のような時に優れていそうです。

おわりに

本記事では、moto を利用し、単体テストで AWS リソースのモックをサクッと導入する方法をご紹介しました。OpenSearch など一部対応していないリソースについては、docker-compose などを用いてローカル環境を整える必要はありますが、「とりあえず単体テストにモックを導入したい!」と言ったシーンにはぴったりな選択肢かなと考えています。今回は紹介しませんでしたが、moto には Stand-alone Server Mode [7]があり、必ずしも Python のコードを書かなくても良かったりもします。

トドケールでは、一緒に働いてくれるエンジニアを募集しています!

https://todoker.notion.site/efc2eea5eb054b6e8757fa3553af58d1

脚注
  1. https://github.com/spulec/moto/blob/master/IMPLEMENTATION_COVERAGE.md ↩︎

  2. https://github.com/spulec/moto#decorator ↩︎

  3. https://github.com/spulec/moto#context-manager ↩︎

  4. https://docs.python.org/ja/3/library/contextlib.html#contextlib.ExitStack ↩︎

  5. https://github.com/spulec/moto#raw-use ↩︎

  6. https://github.com/spulec/moto#example-on-unittest-usage ↩︎

  7. https://github.com/spulec/moto#stand-alone-server-mode ↩︎

Discussion