💡

SAM でビルドした Lambda ハンドラーに対して、カスタム例外を期待するテストコードを書きたい

2024/03/23に公開

表題の通りだが、少しハマったので備忘録として残すことにする。

概略

プロダクトコードのカスタム例外クラスの 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