かんがるーさんの日記

最近自分が興味をもったものを調べた時の手順等を書いています。今は Spring Boot をいじっています。

resize-image-app-project プロジェクトで作成した AWS Lambda のユニットテストを作成する(local動作版)

概要

記事一覧はこちらです。

S3 にアップロードされた画像ファイルから Lambda でサムネイル画像を生成してみる で作成した AWS Lambda のユニットテストを作成してみます。

前回 deploy するための外部ライブラリを収集するのに lambci/lambda:build-python3.8 という Docker Image を利用したので、おそらく Docker 上で動作確認することもできると思いますが、Testing Serverless Services という Web ページを見つけて local で動作させることもできるようなので試してみます。

参照したサイト・書籍

  1. Testing Serverless Services
    https://towardsdatascience.com/testing-serverless-services-59c688812a0d

  2. Python実践入門 ── 言語の力を引き出し、開発効率を高める WEB+DB PRESS plus

    • unittest モジュールの使い方が記載されていて参考にしました。
  3. spulec / moto
    https://github.com/spulec/moto

  4. What is the fastest way to empty s3 bucket using boto3?
    https://stackoverflow.com/questions/43326493/what-is-the-fastest-way-to-empty-s3-bucket-using-boto3

目次

  1. ディレクトリ名を resize-service → resize_service に変更する
  2. プロジェクトのルートディレクトリ直下に tests ディレクトリを作成してテストのサンプルを作成・実行する
  3. moto モジュールをインストールする
  4. test_resize.py にテストを実装して実行してみる。。。が NoCredentialsError が発生する
  5. NoCredentialsError のエラーを解消する
  6. IntelliJ IDEA から debug 実行してみる
  7. 最後にディレクトリ構成を記載する

手順

ディレクトリ名を resize-service → resize_service に変更する

プロジェクト名に -(ハイフン) が含まれているとテストのファイルから AWS Lambda のファイルを import できません。IDEA で import 文を書いた時に赤波線が表示されて気づきました。

f:id:ksby:20200613110218p:plain

PEP 8 -- Style Guide for Python CodePackage and Module Names には all-lowercase names にするか Underscores が使用できると記載されていますが、ディレクトリ名を _(アンダースコア) に変更して serverless.yml の service 名も同じに変更すると今度は deploy が成功しません。

f:id:ksby:20200613113132p:plain

The stack service name "resize_service-dev" is not valid. A service name should only contain alphanumeric (case sensitive) and hyphens. It should start with an alphabetic character and shouldn't exceed 128 characters. のエラーが出ます。

ディレクトリ名は _(アンダースコア) に変更し(resize_service)、serverless.yml の service 名は -(ハイフン)のままにします(resize-service)。

service: resize-service

..........

ちなみに npx sls create --template aws-python3 --path xxxxxxxx_service と create コマンドに渡すディレクトリ名に _(アンダースコア)を含めた場合、

f:id:ksby:20200613115112p:plain

serverless.yml の service 名は -(ハイフン)になりました。

f:id:ksby:20200613115234p:plain

プロジェクトのルートディレクトリ直下に tests ディレクトリを作成してテストのサンプルを作成・実行する

プロジェクトのルートディレクトリ直下に tests ディレクトリを作成し、その下に __init__.py というファイルを作成します(中身は空)。

test_resize.py というファイルも作成し以下の内容を記述してから、

import unittest


class TestResizeService(unittest.TestCase):
    def test_resize(self):
        None

コマンドプロンプトを起動して以下のコマンドを実行すると、unittest モジュールにテストのファイルが認識されて1件成功します。

  • venv\Scripts\activate
  • python -m unittest -v

f:id:ksby:20200613120422p:plain f:id:ksby:20200613120618p:plain

テストを実行すると tests ディレクトリの下に __pycache__ というディレクトリが作成されますが git で管理する必要のないディレクトリなので .gitignore に __pycache__/ の記述を追加して無視されるようにします。

..........

# Ignore Python unittest
__pycache__/

..........

ちなみに __init__.py というファイルがないと unittest モジュールにテストが認識されません。最初テストのファイルだけ作成して認識されなくて少し悩みました。。。

f:id:ksby:20200613121536p:plain

moto モジュールをインストールする

AWS Service をモック化してくれる moto モジュールをインストールします。

pip install moto を実行します。依存するモジュールがいろいろインストールされるので少し時間がかかります。

f:id:ksby:20200613122935p:plain

test_resize.py にテストを実装して実行してみる。。。が NoCredentialsError が発生する

tests ディレクトリの下にテストで使用する sample.jpg をコピーし、s3_event.json というファイルを新規作成して S3 のイベントの JSON を記述します。

{
  "Records": [
    {
      "eventVersion": "2.1",
      "eventSource": "aws:s3",
      "awsRegion": "ap-northeast-1",
      "eventTime": "2020-06-10T00:45:25.995Z",
      "eventName": "ObjectCreated:Put",
      "userIdentity": {
        "principalId": "AWS:XXXXXXXXXXXXXXXXXXXXX"
      },
      "requestParameters": {
        "sourceIPAddress": "xxx.xxx.xxx.xxx"
      },
      "responseElements": {
        "x-amz-request-id": "11F4BEB2A6E4D924",
        "x-amz-id-2": "k2RJ6yeU8sk5tRp7ntfyM8QDmR58rF1TIdacJimdExhLOOSQ3N6pOtIAWXyk6pGjTeBGOBx995ELqaK53zhdAo6Ky/PZb08e"
      },
      "s3": {
        "s3SchemaVersion": "1.0",
        "configurationId": "66e09001-2a37-4900-9a98-06da126cc896",
        "bucket": {
          "name": "ksbysample-upload-bucket",
          "ownerIdentity": {
            "principalId": "XXXXXXXXXXXXXX"
          },
          "arn": "arn:aws:s3:::ksbysample-upload-bucket"
        },
        "object": {
          "key": "sample.jpg",
          "size": 669286,
          "eTag": "febe0aaf4d817457776b6be293973442",
          "sequencer": "005EE02D28714EE2DC"
        }
      }
    }
  ]
}

test_resize.py にテストを実装します。

import json
import unittest

import boto3
from moto import mock_s3

from resize_service import handler


@mock_s3
class TestResizeService(unittest.TestCase):
    UPLOAD_BUCKET = 'ksbysample-upload-bucket'
    RESIZE_BUCKET = 'ksbysample-resize-bucket'

    def setUp(self):
        s3_client = boto3.client('s3')
        s3_client.create_bucket(Bucket=TestResizeService.UPLOAD_BUCKET)
        s3_client.create_bucket(Bucket=TestResizeService.RESIZE_BUCKET)

    def tearDown(self):
        s3 = boto3.resource('s3')
        upload_bucket = s3.Bucket(TestResizeService.UPLOAD_BUCKET)
        upload_bucket.objects.all().delete()
        upload_bucket.delete()
        resize_bucket = s3.Bucket(TestResizeService.RESIZE_BUCKET)
        resize_bucket.objects.all().delete()
        resize_bucket.delete()

    def test_resize(self):
        s3_client = boto3.client('s3')
        s3_client.upload_file('tests/sample.jpg', TestResizeService.UPLOAD_BUCKET, 'sample.jpg')

        with open('tests/s3_event.json', 'r') as f:
            event = json.load(f)

        handler.resize(event, None)

        thumb_object = s3_client.get_object(Bucket=TestResizeService.RESIZE_BUCKET,
                                            Key='sample_thumb.jpg')
        self.assertEqual(thumb_object['ResponseMetadata']['HTTPStatusCode'], 200)
        self.assertGreater(int(thumb_object['ResponseMetadata']['HTTPHeaders']['content-length']), 0)

        # 生成されたサムネイル画像をダウンロードすることも出来る(実際に作成される)
        # s3_client.download_file(TestResizeService.RESIZE_BUCKET, 'sample_thumb.jpg',
        #                         'tests/sample_thumb.jpg')

python -m unittest -v を実行してみると raise NoCredentialsError のエラーが出てテストが失敗しました。。。

f:id:ksby:20200613134030p:plain f:id:ksby:20200613134137p:plain

NoCredentialsError のエラーを解消する

Web で調べると AWS_ACCESS_KEY_ID、AWS_SECRET_ACCESS_KEY が設定されていないことが原因と書かれている記事を見かけるのですが、解決したという人としなかったという人を見かける上に、テストの方はモックの S3 に問題なくアクセスできていて今ひとつ良く分かりません。

試しに AWS_ACCESS_KEY_ID、AWS_SECRET_ACCESS_KEY をいくつかの方法で設定してみると以下の結果でした。

  • test_resize メソッド内で AWS_ACCESS_KEY_ID、AWS_SECRET_ACCESS_KEY の環境変数を設定するのは NG。 f:id:ksby:20200613134845p:plain
  • resize_service/handler.py で s3_client = boto3.client('s3')s3_client = boto3.client('s3', aws_access_key_id='test', aws_secret_access_key='test') に変更すると OK。 f:id:ksby:20200613135322p:plain
  • python -m unittest -v を実行するコマンドプロンプトAWS_ACCESS_KEY_ID、AWS_SECRET_ACCESS_KEY の環境変数を設定すると OK。 f:id:ksby:20200613135536p:plain

何となく原因が分かりました。おそらく以下の動作になっているのでしょう。

  • test_resize.py が読み込まれる。
  • from resize_service import handler により resize_service/handler.py が読み込まれる。
  • resize_service/handler.py のグローバルで s3_client = boto3.client('s3') が実行される。この時は boto3 の s3 クライアントはまだモックではなく Credentials の情報がセットされない。
  • test_resize.py の TestResizeService.test_resize メソッドが実行される。この時は mock_s3 デコレータ により boto3 の s3 クライアントはモック化されて Credentials の情報がセットされる。
  • 結果として test_resize.py 内の処理ではモックの S3 に問題なくアクセスできるが、resize_service/handler.py 内の処理でモックの S3 にアクセスしようとすると raise NoCredentialsError のエラーが出る。

resize_service/handler.py の s3_client = boto3.client('s3') を記述する位置を resize 関数の最初に変更します。

..........

logger = logging.getLogger()
logger.setLevel(logging.INFO)


def resize_image(image_path, resized_path):
    with Image.open(image_path) as image:
        image.thumbnail(thumbnail_size)
        image.save(resized_path)


def resize(event, context):
    s3_client = boto3.client('s3')

    for record in event['Records']:
        bucket = record['s3']['bucket']['name']
        ..........

python -m unittest -v を実行すると今度は成功しました。

f:id:ksby:20200613142047p:plain

IntelliJ IDEA から debug 実行してみる

test_resize.py 内の handler.resize(event, None) の行に breakpoint をセットしてから、IDEA のエディタの左側に表示されている矢印をクリックして Debug 'Unittests for test_r...' を選択します。

f:id:ksby:20200613143729p:plain

初回は FileNotFoundError: [WinError 3] 指定されたパスが見つかりません。: 'tests/sample.jpg' のエラーが出てエラーになります。

f:id:ksby:20200613144122p:plain

IDEA のメインメニューから「Run」-「Edit Configurations...」を選択して「Run/Debug Configurations」ダイアログを表示した後、「Python tests」の下に表示されている項目を選択してから画面右側の「Working directory」の設定を D:\project-serverless\ksbysample-serverless\resize-image-app-project\testsD:\project-serverless\ksbysample-serverless\resize-image-app-project に変更します(プロジェクトのルートディレクトリにする)。

f:id:ksby:20200613144459p:plain

再度 Debug 'Unittests for test_r...' を選択して debug 実行すると今度は breakpoint で止まり、「Step Into」ボタンを押せば resize_service/handler.py の resize 関数に進みます。

f:id:ksby:20200613144621p:plain

最後にディレクトリ構成を記載する

f:id:ksby:20200613145416p:plain

Python には標準で unittest モジュールが用意されていたり、moto という AWS Service をモック化するモジュールが存在したり、IntelliJ IDEA で Lambda のソースを debug 実行できたりして、Serverless は debug しにくいと思っていましたが結構開発しやすくて意外でした。

履歴

2020/06/13
初版発行。