SAM でビルドした Lambda ハンドラーに対して、カスタム例外を期待するテストコードを書きたい
表題の通りだが、少しハマったので備忘録として残すことにする。
概略
プロダクトコードのカスタム例外クラスの import のパスとテストコードで評価するカスタム例外の import パスが異なることによって、テストコードが失敗する状況に陥った。
SAM のビルド成果物の都合上、プロダクトコードとテストコードのパスが異なってしまうため、自前でビルド用のシェルを書いて解決した。
前提
ディレクトリ構成
├── src
│ └── app
│ ├── __init__.py
│ ├── functions
│ │ ├── __init__.py
│ │ └── hello.py
│ └── libs
│ ├── __init__.py
│ └── exceptions.py
├── template.yaml
└── tests
├── __init__.py
└── app
├── __init__.py
├── functions
│ ├── __init__.py
│ └── test_handler.py
└── libs
└── __init__.py
template.yaml(一部抜粋)
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: > sam lambda sample
Resources:
HelloFunction:
Type: AWS::Serverless::Function
FunctionName: hello-function
CodeUri: src/
Handler: app.functions.hello.handler
Runtime: python3.11
Role: !GetAtt HelloFunctionRole.Arn
Architectures:
- x86_64
Layers:
- !Ref RequirementsLayer
詳細
例えば、以下のカスタム例外クラスを使って、ハンドラー内で発生した例外を期待するテストコードを書く場合を考える。
app/libs/exception.py
class CustomException(Exception):
def __init__(self):
super().__init__("Custom exception raised!")
app/functions/hello.py
import json
from app.libs.exceptions import CustomException
def handler(event, context):
body = {
"message": "Hello World",
"input": event,
}
response = {"statusCode": 200, "body": json.dumps(body)}
# ここで例外が発生するかのテストコードを書きたい
if event.get("hello") == "NG":
raise CustomException
return response
tests/app/functions/test_handler.py
from src.app.functions.hello import handler
from src.app.libs.exceptions import CustomException
import pytest
def test_handler_ng():
event = {"hello": "NG"}
context = None
with pytest.raises(CustomException):
handler(event, context)
テストコードを実行する。
================================================================================ FAILURES =================================================================================
_____________________________________________________________________________ test_handler_ng _____________________________________________________________________________
def test_handler_ng():
event = {"hello": "NG"}
context = None
with pytest.raises(CustomException):
> handler(event, context)
tests/app/functions/test_handler.py:10:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
event = {'hello': 'NG'}, context = None
def handler(event, context):
body = {
"message": "Hello World",
"input": event,
}
response = {"statusCode": 200, "body": json.dumps(body)}
if event.get("hello") == "NG":
> raise CustomException
E app.libs.exceptions.CustomException: Custom exception raised!
src/app/functions/hello.py:14: CustomException
========================================================================= short test summary info =========================================================================
FAILED tests/app/functions/test_handler.py::test_handler_ng - app.libs.exceptions.CustomException: Custom exception raised!
ハンドラー内で例外は発生しているのだが、pytest.raises
で補足できずテスト結果としては失敗になっている。
原因
プロダクトコードとテストコードが参照している exception クラスのパスが異なることが原因で発生している。
from app.libs.exceptions import CustomException # app/functions/hello.py
from src.app.libs.exceptions import CustomException # tests/app/functions/test_handler.py
なぜこうなったのか
SAM の CodeUri で指定したフォルダ配下がビルド成果物になるため、src フォルダ自体は含まれない。
そのため、プロダクトコードの方は、app
から参照する必要がある。
src
から import するように修正しても、ローカル実行やテストコードはパスするが、デプロイ環境で動かすと import エラーが発生してしまう。
実際のビルド成果物のフォルダ構成
.aws-sam
├── build
│ ├── HelloFunction
│ │ ├── requirements_layer
│ │ │ └── requirements.txt
│ │ └── app
│ │ ├── __init__.py
│ │ ├── functions
│ │ │ ├── __init__.py
│ │ │ └── hello.py
│ │ └── libs
│ │ ├── __init__.py
│ │ └── exceptions.py
│ ├── RequirementsLayer
│ │ └── python
│ │ └── requirements.txt
│ └── template.yaml
└── build.toml
対応策
src
フォルダを含む形でビルド出来れば、両者の import パスが一致して解決すると考える
具体的な対応としては、一旦新規フォルダ内に、src フォルダ毎コピーして、CodeUri を新規フォルダを指定してみる。
例えば、以下のようなシェルスクリプトを考える。
#!/bin/bash
# build ディレクトリが存在しない場合にのみ、build ディレクトリを作成
if [ ! -d "build" ]; then
mkdir build
fi
# src フォルダをビルドディレクトリにコピーする前に、
# 既存のビルドディレクトリがあれば削除
rm -rf build/src
# src フォルダ全体を build ディレクトリにコピー
cp -r src build/src
# sam build コマンドを実行(引数を渡す)
sam build "$@"
build フォルダ配下に、src フォルダをコピーし、template の CodeUri では、build/
を指定する。
.aws-sam
から、ビルド成果物を確認すると、src フォルダが含まれていることがわかる。
.aws-sam
├── build
│ ├── HelloFunction
│ │ ├── requirements_layer
│ │ │ └── requirements.txt
│ │ └── src
│ │ └── app
│ │ ├── __init__.py
│ │ ├── functions
│ │ │ ├── __init__.py
│ │ │ └── hello.py
│ │ └── libs
│ │ ├── __init__.py
│ │ └── exceptions.py
│ ├── RequirementsLayer
│ │ └── python
│ │ └── requirements.txt
│ └── template.yaml
└── build.toml
これで、プロダクトコードもテストコードも src
フォルダからライブラリを参照できるので、上手くいった。
余談
Serverless Framework だと、package を使えば、該当フォルダのみ成果物に含めることができるのだが、SAM にはそのような機能がないため、このような手間が必要になった。
Discussion