DevelopersIO 2026で「Python Lambdaにおけるテストしやすい設計とAI支援の活用」というタイトルで登壇しました

DevelopersIO 2026で「Python Lambdaにおけるテストしやすい設計とAI支援の活用」というタイトルで登壇しました

DevelopersIO 2026の登壇内容を記事化。Python Lambdaのhandlerを薄くして業務ルールを純粋関数に切り出す設計と3つのテスト境界を解説し、AIにテストを書かせた実験から人間が仕様を判断しやすくなる効果を示します。
2026.10.26

はじめに

こんにちは、クラウド事業統括本部の北野です。

2026 年 10 月 24 日に開催された「DevelopersIO 2026 Tokyo」で、「Python Lambdaにおけるテストしやすい設計とAI支援の活用」というタイトルで登壇しました。

この記事では、発表の内容をコードと図を交えて紹介します。
時間の都合で話せなかった内容も、あわせて詳しく書いています。

スライド

最初に結論

ソフトウェア設計で、人間が仕様(プログラム・テスト)を判断できる状態を保つ

AIはプログラムやテストを書いてくれますが、その仕様でよいかは決めてくれません。だから、人間がテストを読んで仕様を判断できる形にコードを分けておきます。これが発表で一番伝えたかったことです。

元ネタブログ

本記事のコードは要点を示す抜粋で、importや補助関数の定義は省略しています。
細かく知りたい方や、pytestとmotoの基本的な使い方については、こちらのブログとサンプルコードを参照してください。

https://dev.classmethod.jp/articles/introduction-python-lambda-testing/

発表のhandlerとdomainは、記事ではそれぞれcontrollerとserviceという名前です。発表では、Lambdaの呼び名に合わせてhandler、業務ルールの層だと名前だけで分かるようにdomainと呼んでいます。

1. こんなテスト、書いていませんか

題材: 注文を受け付けて保存するLambda

注文イベントを受け取ったLambdaがDynamoDBに注文を保存する構成図

  • 入力: 注文IDと明細(単価×数量)のイベント
  • 業務ルール: 合計が1万円以上なら1割引き。端数は切り捨て
  • 出力: DynamoDBに注文を保存し、合計金額を返す

業務ルールは割引の1つだけに絞り、これを「仕様」の代表として扱います。バリデーションや外部APIまで入れると、仕様がどこにあるかという話がぼやけるためです。

とあるLambda handler

import json
import os
import boto3

dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(os.environ["TABLE_NAME"])

def handler(event, context):
    items = event.get("items", [])
    subtotal = sum(i["price"] * i["quantity"] for i in items)
    total = subtotal * 9 // 10 if subtotal >= 10000 else subtotal
    table.put_item(Item={
        "order_id": event["order_id"],
        "items": items,
        "total": total,
    })
    return {"statusCode": 200, "body": json.dumps({"data": {"total": total}})}

短くて動くコードですが、業務ルール(合計と割引)、AWSの呼び出し(put_item)、レスポンスの組み立てが1つの関数に入っています。boto3のリソースはモジュールの先頭で初期化しています。このLambdaをテストしようとすると、どうなるでしょうか。

とあるテスト

@patch.dict("os.environ", {"TABLE_NAME": "orders"})
@patch("boto3.resource")
def test_handler(mock_resource):
    mock_table = MagicMock()
    mock_resource.return_value.Table.return_value = mock_table
    import handler as mod
    importlib.reload(mod)

    event = {"order_id": "o-1", "items": [{"price": 6000, "quantity": 2}]}
    result = mod.handler(event, {})
    body = json.loads(result["body"])

    assert body["data"]["total"] == 10800
    mock_table.put_item.assert_called_once_with(Item={
        "order_id": "o-1", "items": event["items"], "total": 10800,
    })

準備に7行、確かめたいことに2行です。環境変数と boto3.resource をpatchし、モジュール先頭で初期化しているので importlib.reload までしています。このテストが何を確かめたいのか、ぱっと見て分かるでしょうか。

このテストの何が問題か

  • 業務仕様がモック設定に埋もれる
  • SDKの呼び出しと業務ロジックが1つのassertに混在する
  • put_item を batch_writer に変えただけで全部のテストが落ちる
  • モックが正しいかを人間が確かめる必要がある

テストがhandlerを通じてboto3の呼び出し形に密結合している図

一番大きいのは1つ目です。テストには12,000円のケースしかなく、1万円ちょうどが割引対象かは分かりません。

残りの3つは、テストが「何を保存したか」ではなく「put_item をどう呼んだか」を見ていることから来ています。業務ロジックと呼び出し方が1つのassertに混ざり、batch_writer に変えるだけで落ちます。MagicMock はどんな引数でも成功するので、DynamoDBが受け付ける値かどうかも分かりません。

AIを持ち出す前に、人間がもう仕様を読めていない状態です。原因はテストの書き方ではなく設計にあります。

2. handlerを薄くし、依存を分離する

1章のテストが読みづらかったのは、業務ルールとAWSの呼び出しが同じ関数に入っていたからです。割引の計算を確かめたいだけなのに、同じ関数の中にある put_item まで一緒に動いてしまうので、テストはDynamoDBのモックから始めるしかありませんでした。

そこで、業務ルールとAWSの呼び出しを別の場所に置きます。目的は、人間が読むべき仕様を1か所に集めることです。割引のルールだけが書かれた関数があれば、その関数のテストを読むだけで仕様が分かり、「本当にその仕様でよいか」を判断できます。

handlerは入口と出口の処理だけを担当する薄い関数にし、中身を責務ごとに分けていきます。

4つの層に分ける

handler・usecase・domain・repositoryの4層と組み立て役の図

  • handler: イベントを受け取りusecaseを呼ぶ。業務判断をしない
  • usecase: 業務の流れを組み立てる
  • domain: 業務ルールそのもの。AWSを知らない純粋関数で、仕様の置き場所になる
  • repository: AWSの呼び出し。業務ルールを知らない

層の名前はClean ArchitectureやDDDなど特定の流派に従ったものではなく、責務が伝わるように付けたものです。言いたいのは「業務ルールとAWSの呼び出しを別の場所に置く」ことだけです。AWSのLambdaテストガイドでも、同じ考え方が勧められています。

Simplify your tests and Lambda functions by separating Lambda-specific code from your core business logic.

handlerは入口と出口だけ

import json

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": json.dumps({"data": {"total": order.total}})}

handlerが知っているのは、Lambdaのイベントとレスポンスの形だけです。割引の判定も put_item もありません。

usecaseは流れ

# app/usecases/order_usecase.py
def create_order(items, order_id, repository: OrderRepository) -> Order:
    total = calculate_total(items)
    order = Order(order_id=order_id, items=items, total=total)
    repository.save(order)
    return order

ポイントは、create_order が repositoryを引数で受け取っている ことです。

最初のhandlerはboto3のリソースを自分で作っていたので、テストでは boto3.resource をpatchしてreloadするしかありませんでした。外から受け取る形にすれば、本番では本物を、テストではMockを渡すだけで済みます。これが依存性の注入(DI)です。

domainはルール

# app/domain/order_domain.py
def calculate_total(items: list[OrderItem]) -> int:
    """10,000円以上は10%割引"""
    subtotal = sum(item.price * item.quantity for item in items)
    if subtotal >= 10000:
        return subtotal * 9 // 10
    return subtotal

「1万円以上で1割引き、端数は切り捨て」というルールは、domainの calculate_total にしか書かれていません。

repositoryがAWSを呼ぶ

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=to_item(order))
        except ClientError as e:
            raise OrderSaveError(order.order_id) from e

@cache
def get_order_repository() -> OrderRepository:
    dynamodb = boto3.resource("dynamodb")
    return OrderRepository(dynamodb, os.environ["TABLE_NAME"])

boto3を触るのは、このクラスの中だけです。to_item は注文をDynamoDBのアイテム形式に変換する関数です。dynamodbリソースを引数で受け取っているので、3章ではmotoのリソースやMagicMockを渡すだけでテストできます。boto3の ClientError は OrderSaveError に変換し、usecase以上の層がboto3の例外を知らずに済むようにしています。

get_order_repository が組み立て役で、本物のboto3リソースからrepositoryを作ってhandlerに渡します。put_item を batch_writer に変えたくなっても、変更はこのクラスの中で完結します。

分けると何が変わるか

太ったhandlerでは、1つの変更がテスト全体に波及していました。分けた後は、割引ルールを変えたときはdomainのテストだけが、DynamoDBの呼び出し方を変えたときはrepositoryのテストだけが影響を受けます。どの層が何を守るかが決まるので、テストの置き場所も決まります。

3. 3つのテスト境界を使い分ける

何をどこで守るか

4層の図に3つのテスト境界を重ねた図

  • 業務ロジック(pytest単体): 業務仕様が正しいか。人間が判断する
  • AWS境界(moto): repositoryが期待どおり書き込めるか
  • クラウドでのテスト(実AWS): IAMや環境変数などがつながるか

3つとも欠かせませんし、どれも人間が読んで判断する場面があります。ただ、判断する内容が違います。業務ロジックのテストでは「その仕様でよいか」を判断します。AWS境界のテストでは保存の形が意図どおりか、クラウドでのテストでは環境の設定が正しいかを確かめます。業務仕様を疑って読む場所が業務ロジックのテストに集まる、という違いです。

業務ロジックのテスト(pytest単体)

def test_no_discount():
    assert calculate_total([OrderItem(price=3000, quantity=2)]) == 6000

def test_exact_threshold():
    assert calculate_total([OrderItem(price=10000, quantity=1)]) == 9000

def test_truncation():
    """割引後の端数は切り捨て"""
    assert calculate_total([OrderItem(price=10001, quantity=1)]) == 9000

モックなしで、境界値を1行ずつ足せます。実行もミリ秒で終わります。テストを読むだけで「1万円ちょうどは割引対象」「端数は切り捨て」という仕様が分かるので、「本当にそれでいいんだっけ?」と人間が疑問を持てます。テストがそのまま仕様書になります。

AWS境界のテスト(moto)

@pytest.fixture
def dynamodb_table():
    with mock_aws():
        dynamodb = boto3.resource("dynamodb", region_name="ap-northeast-1")
        create_orders_table(dynamodb)
        yield dynamodb

def test_save(dynamodb_table):
    order = Order(order_id="o-1", items=[OrderItem(price=10000, quantity=1)], total=9000)
    repo = OrderRepository(dynamodb_table, "orders")
    repo.save(order)

    table = dynamodb_table.Table("orders")
    saved = table.get_item(Key={"order_id": "o-1"})["Item"]
    assert saved["total"] == 9000

motoは、boto3からのAWS API呼び出しをメモリ上の模擬実装で受けるライブラリです。fixtureの create_orders_table は、moto上にordersテーブルを作るヘルパー関数です。

このテストは put_item の呼び出し方ではなく、get_item で読み戻した結果を確かめています。1章のテストと違い、batch_writer に変えても同じ値が保存されていれば通ります。ただし、IAMの権限チェックや実運用の障害は再現されません。

motoで自然に起きない障害系はMagicMockで

mock_dynamodb = MagicMock()
mock_table = mock_dynamodb.Table.return_value
mock_table.put_item.side_effect = ClientError(
    {"Error": {"Code": "ProvisionedThroughputExceededException"}}, "PutItem"
)
repo = OrderRepository(mock_dynamodb, "orders")
order = Order(order_id="o-1", items=[OrderItem(price=10000, quantity=1)], total=9000)

with pytest.raises(OrderSaveError):
    repo.save(order)

スループット超過のような実運用の障害は、moto上では自然に発生しません。そこで side_effect で ClientError を起こし、repositoryが OrderSaveError に変換することを確かめます。

ここでもMagicMockを使っていますが、1章のテストとは差し込む場所が違います。1章では boto3.resource をpatchしてhandler全体を通していました。ここではrepositoryのコンストラクタに渡すだけで、テストはrepositoryの中で完結します。

クラウドでのテスト(実AWS)

AWSのテストガイドで「Testing in the cloud」と呼ばれているテストです。デプロイして初めて分かる失敗を見つけます。

IAM、環境変数、デプロイパッケージの中身は、ローカルのテストでは見ていません。だから、この3つはどれもmotoでは通ってしまいます。

テストはデプロイ済みのLambdaをinvokeして、DynamoDBから get_item で読み戻すだけです。正常系で読み書きが通ることだけを少数確かめれば十分です。業務ロジックの境界値は、ここでは繰り返しません。pytestのマーカーで普段のテストと分け、デプロイ後にだけ流します。

テストピラミッド

業務ロジック・AWS境界・クラウドでのテストのテストピラミッド

本数は業務ロジックのテストが最も多く、クラウドでのテストが最も少なくなります。テストピラミッドで大事なのは、層の名前ではなく次の考え方です。

Write lots of small and fast unit tests. Write some more coarse-grained tests and very few high-level tests that test your application from end to end.

1章の太ったhandlerのテストは、下の2段をまたいで1本に混ぜていた状態です。handlerとusecaseは業務判断を持たないので、テストは薄くて構いません。

4. AIはテストを書く。仕様の判断は人間が持つ

ここまでは、人間がテストを書く前提で話してきました。ただ、最近は「テストケースを洗い出して」「テストコードの下書きを書いて」とAIに頼む機会が増えています。
では、ここまでの設計はAIにテストを書かせるときにも意味があるのでしょうか。それとも、AIなら太ったhandlerでも読みやすいテストを書いてくれるのでしょうか。これを確かめるために実験しました。

実験: 同じ依頼を2つの設計に投げる

用意したのは2つのコードです。

  • 太ったhandler: 1章と同じく、合計・割引・保存が1つの関数に入ったLambda
  • 4層版: 2章のとおり、handler・usecase・domain・repositoryに分けたLambda

それぞれにClaude Code(バージョン2.1.296、モデルはclaude-opus-5-5)で同じ依頼を3回ずつ投げました。依頼は2段階です。

  1. src配下のコードに対して必要なテストケースを洗い出す
  2. 洗い出したテストケースのテストコードを書き、pytestで実行する

ただし、実験は各3回でモデルも1つと少ないので、あくまで傾向としてみてください。

moto、patch、層といった言葉は依頼文に入れていません。AIが自分でコードを読んで、どこに何のテストを書くかを決めます。結果を観点ごとにまとめたのが次の表です。

観点 太ったhandler 4層版
境界値 3回とも挙がる 同じ。差はない
割引のテスト handler経由。DBの準備付き domainを直接呼ぶだけ
レビュー フェイクの忠実さ 層の責務に合うか

表の1行目は、AIが書くテストの中身です。1万円ちょうどは割引か、端数はどうなるかといった境界値は、どちらの設計でも3回とも挙がり、書かれたテストは全件通りました。価値の低いテストも両方にありました。float誤差を大量のループで総当たりするテストや、dataclassの既定値を確かめるだけのテストです。良い点も悪い点も、量は同じくらいでした。テストの中身は設計で差がつかなかった、ということです。

差が出たのは表の2行目と3行目で、人間がそのテストをレビューするときの手間です。太ったhandlerでは、割引1件の期待値が仕様どおりかを判断する前に、DynamoDBの準備や再importのfixtureが正しいかを確かめる必要があります。仕様の判断とモックの検証が1本のテストに混ざるからです。4層版では、domainのテスト1行を読めば仕様だけを判断できます。次の節で詳しく見ます。

AIにテストを任せるときに起きること

太ったhandlerでは、業務ルールのテストもhandler経由になります。

面白かったのは、AIがテーブルをフェイクに差し替えるfixtureを自分で作ったことです。ソースコードで分けなかった責務を、AIがテストの中で分けようとしました。ただ、そのフェイクが正しいかは人間が確かめるしかありません。

4層版では、境界値のテストがdomainの純粋関数に1行ずつ置かれ、domainのテストは10件を超えても0.02秒で終わりました。usecaseのテストは、Mockのrepositoryを渡すだけです。AIエージェントが、設計に沿った場所にテストを置いてくれています。

一方で、仕様が未確定の挙動を期待値に固定したテストは、どちらの設計にも同じくらいありました。設計を分けてもここは変わりません。その期待値でよいかは、人間が判断するしかない部分です。

層ごとのレビュー観点

テスト レビューで重視すること
domain(pytest単体) 期待値が業務仕様どおりか
usecase(Mock) 処理の順番と、失敗したときの扱い
repository(moto) 保存した値を読み戻せるか
handler 入力と出力の形

表の4つは2種類に分かれます。

  • domainとhandler: 値やレスポンスが それで正しいか を、人間が仕様として判断する
  • usecaseとrepository: 決まった仕様どおりに動くか を確かめる。処理の順番や保存の形が意図どおりかは見るが、仕様そのものを疑う場面は少ない

人間が仕様を疑いながら時間をかけて読むのは、主にdomainとhandlerのテストです。太ったhandlerでは、1本のテストにこれらの観点が全部混ざるので、どこを疑って読めばよいかが決まりません。

実験でも差が出ています。AIが書いたテストを、テスト本体だけ読めば期待値が妥当か判断できるものと、先にfixtureの中身(テーブルの作り方やフェイクの挙動)を確かめないと判断できないものに分けてみました。前者は、1回の試行あたり太ったhandlerでは0〜1件、4層版では19〜23件でした。4層版のdomainやusecaseのテストは、引数と期待値だけで完結しているからです。

AIを主役にしない

AIにテストを書かせるために設計を分けるのではありません。人間が仕様を理解し、安心して変更・レビューできるように分けます。テストケースの列挙と実装はAIに任せ、人間は期待値と仕様の妥当性をレビューします。

設計が整っていないコードにAIを投入しても、人間が読めないテストが速く増えるだけです。設計を分けておけば、AIが書いたテストも人間が読める場所に置かれます。

まとめ

  • 太ったhandlerのテストは、仕様をモックに埋もれさせる
  • 層に分けて役割を固定すると、人間が読む場所が絞られる
  • 業務仕様は純粋関数で、AWS境界はmotoで、環境の設定は実AWSで守る
  • AIはテストを書く。仕様の妥当性は人間が判断する

いきなり4層にしなくても構いません。手始めに、handlerの中の業務ルールを純粋関数に切り出す だけで、業務仕様のテストが数行で書け、人間が読める場所に仕様が戻ってきます。

おわりに

実験では、設計が整っていなくてもAIはきちんと境界値を挙げ、動くテストを書いてきました。
差が出たのは、人間がそのテストから仕様を判断するまでの手間です。AIがテストを書く時代だからこそ、人間が読める設計の価値が上がっていると感じています。

入力バリデーションや外部APIのモックなど、発表で扱わなかった内容は元ネタの記事にまとめています。

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

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事