😸

boto3をlocalstack環境でpytestする

2022/09/01に公開

先月localstackがついにv1.0になりました。
Announcing LocalStack 1.0 General Availability!

上記ブログを読むとGAに伴う機能強化も色々あり、

  • LocalStack Extensionsを使うとLocalStackの応答をフックして任意のコードを実行できる
  • データの永続性を定義しやすくなった
  • ロギング強化

など、他にもLocalStack自体の開発サイクルを改善したことにも触れています。
これから自分もLocalStackを使う機会は増えそうだな、と再認識したので今回はboto3をpytestする単純なものから。

前提

[packages]
localstack = "*"
pytest = "*"
awscli-local = "*"
boto3 = "*"
botocore = "*"
$ grep -A4 localstack ~/.aws/config 
[profile localstack]
source_profile = localstack
region = ap-northeast-1
output = json

$ grep -A4 localstack ~/.aws/credentials 
[localstack]
region = ap-northeast-1
aws_access_key_id=local
aws_secret_access_key=local

ソースコード

ある程度の複雑さが欲しかったのでOSSから拾ってきました。
(AWS SDK for Python code examples)[https://github.com/awsdocs/aws-doc-sdk-examples/tree/main/python]

テストコード実装の選択肢

botocore.stub.Stubber

boto3ではStubberクラスが提供されているのでそれを使ってテストコードも可能ですが、今回は紹介だけに留めます。AWS純正のテストツールなんですがコードの巨大さがよく分かります...

LocalStack

コンテナ環境(http://localhost:4566)を使ってAWSサービスを再現してくれるため、リクエストをstubやmockするより(コンテナに対して)実際にリクエスト応答を実行してAWSリソースのテストが可能です。

個人的にはLocalStackの開発チームが言っている the broken cloud software development model を直すという方針が好きです。この記事は実感わきました。
https://localstack.cloud/blog/2021-05-06-localstack-40k-stars/

今回のテストコード実装例

アプリの実行環境ごとにソースコードでboto3.session.Sessionendpoint_urlを切り替える方法もあり得ますが。が、テストコード導入のためにソースコード変更の必要が...という辛さもあるので今回は選択肢から外しました。

ソースコード

https://github.com/awsdocs/aws-doc-sdk-examples/blob/main/python/example_code/dynamodb/GettingStarted/scenario_getting_started_movies.py

conftest.py

今回の例では

export AWS_REGION=ap-northeast-1
export AWS_PROFILE=localstack
export localstack_endpoint="http://localhost:4566"

がテストコードに必要な環境変数です。

import os
import pytest
import boto3
from botocore.config import Config

AWS_PROFILE = os.getenv('AWS_PROFILE')
AWS_REGION = os.getenv('AWS_REGION')
ENDPOINT = os.getenv('localstack_endpoint')


@pytest.fixture(scope='session', autouse=True)
def dynamodb_local():
    boto3.setup_default_session(profile_name=AWS_PROFILE)
    dynamodb = boto3.resource('dynamodb', region_name=AWS_REGION, endpoint_url=ENDPOINT)

    # return dynamodb
    # モックしたいだけなのでどちらでも可
    yield dynamodb

## test_scenario_getting_started_movies.py
先に挙げたscenario_getting_started_moviesに対するテストコードです。

from unittest import mock

import boto3

import scenario_getting_started_movies
from scenario_getting_started_movies import run_scenario

def test_run_scenario(dynamodb_local):
    movies_file = 'test.json'
    test_Movies = scenario_getting_started_movies.Movies(dynamodb_local)

    with mock.patch('scenario_getting_started_movies.Movies', return_value=test_Movies):
        result = run_scenario('doc-example-table-movies', movies_file, boto3.resource('dynamodb'))

run_scenarioは引数にboto3クライアントを渡すのでdynamodb_localをそのまま使えばお終いでしたが、上記では少しだけ汎用的な書き方を書き方をしました。

  • mock.patchでMoviesクラスをlocalstack用にモック
  • withブロック内でモックされたオブジェクト値を使ってソースコードを実行

実行すると次のようになりました。

$ python -m pytest -s -v test/test_scenario_getting_started_movies.py
======================================================================================================================================================= test session starts ========================================================================================================================================================
platform darwin -- Python 3.8.9, pytest-7.1.2, pluggy-1.0.0 -- **/.venv/bin/python
cachedir: .pytest_cache
rootdir: **/aws-doc-sdk-examples/python/example_code/dynamodb/GettingStarted
collected 1 item

test/test_scenario_getting_started_movies.py::test_run_scenario ----------------------------------------------------------------------------------------
Welcome to the Amazon DynamoDB getting started demo.
----------------------------------------------------------------------------------------
Enter the title of a movie you want to add to the table: abc
What year was it released? 2999
On a scale of 1 - 10, how do you rate it? 0
0.0 must be between 1 and 10.
On a scale of 1 - 10, how do you rate it? 1
Summarize the plot for me: Secret!!

Added 'abc' to 'doc-example-table-movies'.
----------------------------------------------------------------------------------------

Let's update your movie.
You rated it 1.0, what new rating would you give it? 1
You summarized the plot as 'Secret!!'.
What would you say now? Not say again..

Updated 'abc' with new attributes:
{'info': {'plot': 'Not say again..', 'rating': Decimal('1.0')}}
----------------------------------------------------------------------------------------
----------------------------------------------------------------------------------------
Let's move on...do you want to get info about 'The Lord of the Rings: The Fellowship of the Ring'? (y/n) n
----------------------------------------------------------------------------------------

Let's get a list of movies released in a given year. Enter a year between 1972 and 2018: 2018
I don't know about any movies released in 2018!
Try another year? (y/n) n
----------------------------------------------------------------------------------------

Let's remove your movie from the table. Do you want to remove 'abc'? (y/n)n
----------------------------------------------------------------------------------------

Delete the table? (y/n) n
Don't forget to delete the table when you're done or you might incur charges on your account.

Thanks for watching!
----------------------------------------------------------------------------------------
PASSED

=================================================================================================================================================== 1 passed in 69.32s (0:01:09) ===================================================================================================================================================

備考

AWSサービスの初学者にlocalstack環境はすごく良さそう。
docker-composeやテストコードの学習コストが大したことないなら、実際にAWSアカウントで試行錯誤するより気軽にアプリ開発を試せます。

Discussion