「Lambda のテスト、ローカルでは全部通るのに、本番にデプロイすると権限エラーやイベント形状の違いで落ちる」——サーバーレスのテストで最も多い失望です。原因は明確で、Lambda のロジックの多くは『コード』ではなく『クラウドのサービス設定(IAM・イベントソース・クォータ)』に宿るから。手元のモックは、その肝心な部分を再現できません。
この記事は、AWS Lambda を本番品質でテストするための戦略ガイドです。AWS公式のテスト指針を起点に、薄いハンドラの単体テスト・SDKのモック・sam local の使いどころと限界・クラウドでの結合/E2Eまでを一気通貫で解説します。題材として、私が中核開発者として構築したサーバーレス決済プラットフォーム(本番二重課金0件)を支えたテスト規律も交えます。Lambda 本体の実行モデルは姉妹記事 AWS Lambda 本番運用ガイド に委ね、本稿は**「どうテストするか」一点**に集中します。
この記事のルール:テストの定義・指針・ツール名は AWS 公式ドキュメント(Lambda testing guide ほか・2026年6月時点) と各ツールの公式ページに基づきます。ツールのバージョン・APIは改定されるため、本番導入前に必ず公式(末尾「参考」)で確認してください。
0. メンタルモデル:「クラウドでのテスト」を最優先にする
まず、AWS公式の最重要メッセージを受け取ります。これがサーバーレステストの設計を決めます。
- 公式の指針:クラウドでのテストを最優先せよ。公式は明言します——「クラウドベースのテストが、関数とアプリケーションの品質を最も正確に測る」「セキュリティポリシー・サービス設定・クォータ・最新のAPIシグネチャまで包括的にテストできるのはクラウドだけ」。
- モックの限界:「モックを使うテストは机上では通ってもクラウドで失敗しうる。結果が最新APIと一致しないことがあり、サービス設定やクォータはテストできない」。
- エミュレータの限界:「エミュレータに依存するテストはローカルで成功してもクラウドで失敗しうる(本番のセキュリティポリシー・サービス間設定・クォータ超過のため)」「エミュレータは控えめに使え」。
- だから比重が変わる:普通のアプリより結合テストの比重を上げる。なぜなら、ロジックの多くがサービス設定(IAM・イベントマッピング)に宿るから。「マネージドサービスそのものはテスト不要だが、それらとの結合はテストが必要」。
公式が定義する3層を、この順で設計します。
| 層 | 公式の定義 | 例 |
|---|---|---|
| 単体(unit) | 隔離されたコードブロックに対するテスト | 配送料計算のビジネスロジック検証 |
| 結合(integration) | 2つ以上のコンポーネント/サービスの相互作用(通常クラウド) | 関数がキューのイベントを処理することの検証 |
| E2E | アプリ全体の振る舞い | イベントが各サービス間を流れて注文が記録される |
1. 単体テスト:薄いハンドラ+純粋ロジック
公式のテスト容易性の指針は一言です——「ハンドラはイベントを受け取り、ビジネスロジックに必要な詳細だけを渡す薄いアダプタにせよ」。こうすれば、Lambda 固有の事情を気にせず、ビジネスロジックを普通の単体テストで固められます。
// domain.ts — Lambdaを一切importしない純粋ロジック。最速・最安で大量のケースを回せる
export interface Order { id: string; amount: number; destination: "domestic" | "international"; }
export function deliveryFee(order: Order): number {
if (order.amount < 0) throw new RangeError("amount must be non-negative"); // 境界で検証
const base = order.destination === "international" ? 2000 : 500;
return order.amount >= 10_000 ? 0 : base; // 1万円以上は送料無料
}
// handler.ts — 薄いアダプタ。eventからの抽出・検証だけを担い、判断はdomainに委ねる
import type { APIGatewayProxyEventV2 } from "aws-lambda";
import { deliveryFee, type Order } from "./domain";
export const handler = async (event: APIGatewayProxyEventV2) => {
const body = JSON.parse(event.body ?? "{}") as Partial<Order>;
if (!body.id || typeof body.amount !== "number" || !body.destination) {
return { statusCode: 422, body: JSON.stringify({ message: "invalid order" }) };
}
return { statusCode: 200, body: JSON.stringify({ fee: deliveryFee(body as Order) }) };
};
// domain.test.ts — 純粋関数なのでクラウドもDockerも不要。境界値を厚くテストする
import { describe, it, expect } from "vitest";
import { deliveryFee } from "./domain";
describe("deliveryFee", () => {
it("国内は500円、国際は2000円", () => {
expect(deliveryFee({ id: "a", amount: 100, destination: "domestic" })).toBe(500);
expect(deliveryFee({ id: "b", amount: 100, destination: "international" })).toBe(2000);
});
it("1万円以上は送料無料", () => {
expect(deliveryFee({ id: "c", amount: 10_000, destination: "international" })).toBe(0);
});
it("負の金額は拒否する", () => {
expect(() => deliveryFee({ id: "d", amount: -1, destination: "domestic" })).toThrow(RangeError);
});
});
現実的なイベントで叩く:ハンドラ自体を単体テストするなら、
sam local generate-eventが本物に近いサンプルイベント(API Gateway/S3/SQS等)を生成してくれます。手書きのイベントは形状がズレやすいので、生成物を基にします。
2. SDKのモック:依存を切って単体テストを速く保つ
ビジネスロジックの外側で、DynamoDB等のSDK呼び出しを含む薄い層を単体テストしたいことがあります。ここはSDKをモックします。本物のAWSに繋がないので速く、決定的です。
2.1 TypeScript:aws-sdk-client-mock
AWS SDK for JavaScript v3 のクライアントをモックする標準ライブラリが aws-sdk-client-mock(AWS SDK for JavaScriptチーム推奨)。mockClient() で包み、.on(Command).resolves(...) で応答を定義します。
// repo.test.ts — DynamoDBDocumentClient をモックし、本物のAWSに繋がず単体テスト
import { mockClient } from "aws-sdk-client-mock";
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";
import { describe, it, expect, beforeEach } from "vitest";
import { getOrder } from "./repo";
const ddbMock = mockClient(DynamoDBDocumentClient);
beforeEach(() => ddbMock.reset());
it("注文が見つかればドメインオブジェクトに変換して返す", async () => {
ddbMock.on(GetCommand).resolves({ Item: { id: "o1", amount: 1200 } });
await expect(getOrder("o1")).resolves.toEqual({ id: "o1", amount: 1200 });
});
it("見つからなければ null", async () => {
ddbMock.on(GetCommand).resolves({ Item: undefined });
await expect(getOrder("missing")).resolves.toBeNull();
});
2.2 Python:moto
Python では moto(公式の testing guide が名指しで紹介)。@mock_aws デコレータで boto3 呼び出しをインメモリにモックします。
# test_repo.py — moto で DynamoDB をモック(実リソース不要・インメモリ)
import boto3
from moto import mock_aws
from repo import get_order
@mock_aws
def test_get_order_returns_none_when_missing():
ddb = boto3.resource("dynamodb", region_name="ap-northeast-1")
ddb.create_table(
TableName="orders",
KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}],
AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}],
BillingMode="PAY_PER_REQUEST",
)
assert get_order("missing") is None
Powertoolsのテスト:構造化ログ/トレース/メトリクスを使う関数のテストでは、
POWERTOOLS_DEVを立てるとLoggerは整形出力、Tracerは無効化、Metricsは標準出力への送出を無効化します。POWERTOOLS_SERVICE_NAME/POWERTOOLS_METRICS_NAMESPACEをテスト前に設定しておくと、メトリクスのスキーマ検証で落ちるのを防げます。モックの注意:公式が言う通り、モックはIAM権限・クォータ・サービス設定を検証できない。単体テストは「ロジックの正しさ」専用と割り切り、設定の正しさはクラウドの結合テストで担保します(次章)。
3. ローカル検証:sam local の使いどころと限界
手元で素早く動かしたいときは AWS SAM のローカルコマンド。3つを役割で押さえます(いずれもDocker前提)。
| コマンド | 何をするか | 既定ポート |
|---|---|---|
sam local invoke | 関数を1回ローカル実行(--event でイベント注入) | — |
sam local start-api | ローカルHTTPサーバでAPIとして叩く | 3000 |
sam local start-lambda | Lambdaサービスを模したローカルエンドポイント(CLI/SDKから呼ぶ) | 3001 |
# 生成イベントでローカル1回実行 → 入出力を素早く確認
sam local generate-event apigateway http-api-proxy > events/http.json
sam local invoke OrdersFunction --event events/http.json
限界を正しく理解するのが本章の主眼です。公式は明言します——「ローカルテストは迅速な開発・デプロイ前確認には良いが、クラウド上のリソース間の権限などは検証できない。可能な限りクラウドでテストせよ」。sam local で叩いて関数からAWSを呼ぶと、呼び出しIDがLambdaサービス由来ではなくなり、実行ロールを引き受けない——つまりIAMの検証にならない。だから sam local は「ロジックの素早い確認」に留め、本番品質はクラウドで担保します。
4. 結合・E2E:クラウドで、使い捨てスタックで
サーバーレスの品質は、結局クラウドで本物のサービスに対してテストしないと担保できません。公式の推奨パターンを実装に落とします。
4.1 ブランチ/PRごとに使い捨てスタックを立てる
公式は「ユニークな名前のリソースを各スタックに作る」「コードのブランチ境界でスタックを分ける」ことをベストプラクティスとします。CIでPRごとに一意プレフィックスのスタックをデプロイ→テスト→破棄します。
# 結合テストCI(抜粋):PRごとに使い捨てスタックを立て、本物のサービスでテストし、必ず壊す
jobs:
integration:
runs-on: ubuntu-latest
permissions: { id-token: write, contents: read } # OIDCで鍵レス(デプロイ記事参照)
env: { STACK: orders-pr-${{ github.event.number }} }
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v6
with: { role-to-assume: arn:aws:iam::123456789012:role/ci-deploy, aws-region: ap-northeast-1 }
- uses: aws-actions/setup-sam@v2
- run: sam build && sam deploy --stack-name "$STACK" --no-confirm-changeset --resolve-s3
- run: npm run test:integration # 本物のAPI/DB/キューに対して検証
- if: always() # 成否に関わらずスタックを破棄(コスト/汚染防止)
run: sam delete --stack-name "$STACK" --no-prompts
4.2 非同期は「副作用」をポーリングで検証する
イベント駆動(SQS/EventBridge/Streams)は戻り値が無いので、下流の副作用を観測してアサートします。公式のArrange-Act-Assert:関数を起動(Act)→宛先(SQS/DynamoDB)から結果を取得して検証(Assert)。
// 非同期フローの結合テスト:イベント投入 → 下流の副作用が現れるまでポーリングして検証
import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb";
async function waitForItem(table: string, id: string, timeoutMs = 30_000) {
const ddb = new DynamoDBClient({});
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const { Item } = await ddb.send(new GetItemCommand({ TableName: table, Key: { id: { S: id } } }));
if (Item) return Item; // 副作用が現れた=関数が正しく処理した
await new Promise((r) => setTimeout(r, 1000)); // 1秒間隔でポーリング
}
throw new Error(`item ${id} did not appear within ${timeoutMs}ms`); // 失敗を明確に
}
E2Eはモックを使わない(公式)。API Gateway→Lambda→DynamoDB を通しで叩き、各サービスの設定とIAM権限まで含めて検証します。これが「ローカルで通って本番で落ちる」を構造的に潰す唯一の方法です。
5. サーバーレスのテストピラミッド:比重を組み替える
普通のアプリの「単体多め・E2E少なめ」を、サーバーレスでは結合に厚く組み替えます(公式が「結合テストに注力せよ」と明言)。
▲ E2E(少数・モック禁止)
╱ ╲ 主要なユーザーフローを通しで。設定・IAM・サービス間連携を検証
╱ ╲
╱ 結合 ╲ ← サーバーレスはここが厚い
╱ 多め ╲ 関数×実サービス(DB/キュー/API GW)。設定の正しさはここでしか分からない
╱─────────╲
╱ 単体 ╲ 最多・最速・最安。純粋ロジック+SDKモック。IAMやクォータは検証しない
╱─────────────╲
設計指針:
- 単体:ビジネスロジック(純粋関数)と、SDKモックでの薄いデータ層。速く・大量に・境界値を厚く。
- 結合:関数を実サービスに繋いで、設定・権限・イベント形状を検証。サーバーレスではここが品質の主戦場。
- E2E:主要フローを通しで、モック禁止。数は絞る。
- ローカル(sam local):ピラミッドの「層」ではなく、開発中の素早いフィードバック手段。品質保証の代替にはしない。
6. まとめ:Lambdaテスト戦略チートシート
- 最優先はクラウドでのテスト(公式)。モック/エミュレータはIAM・クォータ・設定・最新API差分を再現できない。「机上で通って本番で落ちる」の正体。
- 単体:ハンドラは薄いアダプタ、ビジネスロジックは純粋関数に切り出して厚くテスト。
sam local generate-eventで現実的なイベントを使う。 - SDKモック:TypeScript=aws-sdk-client-mock(AWS SDK for JSチーム推奨)、Python=moto(
@mock_aws・公式紹介)。ロジック専用、設定は検証しない。 - sam local:
invoke/start-api(3000)/start-lambda(3001)はDocker前提。権限は検証できないので控えめに。 - 結合/E2E:ブランチ/PRごとの使い捨てスタックを立て→実サービスで検証→必ず破棄。非同期は副作用をポーリング。E2Eはモック禁止。
- ピラミッド:サーバーレスは結合に厚く。設定とIAMはそこでしか守れない。
私は決済プラットフォームで、本番二重課金0件を「祈り」ではなくテストで担保しました。冪等化ロジックを純粋関数で厚く単体テストし、SQS→Lambda→DynamoDBの冪等な副作用を使い捨てスタックの結合テストで検証し、E2Eで決済フローを通しで守る——この層構造が、止められない決済基盤を安心して変更し続ける土台です。テストを含むAI駆動開発の品質ゲート設計は 品質ゲートの作り方 も参照ください。
「自社のサーバーレスを、机上ではなく本番で確実に動く形でテストしたい」——テストピラミッドの設計から使い捨てスタックのCI、非同期の副作用検証まで、一人 × 生成AI(Claude Code)の速さで伴走します。 現状のテスト戦略の診断からでも、お気軽にご相談ください。
参考(公式ドキュメント)
- Testing serverless functions and applications — 単体/結合/E2Eの定義、クラウド優先、エミュレータは控えめに、結合に注力
- aws-samples/serverless-test-samples — 言語別の実例集(公式リファレンス)
- aws-sdk-client-mock — AWS SDK for JavaScript v3 のモック(
mockClient/.on/.resolves) - moto — Python向けAWSモック(
@mock_aws) - sam local invoke / start-api / start-lambda — ローカル検証の3コマンドと限界
- sam local generate-event — サンプルイベント生成
- Powertools environment variables —
POWERTOOLS_DEV等、テスト時の挙動