pytestとmotoで始めるLambda単体テスト入門

pytestとmotoで始めるLambda単体テスト入門

pytestとmotoを使ってAWSリソースなしにLambdaの単体テストを書く方法を、テスタブルな設計の基本からレイヤードアーキテクチャ、エラーハンドリング、外部APIモックまで5章で段階的に解説します。
2026.04.07

はじめに

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 などを利用するコードをテストできます。

https://github.com/getmoto/moto

2. テストしにくいコードになっている

motoを使っても、コードの設計次第でそもそもテストを書けないケースがあります。例えば、ビジネスロジックとAWSアクセスが1つの関数に混在していたりすると、ロジックだけを単独でテストすることができません。

テストを書くには、テストしやすいコード設計 が前提です。この記事では設計の考え方から順を追って解説します。

なぜLambdaにテストを書くのか

Lambdaはマネジメントコンソールでのちょっとした手動確認がしやすい分、テストを書かないまま本番に出てしまいがちです。しかし、テストを書くことで次のようなメリットが得られます。

  • バグの早期発見: デプロイ前にロジックの誤りを検出できる
  • リファクタリングの安全網: コードを変更したときに意図しない挙動の変化に気づける
  • 仕様のドキュメント化: テストコードが「このLambdaはどう動くべきか」の仕様書になる

特に最近は、AIエージェントを使ってコードを変更する機会が増えています。AIはスピーディーに実装を提案してくれますが、変更がシステム全体にどこまで影響しているかは人間が把握しにくいこともあります。自動テストというガードレールがあれば、AIによる変更後にテストを実行するだけで「意図しない挙動の変化がないか」をすぐに確認できます。テストは、AIと安心して開発を進めるための土台でもあります。

この記事の流れ

pytestmoto を使って、テスタブルな設計からはじめ、実際のAWSリソースを使わずにLambdaの単体テストを書く方法を段階的に紹介します。

  1. テスタブルな設計の基本(motoのインターセプトとロジックの分離)+ カバレッジレポート
  2. 現実的なファイル分割 — すべての層をDI・モックでテストする
  3. リポジトリ層をmotoでテストする
  4. エラーハンドリングとバリデーションのテスト
  5. 応用: 外部API呼び出しのモック

読み終わった後には、自分のLambdaにpytestとmotoでテストを書けるようになることを目指しています。

今回作るもの

今回のサンプルコード全体はGitHubで公開しています。

https://github.com/rednes/introduction-python-lambda-testing

環境セットアップ

ディレクトリ構成

サンプルは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の例を示します。

chapter1/pyproject.toml
[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を実行するとカバレッジが表示されるオプションです。

  • 参考:Configuration - pytest documentation

前提: uvのインストール

本記事ではPythonパッケージマネージャに uv を使用します。未インストールの場合は公式の手順でインストールしてください。

https://docs.astral.sh/uv/getting-started/installation/

uvの概要や使い方の詳細は弊社ブログをご覧ください。

https://dev.classmethod.jp/articles/uv-unified-python-packaging-explained/

各Chapterフォルダ下で次のコマンドを実行すると、必要なライブラリがインストールできます。

$ uv sync --dev

Chapter1: テスタブルな設計の基本

テストを書く前に、まず「テストできるコード」になっているかを確認することが重要です。コードを適当に書いてからテストしようとすると、テストそのものが成立しないケースがあります。

例えば、次のようなLambdaを考えます。
ロジック部分はとても簡単で、「アイテムの金額を合計し、10000以上なら10%割引する。order_idとアイテムと合計金額をDynamoDBに保存する」だけのものです。

src/order_handler.py(テストしにくい例)
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通信をインターセプトするため、モジュールレベルで初期化した dynamodbmock_aws() コンテキスト内であればモックされます。しかし、このままではビジネスロジックとAWSアクセスが混在したままで、ロジックだけを素早く確認することはできません。

ビジネスロジックを純粋関数に分離する

この問題を解決するため、計算ロジックを 純粋関数 として分離します。

src/order_handler.py(リファクタリング後)
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に接続しないよう、ダミーの認証情報を環境変数に設定します。

chapter1/tests/conftest.py
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も不要です。

こんな風に境界値のテストを書いたりします。

tests/test_order_handler.py
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が「実際の値」と「期待値」の両方を表示してくれるので、原因を特定しやすいです。

次に複数アイテムの合算と、割引後の端数処理を確認します。

tests/test_order_handler.py
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_orderhandlerはDynamoDBアクセスが発生するため、dynamodb_tableフィクスチャを引数に取ります。motoはbotocore層のHTTP通信をインターセプトするため、mock_aws()コンテキスト内であればモジュールレベルで初期化した_dynamodbの呼び出しがモックされます。

tests/test_order_handler.py
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ハンドラー全体を動かし、レスポンスのstatusCodetotalを検証します。

実際のテスト実行は、次のコマンドで実施できます。

$ 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.pyOrderItem(明細1行)とOrder(注文全体)のdataclassを定義します。他のレイヤーはこのモデルをやり取りします。

src/app/models/order.py
from dataclasses import dataclass

@dataclass
class OrderItem:
    price: int
    quantity: int

@dataclass
class Order:
    order_id: str
    items: list[OrderItem]
    total: int = 0

サービス — 実装とテスト

AWSに依存しない計算ロジックをサービスレイヤーに集めます。

src/app/services/order_service.py
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もフィクスチャも不要でテストできます。

tests/test_order_service.py
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へのアクセスをリポジトリに閉じ込めます。dynamodbtable_nameはコンストラクタで受け取るDI設計です。

src/app/repositories/order_repository.py
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で検証します。

tests/test_order_repository.py
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,
    })

ユースケース — 実装とテスト

サービスとリポジトリを組み合わせてユースケースを実現します。リポジトリは引数で受け取ります。

src/app/usecases/order_usecase.py
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

MagicMockrepositoryとして渡します。mock_repo.save.assert_called_once_withで、ユースケースがリポジトリを正しく呼び出したことを検証できます。

tests/test_order_usecase.py
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にまとめています。

src/app/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"])

@cachefunctools標準ライブラリのデコレータで、同じ引数での呼び出し結果をキャッシュします。get_order_repository()は引数なしの関数なので、初回呼び出し時に生成したインスタンスが以降もそのまま返ります。

tests/test_dependencies.py
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によるシングルトン動作が実装に反映されていることを保証できます。

src/controller.py
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}}}

テスト時はpatchget_order_repositoryを差し替え、MagicMockを渡します。

tests/test_controller.py
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.pyget_order_repository()@cacheでキャッシュされるため、テスト間でインスタンスが使い回される問題があります。これを防ぐため、各テストの前後でキャッシュをクリアするフィクスチャを追加します。

tests/conftest.py
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を振り返ります。

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_itemItemキーをItmと書き間違えた(DynamoDBはエラーを返すが、MagicMockは何でも受け入れる)
  • KeySchemaと合わないフィールドをキーに指定した
  • 実際のデータ型とAttributeTypeが一致していない

また、テスト対象のメソッドが増えるたびにmock_dynamodb.Table.return_value = mock_tableのような準備が積み重なり、モックの設定コードがテストの本質を埋もれさせることがあります。

motoを使うメリット

motoは本物のDynamoDB APIと同じ振る舞いをメモリ上で再現します。そのため次のことを確認できます。

  • put_itemの引数が正しい構造になっているか
  • get_itemで期待どおりのデータが取り出せるか
  • テーブルのスキーマ定義(KeySchema, AttributeDefinitions)と操作が整合しているか

モックの設定コードも不要になり、テストが「何を操作して何を確認するか」だけになるため読みやすくなります

test_order_repository.py
# 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をモックするフィクスチャを追加します。

tests/conftest.py
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リソースを使うか」をテスト側が明示的にコントロールできます。

tests/test_order_repository.py
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

usecasescontrollerのテストは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にまとめます。

src/app/exceptions.py
class OrderError(Exception):
    """注文ドメインの基底例外"""

class OrderSaveError(OrderError):
    """注文の保存に失敗"""

class OrderItemValidationError(OrderError):
    """注文アイテムの入力値が不正"""

inputs層 — パースとバリデーション

eventから受け取った生データを OrderItem モデルに変換し、バリデーションするのがinputs層の役割です。

src/app/inputs/order_input.py
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障害時の例外変換を追加します。

src/app/repositories/order_repository.py
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を使うよう変更し、OrderSaveErrorOrderItemValidationErrorのハンドリングを追加します。

src/controller.py
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 の全体
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_effectClientErrorを設定すると、put_itemが呼ばれた瞬間に指定したエラーを発生させることができます。

tests/test_order_repository.py
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が上位へ伝播することもテストします。

tests/test_order_usecase.py
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 の全体
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に依存関係を追加します。

chapter5/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に依存してしまいます。カスタム例外を挟むことで、ライブラリへの依存をゲートウェイ層に閉じ込められます。

src/app/exceptions.py
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 の全体
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.pygateway.process()を呼ぶだけです。dependencies.pyは環境変数からURLとタイムアウト値を読み込み、PaymentGatewayを生成して返します。

controller.pyではrequestsではなくカスタム例外を受け取り、ステータスコードに変換します。必須パラメータ不足もKeyErrorでハンドリングします。

src/controller.py
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とタイムアウトを環境変数に設定します。

tests/conftest.py
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 の全体
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で完結します。

tests/test_payment_usecase.py
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と同じく、patchget_payment_gatewayの戻り値を差し替えることでGatewayをモックします。カスタム例外をside_effectに設定してエラー系も検証できます。

tests/test_controller.py
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サービスに対応しているので、ぜひご自身のプロジェクトにも取り入れてみてください。

このブログがどなたかのお役に立てれば幸いです。

参考資料

この記事をシェアする

関連記事