# Lambdaのテスト戦略：単体・結合・E2Eの設計、SDKモック、sam local、クラウドでの検証

> AWS Lambdaを本番品質でテストする実装ガイド。AWS公式が定義する単体/結合/E2Eと『クラウドでのテストを優先せよ』という指針、薄いハンドラと純粋ロジックの単体テスト、aws-sdk-client-mock/motoによるSDKモック、sam localの使いどころと限界、使い捨てスタックによる結合・非同期の副作用検証まで、AWS公式仕様に忠実な実コードで解説します。

- 公開日: 2026-06-28
- 著者: 友田 陽大
- タグ: AWS, Lambda, テスト, サーバーレス, テスト容易性
- URL: https://tomodahinata.com/blog/aws-lambda-testing-strategy-unit-integration-mocking-sam-local-guide

## 要点

- AWS公式の指針は明確：『クラウドでのテストを最優先』。モックは机上で通ってもクラウドで落ちる（IAM・クォータ・サービス設定・最新API差分を再現できない）
- ハンドラは薄いアダプタにし、ビジネスロジックを純粋関数に切り出して単体テスト。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前提のローカル検証。権限などは検証できないため『控えめに』使い、本番品質はクラウドで担保
- サーバーレスは結合テストの比重が高い。ロジックの多くがサービス設定に宿るため、ブランチ別の使い捨てスタックを立て、非同期は副作用（SQS/DynamoDB）をポーリング検証する

---

「Lambda のテスト、ローカルでは全部通るのに、本番にデプロイすると権限エラーやイベント形状の違いで落ちる」——サーバーレスのテストで最も多い失望です。原因は明確で、**Lambda のロジックの多くは『コード』ではなく『クラウドのサービス設定（IAM・イベントソース・クォータ）』に宿る**から。手元のモックは、その肝心な部分を再現できません。

この記事は、AWS Lambda を**本番品質でテストする**ための戦略ガイドです。**AWS公式のテスト指針**を起点に、**薄いハンドラの単体テスト**・**SDKのモック**・**sam local の使いどころと限界**・**クラウドでの結合/E2E**までを一気通貫で解説します。題材として、私が中核開発者として構築した[サーバーレス決済プラットフォーム](/case-studies/payment-platform-reliability)（**本番二重課金0件**）を支えたテスト規律も交えます。Lambda 本体の実行モデルは姉妹記事 [AWS Lambda 本番運用ガイド](/blog/aws-lambda-production-guide) に委ね、本稿は**「どうテストするか」一点**に集中します。

> **この記事のルール**：テストの定義・指針・ツール名は **AWS 公式ドキュメント（Lambda testing guide ほか・2026年6月時点）** と各ツールの公式ページに基づきます。ツールのバージョン・APIは改定されるため、本番導入前に必ず公式（末尾「参考」）で確認してください。

---

## 0. メンタルモデル：「クラウドでのテスト」を最優先にする

まず、AWS公式の最重要メッセージを受け取ります。これがサーバーレステストの設計を決めます。

- **公式の指針：クラウドでのテストを最優先せよ**。公式は明言します——「**クラウドベースのテストが、関数とアプリケーションの品質を最も正確に測る**」「セキュリティポリシー・サービス設定・クォータ・最新のAPIシグネチャまで包括的にテストできるのはクラウドだけ」。
- **モックの限界**：「**モックを使うテストは机上では通ってもクラウドで失敗しうる**。結果が最新APIと一致しないことがあり、サービス設定やクォータはテストできない」。
- **エミュレータの限界**：「エミュレータに依存するテストはローカルで成功してもクラウドで失敗しうる（本番のセキュリティポリシー・サービス間設定・クォータ超過のため）」「**エミュレータは控えめに使え**」。
- **だから比重が変わる**：普通のアプリより**結合テストの比重を上げる**。なぜなら、ロジックの多くがサービス設定（IAM・イベントマッピング）に宿るから。「**マネージドサービスそのものはテスト不要だが、それらとの結合はテストが必要**」。

公式が定義する3層を、この順で設計します。

| 層 | 公式の定義 | 例 |
| --- | --- | --- |
| **単体（unit）** | 隔離されたコードブロックに対するテスト | 配送料計算のビジネスロジック検証 |
| **結合（integration）** | 2つ以上のコンポーネント/サービスの相互作用（通常クラウド） | 関数がキューのイベントを処理することの検証 |
| **E2E** | アプリ全体の振る舞い | イベントが各サービス間を流れて注文が記録される |

---

## 1. 単体テスト：薄いハンドラ＋純粋ロジック

公式のテスト容易性の指針は一言です——「**ハンドラはイベントを受け取り、ビジネスロジックに必要な詳細だけを渡す薄いアダプタにせよ**」。こうすれば、Lambda 固有の事情を気にせず、**ビジネスロジックを普通の単体テスト**で固められます。

```ts
// 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万円以上は送料無料
}
```

```ts
// 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) }) };
};
```

```ts
// 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(...)` で応答を定義します。

```ts
// 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 呼び出しをインメモリにモックします。

```python
# 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 |

```bash
# 生成イベントでローカル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ごとに一意プレフィックスのスタックをデプロイ→テスト→破棄**します。

```yaml
# 結合テスト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）**。

```ts
// 非同期フローの結合テスト：イベント投入 → 下流の副作用が現れるまでポーリングして検証
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少なめ」を、サーバーレスでは**結合に厚く**組み替えます（公式が「結合テストに注力せよ」と明言）。

```text
        ▲  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駆動開発の品質ゲート設計は [品質ゲートの作り方](/blog/ai-driven-development-quality-gates-ci-type-safety-test-security) も参照ください。

**「自社のサーバーレスを、机上ではなく本番で確実に動く形でテストしたい」——テストピラミッドの設計から使い捨てスタックのCI、非同期の副作用検証まで、一人 × 生成AI（Claude Code）の速さで伴走します。** 現状のテスト戦略の診断からでも、お気軽にご相談ください。

---

### 参考（公式ドキュメント）

- [Testing serverless functions and applications](https://docs.aws.amazon.com/lambda/latest/dg/testing-guide.html) — 単体/結合/E2Eの定義、クラウド優先、エミュレータは控えめに、結合に注力
- [aws-samples/serverless-test-samples](https://github.com/aws-samples/serverless-test-samples) — 言語別の実例集（公式リファレンス）
- [aws-sdk-client-mock](https://github.com/m-radzikowski/aws-sdk-client-mock) — AWS SDK for JavaScript v3 のモック（`mockClient`/`.on`/`.resolves`）
- [moto](https://pypi.org/project/moto/) — Python向けAWSモック（`@mock_aws`）
- [sam local invoke](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-local-invoke.html) / [start-api](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-local-start-api.html) / [start-lambda](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/using-sam-cli-local-start-lambda.html) — ローカル検証の3コマンドと限界
- [sam local generate-event](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-local-generate-event.html) — サンプルイベント生成
- [Powertools environment variables](https://docs.aws.amazon.com/powertools/typescript/latest/environment-variables/) — `POWERTOOLS_DEV` 等、テスト時の挙動
