pytestとmotoで始めるLambda単体テスト入門
はじめに
PythonでAWS Lambdaは書けるけど、テストの書き方がわからない。
そんな方向けの入門記事です。
この記事では次の方を対象としています。
- Pythonの基本文法は理解している
- Lambdaの開発経験はあるが、テストを書いたことがない
- pytestやmotoを使ったことがない
Lambdaのテストが難しいと感じる理由
Lambdaのテストが難しく感じる背景には、大きく2つの要因があります。
1. AWSサービスへの依存
S3やDynamoDBなどのAWSサービスを呼び出している場合、テスト実行のたびに本物のAWSリソースが必要になります。コストもかかりますし、テスト環境の整備も大変です。
この問題は、moto を使うことで解決できます。moto は boto3/botocore 経由の AWS API 呼び出しをモック実装に差し替えるライブラリで、実際の AWS リソースを使わずに S3 や DynamoDB などを利用するコードをテストできます。
2. テストしにくいコードになっている
motoを使っても、コードの設計次第でそもそもテストを書けないケースがあります。例えば、ビジネスロジックとAWSアクセスが1つの関数に混在していたりすると、ロジックだけを単独でテストすることができません。
テストを書くには、テストしやすいコード設計 が前提です。この記事では設計の考え方から順を追って解説します。
なぜLambdaにテストを書くのか
Lambdaはマネジメントコンソールでのちょっとした手動確認がしやすい分、テストを書かないまま本番に出てしまいがちです。しかし、テストを書くことで次のようなメリットが得られます。
- バグの早期発見: デプロイ前にロジックの誤りを検出できる
- リファクタリングの安全網: コードを変更したときに意図しない挙動の変化に気づける
- 仕様のドキュメント化: テストコードが「このLambdaはどう動くべきか」の仕様書になる
特に最近は、AIエージェントを使ってコードを変更する機会が増えています。AIはスピーディーに実装を提案してくれますが、変更がシステム全体にどこまで影響しているかは人間が把握しにくいこともあります。自動テストというガードレールがあれば、AIによる変更後にテストを実行するだけで「意図しない挙動の変化がないか」をすぐに確認できます。テストは、AIと安心して開発を進めるための土台でもあります。
この記事の流れ
pytest と moto を使って、テスタブルな設計からはじめ、実際のAWSリソースを使わずにLambdaの単体テストを書く方法を段階的に紹介します。
- テスタブルな設計の基本(motoのインターセプトとロジックの分離)+ カバレッジレポート
- 現実的なファイル分割 — すべての層をDI・モックでテストする
- リポジトリ層をmotoでテストする
- エラーハンドリングとバリデーションのテスト
- 応用: 外部API呼び出しのモック
読み終わった後には、自分のLambdaにpytestとmotoでテストを書けるようになることを目指しています。
今回作るもの
今回のサンプルコード全体はGitHubで公開しています。
環境セットアップ
ディレクトリ構成
サンプルはChapterごとに独立したプロジェクトとして構成しています。
introduction-python-lambda-testing/
├── chapter1/ # テスタブルな設計の基本(カバレッジを含む)
│ ├── pyproject.toml
│ ├── src/
│ │ └── order_handler.py
│ └── tests/
│ ├── conftest.py
│ └── test_order_handler.py
├── chapter2/ # 現実的なファイル分割(全層DI・モック)
├── chapter3/ # リポジトリ層をmotoでテストする
├── chapter4/ # エラーハンドリングとバリデーションのテスト
└── chapter5/ # 外部API呼び出しのモック
各Chapterで独立したpyproject.tomlを持ちます。Chapter1の例を示します。
[project]
name = "chapter1"
version = "1.0.0"
requires-python = ">=3.13"
[dependency-groups]
dev = [
"boto3>=1.42.83",
"moto[dynamodb]>=5.1.22",
"pytest>=9.0.2",
"pytest-cov>=7.1.0",
]
[tool.pytest]
testpaths = ["tests"]
pythonpath = ["src"]
addopts = ["--cov=src", "--cov-report=term-missing"]
-
moto[dynamodb]: motoはサービスごとにオプション(moto[s3]、moto[dynamodb]など)が分かれています。使用するサービスに合わせてインストールします -
pythonpath = ["src"]:src/配下のモジュールをテストから直接インポートできます。各テストファイルにsys.path.insertを書く必要がなくなります -
addopts:pytestを実行するとカバレッジが表示されるオプションです。
前提: uvのインストール
本記事ではPythonパッケージマネージャに uv を使用します。未インストールの場合は公式の手順でインストールしてください。
uvの概要や使い方の詳細は弊社ブログをご覧ください。
各Chapterフォルダ下で次のコマンドを実行すると、必要なライブラリがインストールできます。
$ uv sync --dev
Chapter1: テスタブルな設計の基本
テストを書く前に、まず「テストできるコード」になっているかを確認することが重要です。コードを適当に書いてからテストしようとすると、テストそのものが成立しないケースがあります。
例えば、次のようなLambdaを考えます。
ロジック部分はとても簡単で、「アイテムの金額を合計し、10000以上なら10%割引する。order_idとアイテムと合計金額をDynamoDBに保存する」だけのものです。
import boto3
import os
# モジュールレベルで初期化(コールドスタート最適化)
_dynamodb = boto3.resource("dynamodb")
_TABLE_NAME = os.environ["TABLE_NAME"]
def handler(event, context):
items = event.get("items", [])
# ビジネスロジックとAWS操作が混在している
subtotal = sum(item["price"] * item["quantity"] for item in items)
total = int(subtotal * 0.9) if subtotal >= 10000 else subtotal
table = _dynamodb.Table("orders")
table.put_item(Item={"order_id": event["order_id"], "items": items, "total": total})
return {"statusCode": 200, "body": {"total": total}}
このコードは問題があります。「割引計算が期待どおりか」だけをテストできません。
割引ロジックを確認したくても、DynamoDBへのアクセスが常に発生します。handler全体をテストするしかなく、割引ロジックのみを独立して検証することができません。
motoを使えば handler 全体のテストは書けます。
motoはbotocore層のHTTP通信をインターセプトするため、モジュールレベルで初期化した dynamodb も mock_aws() コンテキスト内であればモックされます。しかし、このままではビジネスロジックとAWSアクセスが混在したままで、ロジックだけを素早く確認することはできません。
ビジネスロジックを純粋関数に分離する
この問題を解決するため、計算ロジックを 純粋関数 として分離します。
import boto3
import os
# モジュールレベルで初期化(コールドスタート最適化)
_dynamodb = boto3.resource("dynamodb")
_TABLE_NAME = os.environ["TABLE_NAME"]
def calculate_total(items: list[dict]) -> int:
"""注文明細から合計金額を計算する(10,000円以上は10%割引)"""
subtotal = sum(item["price"] * item["quantity"] for item in items)
if subtotal >= 10000:
return int(subtotal * 0.9)
return subtotal
def save_order(order_id: str, items: list[dict], total: int) -> None:
table = _dynamodb.Table(_TABLE_NAME)
table.put_item(Item={"order_id": order_id, "items": items, "total": total})
def handler(event, context):
items = event.get("items", [])
total = calculate_total(items)
save_order(event["order_id"], items, total)
return {"statusCode": 200, "body": {"data": {"total": total}}}
calculate_total はAWSに一切依存しない純粋関数になりました。引数を渡して戻り値を確認するだけでテストできます。
テスト前準備(AWS認証情報とフィクスチャの設定)
フィクスチャとは
フィクスチャとは、pytestでテストの前後処理(セットアップ・クリーンアップ)を再利用可能な形で定義する仕組みです。テスト関数の引数にフィクスチャ名を書くだけで、pytestが自動で実行して結果を渡し、必要なら後処理もできます。
@pytest.fixture
def dynamodb_table():
# ① セットアップ: テーブルを作成
...
yield dynamodb # ② テスト関数に渡す
# ③ クリーンアップ: yieldの後が自動実行される(mock_aws()のwithブロックを抜ける)
def test_save_order(dynamodb_table): # ← 引数に書くだけで自動注入される
...
yieldを使うと「テスト実行中だけ存在するリソース」を渡せます。yieldの後に書いたクリーンアップ処理は、テストが成功・失敗いずれの場合でも必ず実行されます。
conftest.pyに書く理由
フィクスチャをconftest.pyに書いておくと、同じディレクトリ以下のすべてのテストファイルで共通して使えます。また、conftest.pyはpytestがテスト収集前に自動で読み込むため、モジュールレベルに書いた環境変数設定が確実に先に適用されます。
motoを使う際は、誤って本物のAWSに接続しないよう、ダミーの認証情報を環境変数に設定します。
import os
import boto3
import pytest
from moto import mock_aws
os.environ["AWS_ACCESS_KEY_ID"] = "testing"
os.environ["AWS_SECRET_ACCESS_KEY"] = "testing"
os.environ["AWS_SECURITY_TOKEN"] = "testing"
os.environ["AWS_SESSION_TOKEN"] = "testing"
os.environ["AWS_DEFAULT_REGION"] = "ap-northeast-1"
os.environ["TABLE_NAME"] = "orders"
@pytest.fixture
def dynamodb_table():
with mock_aws():
dynamodb = boto3.resource("dynamodb", region_name="ap-northeast-1")
dynamodb.create_table(
TableName="orders",
KeySchema=[{"AttributeName": "order_id", "KeyType": "HASH"}],
AttributeDefinitions=[{"AttributeName": "order_id", "AttributeType": "S"}],
BillingMode="PAY_PER_REQUEST",
)
yield dynamodb
テストコード
純粋関数のテスト(moto不要)
calculate_totalはAWSに依存しない純粋関数なので、引数を渡して戻り値をassertで確認するだけでテストできます。フィクスチャもmotoも不要です。
こんな風に境界値のテストを書いたりします。
from order_handler import calculate_total, save_order, handler
def test_empty_items():
# 明細が空のとき合計0円になること
assert calculate_total([]) == 0
def test_no_discount_below_threshold():
items = [{"price": 3000, "quantity": 2}] # 合計6,000円 → 割引なし
assert calculate_total(items) == 6000
def test_exact_threshold():
items = [{"price": 10000, "quantity": 1}] # ちょうど10,000円 → 10%割引が適用される
assert calculate_total(items) == 9000
def test_discount_applied_at_threshold():
items = [{"price": 6000, "quantity": 2}] # 合計12,000円 → 10%割引
assert calculate_total(items) == 10800
assert 式 == 期待値の形で書きます。失敗したときpytestが「実際の値」と「期待値」の両方を表示してくれるので、原因を特定しやすいです。
次に複数アイテムの合算と、割引後の端数処理を確認します。
def test_multiple_items():
items = [
{"price": 2000, "quantity": 3}, # 6,000円
{"price": 1000, "quantity": 5}, # 5,000円
]
# 合計11,000円 → 10%割引 → 9,900円
assert calculate_total(items) == 9900
def test_calculate_total_truncation():
"""割引後の端数は切り捨てられる"""
items = [{"price": 10001, "quantity": 1}]
assert calculate_total(items) == 9000
test_calculate_total_truncation は端数が発生した場合、int() による切り捨てが行われていることを確認するテストです。もし誤って四捨五入等、切り捨て以外の処理をしてしまったとき、このテストがすぐに検知してくれます。
motoのインターセプトを使ったテスト
save_orderとhandlerはDynamoDBアクセスが発生するため、dynamodb_tableフィクスチャを引数に取ります。motoはbotocore層のHTTP通信をインターセプトするため、mock_aws()コンテキスト内であればモジュールレベルで初期化した_dynamodbの呼び出しがモックされます。
def test_save_order(dynamodb_table):
items = [{"price": 3000, "quantity": 3}]
save_order("order-001", items, 9000)
table = dynamodb_table.Table("orders")
response = table.get_item(Key={"order_id": "order-001"})
assert response["Item"]["items"] == items
assert response["Item"]["total"] == 9000
def test_handler(dynamodb_table):
event = {
"order_id": "order-001",
"items": [{"price": 5000, "quantity": 3}], # 合計15,000円 → 10%割引
}
result = handler(event, {})
assert result["statusCode"] == 200
assert result["body"]["data"]["total"] == 13500
test_save_orderは保存処理を単独で確認します。test_handlerはイベントを渡してLambdaハンドラー全体を動かし、レスポンスのstatusCodeとtotalを検証します。
実際のテスト実行は、次のコマンドで実施できます。
$ uv run pytest -v
============================ tests coverage ============================
__________ coverage: platform darwin, python 3.13.12-final-0 ___________
Name Stmts Miss Cover Missing
----------------------------------------------------
src/order_handler.py 16 0 100%
----------------------------------------------------
TOTAL 16 0 100%
========================== 8 passed in 0.40s ===========================
Miss列が0でない場合、その行番号(Missing列)に対応するテストケースが不足しています。100%を目指す必要は必ずしもありませんが、重要なロジックが抜けていないかの指標として活用できます。
割引ロジックを分割することとmotoの利用によって、AWSへの接続なしに、割引ロジックとDynamoDB操作をまとめてテストできました。
Chapter2: 現実的なファイル分割 — すべての層をDI・モックでテストする
Chapter1では1ファイルで完結するコードを例にしました。実際のプロジェクトではファイルを分割することが多いです。
このChapterではレイヤードアーキテクチャをベースに、すべての層を 依存性注入(Dependency Injection) で設計し、MagicMock でテストする方法を紹介します。
なぜレイヤーを分けるのか
アプリケーションには、入力を受け取る処理、業務ルールを実行する処理、データを保存する処理など、役割の異なるコードがあります。
これらを1か所にまとめたまま開発を続けると、コードの見通しが悪くなり、変更の影響範囲も広がっていきます。
たとえば、割引ロジックを修正したいだけなのに、DynamoDB の保存処理やテスト環境の設定まで気にしなければならないことがあります。
このような状態では、修正したい関心ごとに集中しづらく、テストが失敗したときも原因を切り分けにくくなります。
そこで、役割ごとにレイヤーを分けます。
業務ロジックは業務ロジック、データアクセスはデータアクセス、入出力は入出力として分離することで、各層の責務が明確になります。
この分割によって、コードは次のように扱いやすくなります。
- 変更しやすい: 業務ルールの変更が他の層に波及しにくい
- 読みやすい: どこに何を書くべきかが明確になる
- テストしやすい: 必要な層だけを切り出して確認できる
特にテストでは、依存関係を DI で注入できるようにしておくことで、外部依存をモックに差し替えられます。
その結果、業務ロジックの検証で DynamoDB や moto に頼らず、速くて壊れにくいテストを書けるようになります。
ファイル構成
src/
├── controller.py # eventを受け取り、usecaseを呼び出す
└── app/
├── dependencies.py # AWSクライアントの初期化と提供
├── models/order.py # データモデル(dataclass)
├── repositories/order_repository.py # DynamoDBアクセス
├── services/order_service.py # ビジネスロジック(純粋関数)
└── usecases/order_usecase.py # ユースケース(サービスとリポジトリの組み合わせ)
各レイヤーの依存関係を図示します。
モデル
models/order.pyにOrderItem(明細1行)とOrder(注文全体)のdataclassを定義します。他のレイヤーはこのモデルをやり取りします。
from dataclasses import dataclass
@dataclass
class OrderItem:
price: int
quantity: int
@dataclass
class Order:
order_id: str
items: list[OrderItem]
total: int = 0
サービス — 実装とテスト
AWSに依存しない計算ロジックをサービスレイヤーに集めます。
from app.models.order import OrderItem
def calculate_total(items: list[OrderItem]) -> int:
"""10,000円以上は10%割引"""
subtotal = sum(item.price * item.quantity for item in items)
if subtotal >= 10000:
return round(subtotal * 0.9)
return subtotal
AWSに依存しないため、motoもフィクスチャも不要でテストできます。
from app.models.order import OrderItem
from app.services.order_service import calculate_total
def test_calculate_total_no_discount():
items = [OrderItem(price=3000, quantity=2)]
assert calculate_total(items) == 6000
def test_calculate_total_exact_threshold():
items = [OrderItem(price=10000, quantity=1)]
assert calculate_total(items) == 9000
def test_calculate_total_with_discount():
items = [OrderItem(price=6000, quantity=2)]
assert calculate_total(items) == 10800
def test_calculate_total_rounding():
"""割引後の端数は切り捨てでなく四捨五入される"""
items = [OrderItem(price=10001, quantity=1)]
assert calculate_total(items) == 9001 # int()なら9000になる
リポジトリ — 実装とテスト
DynamoDBへのアクセスをリポジトリに閉じ込めます。dynamodbとtable_nameはコンストラクタで受け取るDI設計です。
from app.models.order import Order
class OrderRepository:
def __init__(self, dynamodb, table_name: str):
self._table = dynamodb.Table(table_name)
def save(self, order: Order) -> None:
self._table.put_item(Item={
"order_id": order.order_id,
"items": [{"price": item.price, "quantity": item.quantity} for item in order.items],
"total": order.total,
})
motoを使わずMagicMockでテストします。mock_dynamodb.Table()がmock_tableを返すよう設定し、put_itemが正しい引数で呼ばれたことをassert_called_once_withで検証します。
from unittest.mock import MagicMock
from app.models.order import Order, OrderItem
from app.repositories.order_repository import OrderRepository
def test_save():
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_dynamodb.Table.return_value = mock_table
items = [OrderItem(price=3000, quantity=3)]
order = Order(order_id="order-001", items=items, total=9000)
repo = OrderRepository(mock_dynamodb, "orders")
repo.save(order)
mock_dynamodb.Table.assert_called_once_with("orders")
mock_table.put_item.assert_called_once_with(Item={
"order_id": "order-001",
"items": [{"price": 3000, "quantity": 3}],
"total": 9000,
})
ユースケース — 実装とテスト
サービスとリポジトリを組み合わせてユースケースを実現します。リポジトリは引数で受け取ります。
from app.models.order import Order, OrderItem
from app.repositories.order_repository import OrderRepository
from app.services.order_service import calculate_total
def create_order(items: list[OrderItem], order_id: str, repository: OrderRepository) -> Order:
total = calculate_total(items)
order = Order(order_id=order_id, items=items, total=total)
repository.save(order)
return order
MagicMockをrepositoryとして渡します。mock_repo.save.assert_called_once_withで、ユースケースがリポジトリを正しく呼び出したことを検証できます。
from unittest.mock import MagicMock
from app.models.order import Order, OrderItem
from app.usecases.order_usecase import create_order
def test_create_order():
mock_repo = MagicMock()
order = create_order([OrderItem(price=5000, quantity=3)], "order-001", mock_repo)
assert order.total == 13500
assert order.order_id == "order-001"
expected_order = Order(order_id="order-001", items=[OrderItem(price=5000, quantity=3)], total=13500)
mock_repo.save.assert_called_once_with(expected_order)
コントローラー — 実装とテスト
handlerはeventを受け取り、ユースケースを呼び出します。AWSクライアントの初期化はdependencies.pyにまとめています。
import os
from functools import cache
import boto3
from app.repositories.order_repository import OrderRepository
@cache
def get_order_repository() -> OrderRepository:
return OrderRepository(boto3.resource("dynamodb"), os.environ["TABLE_NAME"])
@cacheはfunctools標準ライブラリのデコレータで、同じ引数での呼び出し結果をキャッシュします。get_order_repository()は引数なしの関数なので、初回呼び出し時に生成したインスタンスが以降もそのまま返ります。
from app.dependencies import get_order_repository
def test_get_order_repository_returns_same_instance():
repo1 = get_order_repository()
repo2 = get_order_repository()
assert repo1 is repo2
==は値の等価性、isはオブジェクトの同一性(同じメモリ上のインスタンスか)を確認します。このテストにより、@cacheによるシングルトン動作が実装に反映されていることを保証できます。
from app.dependencies import get_order_repository
from app.models.order import OrderItem
from app.usecases.order_usecase import create_order
def handler(event, context):
order_id = event["order_id"]
items = [OrderItem(**item) for item in event.get("items", [])]
order = create_order(items, order_id, get_order_repository())
return {"statusCode": 200, "body": {"data": {"total": order.total}}}
テスト時はpatchでget_order_repositoryを差し替え、MagicMockを渡します。
from unittest.mock import MagicMock, patch
from controller import handler
def test_handler():
mock_repo = MagicMock()
with patch("controller.get_order_repository", return_value=mock_repo):
event = {"order_id": "order-001", "items": [{"price": 5000, "quantity": 3}]}
result = handler(event, {})
assert result["statusCode"] == 200
assert result["body"]["data"]["total"] == 13500
conftest.py
Chapter2ではmotoを使わないため、DynamoDB接続のフィクスチャは不要です。ただし、dependencies.pyのget_order_repository()は@cacheでキャッシュされるため、テスト間でインスタンスが使い回される問題があります。これを防ぐため、各テストの前後でキャッシュをクリアするフィクスチャを追加します。
import os
import pytest
os.environ["AWS_ACCESS_KEY_ID"] = "testing"
os.environ["AWS_SECRET_ACCESS_KEY"] = "testing"
os.environ["AWS_SECURITY_TOKEN"] = "testing"
os.environ["AWS_SESSION_TOKEN"] = "testing"
os.environ["AWS_DEFAULT_REGION"] = "ap-northeast-1"
os.environ["TABLE_NAME"] = "orders"
@pytest.fixture(autouse=True)
def _clear_dependency_cache():
from app.dependencies import get_order_repository
get_order_repository.cache_clear()
yield
get_order_repository.cache_clear()
autouse=Trueを指定すると、フィクスチャを引数に書かなくても全テストに自動適用されます。cache_clear()はテスト前後の両方で呼び出し、前のテストで残ったキャッシュがテスト結果に影響しないようにします。
MagicMockのトレードオフ
Chapter2のアプローチはシンプルで速いですが、トレードオフがあります。mock_table.put_item.assert_called_once_with(...)は「そのメソッドが正しく呼ばれたか」を検証しますが、実際のDynamoDB操作が正しく動くかは確認していません。例えば、put_itemの引数のキー名を間違えても、MagicMockは何も言いません。
本番に近いDynamoDB動作を確認したい場合は、次のChapter3でmotoを使ったアプローチを紹介します。
Chapter3: リポジトリ層をmotoでテストする
Chapter2ではすべての層をMagicMockでテストしました。しかし、実際に開発を進めると「MagicMockだけでは不安」な場面が出てきます。このChapterではその背景と、motoを使う判断のトレードオフを整理します。
Chapter2(MagicMock)の限界
Chapter2のtest_order_repository.pyを振り返ります。
def test_save():
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_dynamodb.Table.return_value = mock_table
items = [OrderItem(price=3000, quantity=3)]
order = Order(order_id="order-001", items=items, total=9000)
repo = OrderRepository(mock_dynamodb, "orders")
repo.save(order)
mock_table.put_item.assert_called_once_with(Item={
"order_id": "order-001",
"items": [{"price": 3000, "quantity": 3}],
"total": 9000,
})
このテストが確認しているのは「put_itemが正しい引数で呼ばれたか」だけです。put_itemの中身はMagicMockが握っているため、実際にDynamoDBに保存できるかどうかは検証していません。
例えば次のようなバグはすり抜けます。
put_itemのItemキーをItmと書き間違えた(DynamoDBはエラーを返すが、MagicMockは何でも受け入れる)KeySchemaと合わないフィールドをキーに指定した- 実際のデータ型と
AttributeTypeが一致していない
また、テスト対象のメソッドが増えるたびにmock_dynamodb.Table.return_value = mock_tableのような準備が積み重なり、モックの設定コードがテストの本質を埋もれさせることがあります。
motoを使うメリット
motoは本物のDynamoDB APIと同じ振る舞いをメモリ上で再現します。そのため次のことを確認できます。
put_itemの引数が正しい構造になっているかget_itemで期待どおりのデータが取り出せるか- テーブルのスキーマ定義(
KeySchema,AttributeDefinitions)と操作が整合しているか
モックの設定コードも不要になり、テストが「何を操作して何を確認するか」だけになるため読みやすくなります。
# motoを使うと、モック設定なしでそのまま操作・検証できる
def test_save(dynamodb_table):
repo = OrderRepository(dynamodb_table, "orders")
order = Order(order_id="order-001", items=[OrderItem(price=3000, quantity=3)], total=9000)
repo.save(order)
response = dynamodb_table.Table("orders").get_item(Key={"order_id": "order-001"})
assert response["Item"]["total"] == 9000
トレードオフの整理
| 観点 | MagicMock(Chapter2) | moto(Chapter3) |
|---|---|---|
| テストの速さ | 速い | やや遅い |
| モックの設定コード量 | 多い(return_valueの連鎖) | 少ない(フィクスチャのみ) |
| DynamoDB操作の正確さ | 確認できない | 実際のAPI挙動で確認できる |
| moto未対応操作のリスク | なし | まれにあり(新機能など) |
| 環境変数・フィクスチャ | ほぼ不要 | 必要 |
どちらが正解ということはなく、「DynamoDB操作の正しさまで確認したいか」によって選択が変わります。
今回は、ユースケース・コントローラー層は MagicMock のままにして、DynamoDBの境界となるリポジトリ層だけ moto でテストする方針とします。
conftest.py
motoを利用するため、motoでDynamoDBをモックするフィクスチャを追加します。
import os
import boto3
import pytest
from moto import mock_aws
os.environ["AWS_ACCESS_KEY_ID"] = "testing"
os.environ["AWS_SECRET_ACCESS_KEY"] = "testing"
os.environ["AWS_SECURITY_TOKEN"] = "testing"
os.environ["AWS_SESSION_TOKEN"] = "testing"
os.environ["AWS_DEFAULT_REGION"] = "ap-northeast-1"
os.environ["TABLE_NAME"] = "orders"
@pytest.fixture(autouse=True)
def _clear_dependency_cache():
from app.dependencies import get_order_repository
get_order_repository.cache_clear()
yield
get_order_repository.cache_clear()
@pytest.fixture
def dynamodb_table():
with mock_aws():
dynamodb = boto3.resource("dynamodb", region_name="ap-northeast-1")
dynamodb.create_table(
TableName="orders",
KeySchema=[{"AttributeName": "order_id", "KeyType": "HASH"}],
AttributeDefinitions=[{"AttributeName": "order_id", "AttributeType": "S"}],
BillingMode="PAY_PER_REQUEST",
)
yield dynamodb
リポジトリのテスト(moto)
Chapter2のMagicMockと置き換えるのはここだけです。
Chapter1では_dynamodb = boto3.resource("dynamodb")をモジュールレベルで初期化していたため、motoのHTTPインターセプトに頼っていました。Chapter3のOrderRepositoryはDI設計なので、 motoで初期化したboto3リソースをテストから直接渡す ことができます。インターセプトに依存せず、「どのDynamoDBリソースを使うか」をテスト側が明示的にコントロールできます。
from app.models.order import Order, OrderItem
from app.repositories.order_repository import OrderRepository
def test_save(dynamodb_table):
repo = OrderRepository(dynamodb_table, "orders")
order = Order(order_id="order-001", items=[OrderItem(price=3000, quantity=3)], total=9000)
repo.save(order)
table = dynamodb_table.Table("orders")
response = table.get_item(Key={"order_id": "order-001"})
assert response["Item"]["items"] == [{"price": 3000, "quantity": 3}]
assert response["Item"]["total"] == 9000
レイヤーごとのモック使い分けまとめ
Chapter3では、レイヤーによってモック手法を使い分けています。
| レイヤー | モック手法 |
|---|---|
services(計算ロジック) |
純粋関数 |
repositories(DynamoDBアクセス) |
moto |
usecases, controller |
MagicMock |
usecasesとcontrollerのテストはChapter2と変わりません。
テストが落ちたとき、motoのフィクスチャに問題があればリポジトリ層の test_save だけが落ちます。計算ロジックの問題ならサービス層の test_calculate_total だけが落ちます。落ちた場所がそのまま原因の場所になり、見通しが良くなります。
Chapter4: エラーハンドリングとバリデーションのテスト
Chapter3の構成を拡張して、入力バリデーションエラーやDynamoDB保存失敗など、異常系のテストを書く方法を紹介します。pytest.raisesを使うと、例外の発生をシンプルにテストできます。
ファイル構成の変更点
Chapter4ではイベントのパースとバリデーションを担う inputs層 を新たに追加します。また、例外クラスを専用の exceptions.py に切り出します。
src/
├── controller.py
└── app/
├── dependencies.py
├── exceptions.py # ドメイン例外の定義
├── inputs/order_input.py # イベントのパースとバリデーション
├── models/order.py
├── repositories/order_repository.py
├── services/order_service.py
└── usecases/order_usecase.py
例外クラス(exceptions.py)
ドメイン固有の例外をexceptions.pyにまとめます。
class OrderError(Exception):
"""注文ドメインの基底例外"""
class OrderSaveError(OrderError):
"""注文の保存に失敗"""
class OrderItemValidationError(OrderError):
"""注文アイテムの入力値が不正"""
inputs層 — パースとバリデーション
eventから受け取った生データを OrderItem モデルに変換し、バリデーションするのがinputs層の役割です。
from app.exceptions import OrderItemValidationError
from app.models.order import OrderItem
def parse_order_items_from_event(data: object) -> list[OrderItem]:
if not isinstance(data, list):
raise OrderItemValidationError("items はリストである必要があります")
return [parse_order_item(item) for item in data]
def parse_order_item(data: dict[str, object]) -> OrderItem:
item = _parse(data)
validate_order_item(item)
return item
def _parse(data: dict[str, object]) -> OrderItem:
try:
return OrderItem(**data)
except TypeError as e:
raise OrderItemValidationError(f"不正なアイテムデータ: {e}") from e
def validate_order_item(item: OrderItem) -> None:
if item.price <= 0 or item.quantity <= 0:
raise OrderItemValidationError("price と quantity は1以上である必要があります")
Chapter3まではeventのitemsをcontroller内で[OrderItem(**item) for item in ...]と直接変換していました。inputs層を設けることで、パースとバリデーションをひとつの場所に集め、独立してテストできるようになります。
リポジトリの変更 — エラーハンドリングを追加
saveメソッドにDynamoDB障害時の例外変換を追加します。
from botocore.exceptions import ClientError
from app.exceptions import OrderSaveError
from app.models.order import Order
class OrderRepository:
def __init__(self, dynamodb, table_name: str):
self._table = dynamodb.Table(table_name)
def save(self, order: Order) -> None:
try:
self._table.put_item(Item={
"order_id": order.order_id,
"items": [{"price": item.price, "quantity": item.quantity} for item in order.items],
"total": order.total,
})
except ClientError as e:
raise OrderSaveError(f"注文の保存に失敗しました: {e}") from e
botocore.exceptions.ClientErrorをキャッチしてOrderSaveErrorに変換することで、上位レイヤーがDynamoDB固有の例外型を知らなくて済むようになります。
コントローラーの変更
parse_order_items_from_eventを使うよう変更し、OrderSaveErrorとOrderItemValidationErrorのハンドリングを追加します。
from app.dependencies import get_order_repository
from app.exceptions import OrderItemValidationError, OrderSaveError
from app.inputs.order_input import parse_order_items_from_event
from app.usecases.order_usecase import create_order
def _ok(data: dict[str, object]) -> dict[str, object]:
return {"statusCode": 200, "body": {"data": data}}
def _err(status_code: int, message: str) -> dict[str, object]:
return {"statusCode": status_code, "body": {"error": message}}
def handler(event: dict[str, object], context: object) -> dict[str, object]:
repo = get_order_repository()
try:
items = parse_order_items_from_event(event.get("items", []))
order = create_order(items, event["order_id"], repo)
return _ok({"total": order.total})
except OrderSaveError as e:
return _err(500, str(e))
except OrderItemValidationError as e:
return _err(400, str(e))
except KeyError as e:
return _err(400, f"必須パラメータが不足: {e}")
ヘルパー関数_ok・_errを定義することで、レスポンス構造の一貫性を保ちやすくなります。
テストコード(pytest.raises)
pytest.raisesを使うと、特定の例外が発生することを検証できます。match引数で例外メッセージの内容も確認できます。
inputs層のテスト
inputs層はAWSに依存しないため、フィクスチャ不要でテストできます。
tests/test_order_input.py の全体
import pytest
from app.exceptions import OrderItemValidationError
from app.inputs.order_input import parse_order_item, parse_order_items_from_event, validate_order_item
from app.models.order import OrderItem
def test_parse_order_items_from_event_valid():
items = parse_order_items_from_event([{"price": 5000, "quantity": 3}, {"price": 1000, "quantity": 1}])
assert items == [OrderItem(price=5000, quantity=3), OrderItem(price=1000, quantity=1)]
def test_parse_order_items_from_event_not_a_list_raises_validation_error():
with pytest.raises(OrderItemValidationError, match="items はリスト"):
parse_order_items_from_event("invalid")
def test_parse_order_items_from_event_none_raises_validation_error():
with pytest.raises(OrderItemValidationError, match="items はリスト"):
parse_order_items_from_event(None)
def test_parse_order_item_valid():
item = parse_order_item({"price": 5000, "quantity": 3})
assert item == OrderItem(price=5000, quantity=3)
def test_parse_order_item_missing_field_raises_validation_error():
with pytest.raises(OrderItemValidationError, match="不正なアイテムデータ"):
parse_order_item({"price": 1000})
def test_parse_order_item_unexpected_field_raises_validation_error():
with pytest.raises(OrderItemValidationError, match="不正なアイテムデータ"):
parse_order_item({"price": 1000, "quantity": 1, "discount": 0.1})
def test_parse_order_item_zero_price_raises_validation_error():
with pytest.raises(OrderItemValidationError, match="price と quantity は1以上"):
parse_order_item({"price": 0, "quantity": 1})
def test_validate_order_item_negative_quantity_raises_validation_error():
with pytest.raises(OrderItemValidationError, match="price と quantity は1以上"):
validate_order_item(OrderItem(price=1000, quantity=-1))
リポジトリのテスト — 保存失敗のテスト
motoのテストに加えて、DynamoDB障害時のエラー変換もテストします。
ProvisionedThroughputExceededException(スループット超過)のようなインフラ障害はmotoで再現できません。motoはDynamoDB APIの正常動作をエミュレートしていますが、スループット制限超過やサービス一時停止などの障害は意図的に再現できないためです。
こうした障害のシミュレーションにはMagicMockが適しています。side_effectにClientErrorを設定すると、put_itemが呼ばれた瞬間に指定したエラーを発生させることができます。
import pytest
from unittest.mock import MagicMock
from botocore.exceptions import ClientError
from app.exceptions import OrderSaveError
from app.models.order import Order, OrderItem
from app.repositories.order_repository import OrderRepository
def test_save(dynamodb_table):
repo = OrderRepository(dynamodb_table, "orders")
order = Order(order_id="order-001", items=[OrderItem(price=3000, quantity=3)], total=9000)
repo.save(order)
table = dynamodb_table.Table("orders")
response = table.get_item(Key={"order_id": "order-001"})
assert response["Item"]["total"] == 9000
assert response["Item"]["items"] == [{"price": 3000, "quantity": 3}]
def test_save_raises_order_save_error_on_dynamodb_failure():
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_dynamodb.Table.return_value = mock_table
mock_table.put_item.side_effect = ClientError(
{"Error": {"Code": "ProvisionedThroughputExceededException", "Message": "スループット超過"}},
"PutItem",
)
repo = OrderRepository(mock_dynamodb, "orders")
order = Order(order_id="order-001", items=[OrderItem(price=3000, quantity=3)], total=9000)
with pytest.raises(OrderSaveError, match="注文の保存に失敗しました"):
repo.save(order)
test_saveはmotoを使って実際のDynamoDB操作を確認します。test_save_raises_order_save_error_on_dynamodb_failureはMagicMockでClientErrorを強制発生させ、OrderSaveErrorに変換されることを確認します。このように「正常系はmoto、障害系はMagicMock」と使い分けることで、それぞれの得意な場面をカバーできます。
ユースケースのテスト
OrderSaveErrorが上位へ伝播することもテストします。
import pytest
from unittest.mock import MagicMock
from app.exceptions import OrderSaveError
from app.models.order import OrderItem
from app.usecases.order_usecase import create_order
def test_create_order():
mock_repo = MagicMock()
order = create_order([OrderItem(price=5000, quantity=3)], "order-001", mock_repo)
assert order.total == 13500
mock_repo.save.assert_called_once_with(order)
def test_create_order_propagates_save_error():
mock_repo = MagicMock()
mock_repo.save.side_effect = OrderSaveError("保存失敗")
with pytest.raises(OrderSaveError):
create_order([OrderItem(price=1000, quantity=1)], "order-001", mock_repo)
コントローラーのテスト
例外がHTTPステータスコードに変換されることを検証します。
tests/test_controller.py の全体
from unittest.mock import MagicMock, patch
from app.exceptions import OrderSaveError
from controller import handler
def test_create_order():
mock_repo = MagicMock()
with patch("controller.get_order_repository", return_value=mock_repo):
event = {"order_id": "order-001", "items": [{"price": 5000, "quantity": 3}]}
result = handler(event, {})
assert result["statusCode"] == 200
assert result["body"]["data"]["total"] == 13500
def test_dynamodb_save_error_returns_500():
mock_repo = MagicMock()
mock_repo.save.side_effect = OrderSaveError("保存失敗")
with patch("controller.get_order_repository", return_value=mock_repo):
event = {"order_id": "order-001", "items": [{"price": 1000, "quantity": 1}]}
result = handler(event, {})
assert result["statusCode"] == 500
assert "保存失敗" in result["body"]["error"]
def test_invalid_item_returns_400():
mock_repo = MagicMock()
with patch("controller.get_order_repository", return_value=mock_repo):
result = handler({"order_id": "order-001", "items": [{"price": 1000}]}, {})
assert result["statusCode"] == 400
assert "不正なアイテムデータ" in result["body"]["error"]
def test_missing_order_id_returns_400():
mock_repo = MagicMock()
with patch("controller.get_order_repository", return_value=mock_repo):
result = handler({"items": [{"price": 1000, "quantity": 1}]}, {})
assert result["statusCode"] == 400
assert "必須パラメータが不足" in result["body"]["error"]
レイヤーごとのモック使い分けまとめ
Chapter4でも、レイヤーによってモック手法を使い分けています。
| レイヤー | モック手法 |
|---|---|
inputs(パース・バリデーション) |
純粋関数 |
services(計算ロジック) |
純粋関数 |
repositories(DynamoDBアクセス) |
moto(正常系) + MagicMock(異常系) |
usecases, controller |
MagicMock |
Chapter3からの変更点は inputs レイヤーが加わったことと、repositories が正常系・異常系でモック手法を使い分けるようになった点です。
Chapter5: 応用 — 外部API呼び出しのモック
ここまではAWSサービスへのアクセスをmotoでモックしてきました。LambdaがAWS以外の外部HTTP APIを呼び出す場合は、responsesライブラリが便利です。
まずpyproject.tomlに依存関係を追加します。
[project]
dependencies = ["requests>=2.33.1"]
[dependency-groups]
dev = [
"pytest>=9.0.2",
"pytest-cov>=7.1.0",
"responses>=0.26.0", # HTTPモック用
]
コード構成
Chapter2, Chapter3と同じく、レイヤードアーキテクチャをベースにした構成を採用します。Chapter2のリポジトリ層に相当するポジションで、外部APIとの通信を担う ゲートウェイ層 を登場させます。
まずカスタム例外クラスを定義します。requestsの例外をそのまま上位レイヤーに伝播させると、コントローラーがrequestsに依存してしまいます。カスタム例外を挟むことで、ライブラリへの依存をゲートウェイ層に閉じ込められます。
class PaymentError(Exception):
pass
class PaymentTimeoutError(PaymentError):
pass
class PaymentAPIError(PaymentError):
def __init__(self, status_code: int) -> None:
self.status_code = status_code
super().__init__(f"Payment API error: {status_code}")
PaymentGatewayでは、タイムアウトや5xxエラー時のリトライも実装します。タイムアウト・5xxはリトライしますが、4xxはリトライせずすぐに例外を送出します。requests例外はGateway内でカスタム例外に変換するため、上位レイヤーはrequestsを知らなくてよくなります。
src/app/gateways/payment_gateway.py の全体
from typing import Any
import requests
from app.exceptions import PaymentAPIError, PaymentTimeoutError
class PaymentGateway:
def __init__(self, url: str, timeout: int = 5, max_retries: int = 3):
self._url = url
self._timeout = timeout
self._max_retries = max_retries
def process(self, order_id: str, amount: int) -> dict[str, Any]:
"""外部決済APIを呼び出して決済処理を行う(タイムアウト・5xxエラー時にリトライ)"""
try:
return self._request(order_id, amount)
except requests.Timeout as e:
raise PaymentTimeoutError() from e
except requests.HTTPError as e:
raise PaymentAPIError(e.response.status_code) from e
def _request(self, order_id: str, amount: int) -> dict[str, Any]:
"""リトライ込みでHTTPリクエストを送る。requests例外はそのままraiseする。"""
last_exc: Exception | None = None
for _ in range(self._max_retries):
try:
response = requests.post(
self._url,
json={"order_id": order_id, "amount": amount},
timeout=self._timeout,
)
response.raise_for_status()
return response.json()
except requests.Timeout as e:
last_exc = e
except requests.HTTPError as e:
if e.response.status_code < 500:
raise
last_exc = e
raise last_exc # type: ignore[misc]
payment_usecase.pyはgateway.process()を呼ぶだけです。dependencies.pyは環境変数からURLとタイムアウト値を読み込み、PaymentGatewayを生成して返します。
controller.pyではrequestsではなくカスタム例外を受け取り、ステータスコードに変換します。必須パラメータ不足もKeyErrorでハンドリングします。
from typing import Any
from app.dependencies import get_payment_gateway
from app.exceptions import PaymentAPIError, PaymentTimeoutError
from app.usecases.payment_usecase import process_payment
def handler(event: dict[str, Any], context: object) -> dict[str, Any]:
try:
order_id = event["order_id"]
amount = event["amount"]
result = process_payment(order_id, amount, get_payment_gateway())
return {"statusCode": 200, "body": {"data": result}}
except PaymentTimeoutError:
return {"statusCode": 504, "body": {"error": "決済APIがタイムアウトしました"}}
except PaymentAPIError as e:
return {"statusCode": e.status_code, "body": {"error": "決済APIエラー"}}
except KeyError as e:
return {"statusCode": 400, "body": {"error": f"必須パラメータが不足: {e}"}}
テストコード
@responses.activateデコレータで囲まれたテスト内では、実際のHTTPリクエストを送らずresponses.post()で登録したレスポンスが返ります。タイムアウトもbody=requests.exceptions.Timeout(...)で再現できます。
まずconftest.pyでAPIのURLとタイムアウトを環境変数に設定します。
import os
import pytest
PAYMENT_API_URL = "https://api.example.com/payments"
os.environ["PAYMENT_API_URL"] = PAYMENT_API_URL
os.environ["PAYMENT_API_TIMEOUT"] = "5"
@pytest.fixture(autouse=True)
def _clear_dependency_cache():
from app.dependencies import get_payment_gateway
get_payment_gateway.cache_clear()
yield
get_payment_gateway.cache_clear()
PaymentGatewayクラスのテストです。カスタム例外が正しく送出されることと、リトライ動作を検証します。
tests/test_payment_gateway.py の全体
import json
import responses
import requests
import pytest
from conftest import PAYMENT_API_URL
from app.exceptions import PaymentAPIError, PaymentTimeoutError
from app.gateways.payment_gateway import PaymentGateway
@responses.activate
def test_process_success():
"""正常系: 決済APIが成功レスポンスを返す"""
responses.post(
PAYMENT_API_URL,
json={"transaction_id": "txn-001", "status": "completed"},
status=200,
)
gw = PaymentGateway(PAYMENT_API_URL)
result = gw.process("order-001", 9000)
assert result["transaction_id"] == "txn-001"
assert result["status"] == "completed"
# リクエスト内容の検証
body = json.loads(responses.calls[0].request.body)
assert body["order_id"] == "order-001"
assert body["amount"] == 9000
@responses.activate
def test_process_api_error():
"""異常系: 決済APIが500エラーを返す → PaymentAPIErrorに変換する"""
responses.post(PAYMENT_API_URL, json={"error": "internal"}, status=500)
gw = PaymentGateway(PAYMENT_API_URL)
with pytest.raises(PaymentAPIError) as exc_info:
gw.process("order-001", 9000)
assert exc_info.value.status_code == 500
@responses.activate
def test_process_timeout():
"""異常系: 決済APIがタイムアウトする → PaymentTimeoutErrorに変換する"""
responses.post(PAYMENT_API_URL, body=requests.exceptions.Timeout("timed out"))
gw = PaymentGateway(PAYMENT_API_URL)
with pytest.raises(PaymentTimeoutError):
gw.process("order-001", 9000)
@responses.activate
def test_retry_on_timeout_then_success():
"""タイムアウト後にリトライして成功する"""
responses.add(responses.POST, PAYMENT_API_URL, body=requests.Timeout("timed out"))
responses.add(
responses.POST,
PAYMENT_API_URL,
json={"transaction_id": "txn-001", "status": "completed"},
status=200,
)
gw = PaymentGateway(PAYMENT_API_URL, max_retries=3)
result = gw.process("order-001", 9000)
assert result["transaction_id"] == "txn-001"
assert len(responses.calls) == 2 # 1回失敗 → 1回成功
@responses.activate
def test_retry_on_5xx_then_success():
"""5xxエラー後にリトライして成功する"""
responses.add(responses.POST, PAYMENT_API_URL, json={"error": "internal"}, status=500)
responses.add(
responses.POST,
PAYMENT_API_URL,
json={"transaction_id": "txn-001", "status": "completed"},
status=200,
)
gw = PaymentGateway(PAYMENT_API_URL, max_retries=3)
result = gw.process("order-001", 9000)
assert result["transaction_id"] == "txn-001"
assert len(responses.calls) == 2
@responses.activate
def test_no_retry_on_4xx():
"""4xxエラーはリトライしない → PaymentAPIErrorに変換する"""
responses.post(PAYMENT_API_URL, json={"error": "bad request"}, status=400)
gw = PaymentGateway(PAYMENT_API_URL, max_retries=3)
with pytest.raises(PaymentAPIError) as exc_info:
gw.process("order-001", 9000)
assert exc_info.value.status_code == 400
assert len(responses.calls) == 1 # リトライなし
@responses.activate
def test_retry_exhausted():
"""リトライ上限を超えたらPaymentTimeoutErrorを送出する"""
responses.post(PAYMENT_API_URL, body=requests.Timeout("timed out"))
gw = PaymentGateway(PAYMENT_API_URL, max_retries=3)
with pytest.raises(PaymentTimeoutError):
gw.process("order-001", 9000)
assert len(responses.calls) == 3 # max_retries回試みた
ユースケースのテストはMagicMockで完結します。
from unittest.mock import MagicMock
from app.usecases.payment_usecase import process_payment
def test_process_payment_success():
"""正常系: gatewayの結果をそのまま返す"""
gw = MagicMock()
gw.process.return_value = {"transaction_id": "txn-001", "status": "completed"}
result = process_payment("order-001", 9000, gw)
assert result["transaction_id"] == "txn-001"
gw.process.assert_called_once_with("order-001", 9000)
コントローラーのテストはGatewayをMagicMockで差し替えます。Chapter4と同じく、patchでget_payment_gatewayの戻り値を差し替えることでGatewayをモックします。カスタム例外をside_effectに設定してエラー系も検証できます。
from unittest.mock import MagicMock, patch
from app.exceptions import PaymentAPIError, PaymentTimeoutError
from controller import handler
_GET_GATEWAY = "controller.get_payment_gateway"
def _mock_gateway(side_effect=None, return_value=None):
gw = MagicMock()
if side_effect is not None:
gw.process.side_effect = side_effect
else:
gw.process.return_value = return_value
return gw
def test_handler_success():
gw = _mock_gateway(return_value={"transaction_id": "txn-001", "status": "completed"})
with patch(_GET_GATEWAY, return_value=gw):
result = handler({"order_id": "order-001", "amount": 9000}, {})
assert result["statusCode"] == 200
assert result["body"]["data"]["transaction_id"] == "txn-001"
def test_handler_api_500():
gw = _mock_gateway(side_effect=PaymentAPIError(500))
with patch(_GET_GATEWAY, return_value=gw):
result = handler({"order_id": "order-001", "amount": 9000}, {})
assert result["statusCode"] == 500
assert "決済APIエラー" in result["body"]["error"]
def test_handler_timeout():
gw = _mock_gateway(side_effect=PaymentTimeoutError())
with patch(_GET_GATEWAY, return_value=gw):
result = handler({"order_id": "order-001", "amount": 9000}, {})
assert result["statusCode"] == 504
assert "タイムアウト" in result["body"]["error"]
def test_handler_missing_order_id():
result = handler({"amount": 9000}, {})
assert result["statusCode"] == 400
assert "必須パラメータが不足" in result["body"]["error"]
まとめ
pytestとmotoを使ったLambdaテスト入門を紹介しました。
- テスタブルな設計 + カバレッジ(Chapter1): ビジネスロジックを純粋関数に分離してテストを書く。DynamoDB操作はmotoのHTTPインターセプトでモックし、
pytest-covで未テストの行を可視化できる - 全層DI・モック(Chapter2): レイヤードアーキテクチャで責務を分離し、リポジトリを含むすべての層を
MagicMockでテストする。moto不要で設定が少なく速い - リポジトリ層をmotoでテスト(Chapter3): DI設計でmotoが初期化したboto3リソースをテストから直接渡し、実際のDynamoDB操作を検証する。ユースケース・コントローラーはMagicMockでテストし、落ちた場所から原因を特定しやすい
- エラーハンドリング(Chapter4): inputs層でパース・バリデーションを分離し、
pytest.raisesで例外の発生を検証。正常系はmoto、インフラ障害はMagicMockと使い分けて異常ケースを再現できる - 外部APIのモック(Chapter5):
responsesライブラリでHTTPリクエストをモック。カスタム例外でGateway層にrequests依存を封じ込め、リトライロジックもテストできる
おわりに
pytestとmotoを使うと、実際のAWSリソースなしでLambdaのテストが書けることを確認できました。S3やDynamoDB以外にもSQS、SNS、Cognito等、多くのAWSサービスに対応しているので、ぜひご自身のプロジェクトにも取り入れてみてください。
このブログがどなたかのお役に立てれば幸いです。
参考資料
- pytestとmotoを利用してAWSサービスのmockを使ったテストをしてみる | DevelopersIO
- boto3からDynamoDBへのアクセスをmotoでモックしてみる | DevelopersIO
- pytest+motoでS3へのバケット一覧リクエストからオブジェクト確認リクエストまで細かくテストしてみた | DevelopersIO
- Pythonのテストコードでmockを使ってみた | DevelopersIO
- pytest 使い方まとめ | DevelopersIO
- [Python] pytestで環境変数を設定するときはfixtureでpatchするのがいい | DevelopersIO







