はじめに
SAMのテンプレートに沿ってLambdaをpythonで書いたはいいものの、pytestでテストコード書くのにハマったので基本的な使い方のサンプル作ってみました。
以下の方を対象としてます。
・pythonを真面目に学習したことはないが、Lambdaでライトにpythonを使いたい。
・単純なロジックしかないし、1からpythonを学習するのは面倒。
・ノリでLambdaをpythonでコーディングしたものの、pytestがわからん。
・SAMのテンプレートを利用してに沿ったpytestのサンプルが欲しい
筆者自身が上記の状態でしたが、段々わかってきたので同じような方向けにサンプルを作成しました。
pythonに詳しいわけではないため、誤りなどあればご指摘いただけると助かります。
github(サンプルコード)はこちら
以降はサンプルコードのうち、ポイント(筆者がハマった)部分をかいつまんで説明してます。
おそらく本サンプルをベースに不明点をググれば、最低限のpytestは作成できると勝手に思ってます。
requirements.txt
pythonコードの実行に必要なライブラリを記載します。
テスト対象で利用しているライブラリも記載する必要があります。
今回は最低限のライブラリしか記載してません。
tests/requirements.txt
| 1 2 | pytest pytest-mock | 
ライブラリのインストールは以下コマンド
※CI等でpytestを自動実行する場合は、実行前にライブラリのインストールが必要です。
| 1 | pip install -r tests/requirements.txt | 
実行コマンド
通常はpycharmなどのIDE上で実行するかと思いますが、CIなどで自動一括実行する場合は以下のコマンドを指定します。
| 1 2 | pip install -r tests/requirements.txt pytest tests | 
__init__.py
pythonは実行時のカレントディレクトリを意識します。
故に別ファイル(.py)に記載したモジュールをimportするためには、相対パスを指定する必要があります。
加えて、SAMテンプレートのようにテスト用モジュールからテスト対象モジュールを読めないディレクトリ(モジュール)構成になっている場合はパスを通す必要があります。
今回のサンプルではモジュール実行時の初期化処理である__init__.pyにパスを通す処理を記載してます。
ついでにboto3(AWSのpython用SDK)のモックであるmotoを利用する場合、デフォルトのリージョン指定がないと意図しない挙動となることがあるてため、__init__.pyへ記載してます。
(本サンプルではmotoは扱ってません。ぐぐればたくさん情報出てきます。)
tests/unit/init.py
| 1 2 3 4 5 6 | import os import sys sys.path.append(os.path.abspath(os.path.dirname(os.path.abspath(__file__)) + "/../../src/")) os.environ['AWS_DEFAULT_REGION'] = 'ap-northeast-1' | 
テスト対象モジュール
テスト対象モジュールです。
API Gatewayとのプロキシ統合を意識した戻り値としてますが、やっていることは単純に別モジュール(sub_app.py)のメソッド(return_value)を呼び出しているだけです。
また、カスタム例外のモックも(筆者自身がどハマりしたので)扱うため、例外も別モジュール(custom_exception.py)に定義してます。
src/app.py
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | import json from sub_app import return_value from custom_exception import CustomException import logging logger = logging.getLogger() logger.setLevel(logging.INFO) def lambda_handler(event, context):     try:         return {             'statusCode': 200,             'body': json.dumps({                 'message': return_value()             })         }     except CustomException:         logger.info('CustomExceptionをキャッチ')         return {             'statusCode': 400,             'body': json.dumps({                 'message': 'bad request'             })         }     except Exception as e:         logger.info(f'{e.__class__.__name__}をキャッチ')         raise e | 
sub_app.py
| 1 2 3 4 5 6 | def return_value():     """     文字列を返します。     """     return "dummyMessage" | 
custom_exception.py
| 1 2 3 4 5 6 7 8 9 10 11 12 | class CustomException(Exception):     """     メインロジックでキャッチさせて隠蔽する例外。     """     pass class UncatchedException(Exception):     """     メインロジックでキャッチさせて再度発生させる例外。     """     pass | 
テスト用モジュール
サンプルのメインとなるテスト用モジュールです。
注意点を順番に記載します。
コードはこちら
クラス名とモジュール名
クラス名は「Test」、モジュール名は「test_」から始まる命名をする必要があります。
別の命名をするとテスト対象として認識されず、pytest実行時にテストが実行されません。
(知らないと以外とハマります)
逆にユーティリティの場合は、別の命名とする必要があります。
モック
mocker.patchを利用することでモック化できます。
メインとなる(と勝手に思ってる)利用方法は2種類あります。
1. mocker.patch(モック対象メソッドの文字列指定, return_value=返却する値)
2. mocker.patch(モック対象メソッドの文字列指定, side_effect=例外のインスタンス や 関数)
1は単純に返却する値を指定する場合に利用します。
2は例外の発生や、返す値を可変にする際に利用します。
※本サンプルで例外の発生のみで利用してます。
ここで筆者がドハマったのは引数の指定方法です。
テスト対象モジュールでimportしているメソッドを指定する必要があります。
具体的には、
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | from sub_app import return_value def lambda_handler(event, context):     try:         return {             'statusCode': 200,             'body': json.dumps({                 'message': return_value()             })         }     except CustomException:         logger.info('CustomExceptionをキャッチ')         return {             'statusCode': 400,             'body': json.dumps({                 'message': 'bad request'             })         } | 
のsub_appからimportしたreturn_valueをモックしてCustomExceptionを発生させるには
| 1 | mocker.patch('src.app.return_value', side_effect=src.app.CustomException()) | 
とsrc.app配下のパスを指定する必要があります。
筆者はテストモジュールでモック対象のモジュールをimportして、importしたモジュールを指定してしまいドハマってました。
ただし、テスト対象モジュールでimportしていないモジュールはテストモジュールでimportして例外を指定する必要があります。(たとえば、↓のサンプル)
ちなみに、mocker.patchを変数へ格納すれば呼出回数のassertも可能です。
| 1 2 3 | mock = mocker.patch('src.app.return_value', side_effect=src.app.CustomException()) (テスト処理) assert mock.call_count == 1 | 
例外のassert
pytest.raisesを利用し、発生した例外をassertします。
| 1 2 3 | with pytest.raises(Exception) as e:     app.lambda_handler({'requestContext': {'resourcePath': 'dummyPath'}}, None) assert e.type == custom_exception.UncatchedException | 
ログのassert
caplogを利用し、loggerで出力された内容をassertできます。
| 1 2 3 | def test_logger(self, caplog):      (テスト処理)      assert '確認したいメッセージ' in caplog.text | 
テストコードの全体像
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 | import src.app as app import src import pytest import json import src.custom_exception as custom_exception # テストクラスは「Test」から始まる命名としないとテストクラスとして認識されません。 class TestApp:     # テストメソッドは「test_」から始まる命名としないとテストクラスとして認識されません。     def test_success(self):         """         何もモックしない         """         rtn = app.lambda_handler({'requestContext': {'resourcePath': 'success'}}, None)         assert json.loads(rtn['body'])['message'] == 'dummyMessage'     def test_success_with_mock(self, mocker):         """         return_valueをモックして戻り値を変更する         """         mock = mocker.patch('src.app.return_value', return_value='mockMessage')         rtn = app.lambda_handler({'requestContext': {'resourcePath': 'dummyPath'}}, None)         # 戻り値を検証         assert rtn['statusCode'] is 200         assert json.loads(rtn['body'])['message'] == 'mockMessage'         # モックが呼び出された回数を検証         assert mock.call_count == 1     def test_bad_request_with_mock(self, mocker, caplog):         """         return_valueをモックしてカスタム例外(CustomException)を発生させる。         """         # モック対象と戻り値をsrc.app配下の絶対パスで指定します。         # モック対象と戻り値をテストモジュール内(このファイル)でimportとしてそのパスを指定しても意図した動作となりません。         mock = mocker.patch('src.app.return_value', side_effect=src.app.CustomException())         rtn = app.lambda_handler({'requestContext': {'resourcePath': 'dummyPath'}}, None)         # 戻り値を検証         assert rtn['statusCode'] == 400         assert json.loads(rtn['body'])['message'] == 'bad request'         # loggerによって出力されたのログを検証。         assert 'CustomExceptionをキャッチ' in caplog.text         # モックが呼び出された回数を検証         assert mock.call_count == 1     def test_bad_request_with_mock_uncatched_exception(self, mocker, caplog):         """         return_valueをモックしてカスタム例外(UncatchedException)を発生させる。         """         # 戻り値にapp内でimportしていない例外を指定する場合、前段のようにsrc.app配下のパスを指定できません。         # テストモジュール内(このファイル)でimportした例外を指定します。         # (app.pyではExceptionをcatchしているので、テストモジュール内でimportした例外であってもキャッチされます。)         mock = mocker.patch('src.app.return_value', side_effect=custom_exception.UncatchedException())         with pytest.raises(Exception) as e:             app.lambda_handler({'requestContext': {'resourcePath': 'dummyPath'}}, None)         assert e.type == custom_exception.UncatchedException         # loggerによって出力されたのログを検証         assert 'UncatchedExceptionをキャッチ' in caplog.text         # モックが呼び出された回数を検証         assert mock.call_count == 1 |