Lambda handler のテスト戦略 ~ Lambda Web Adapter でランタイムアップデートの苦労から解放されよう
2026-05-07 | Author: 淡路 大輔
はじめに
あなたの Lambda handler のソースコード、何行ありますか・・・?
AWS Lambda を使っている現場で、こんな状況に心当たりはないでしょうか。
- handler 関数の中にバリデーション、DB アクセス、レスポンス整形がすべて詰まっている
- テストコードが存在しないまま本番で動いている Lambda が何十個もある
- ランタイムのアップデート通知が来るたびに、手動で動作確認をしている
Lambda はサーバーレスの代名詞であり、インフラ管理から解放されるのが最大の魅力です。しかしその一方で、handler に書いたコードのテスタビリティや、ランタイムアップデート時の運用負荷については、あまり語られることがありません。
この記事では、まず、Lambda handler にロジックが集中する Fat handler がなぜテストしづらいのかを構造的に整理します。テストが書けないと、ランタイムアップデートのたびに手動確認に頼ることになり、運用負荷が増していきます。次に、ビジネスロジックを handler から切り出す Thin Handler パターンを紹介します。これだけでもテスタビリティは大きく向上しますが、ローカルでの動作確認や E2E テストまで視野に入れると、Lambda をただの HTTP サーバーとして起動できると便利です。そこで、Lambda Web Adapter を使って handler 自体を書かずに済む方法を紹介します。Web アプリケーション開発と同じツールやテスト手法がそのまま使えるようになります。
builders.flash メールメンバー登録
Unit Test しづらい Lambda、乱立していませんか ?
まず、よく見かける Lambda handler のコードを見てみましょう。この handler は一見シンプルに見えますが、テストを書こうとすると途端に面倒なことに気づきます。
つらいポイント 1 : event の構造がイベントソースごとに違う
Application Load Balancer 経由の場合は ALBEvent、Amazon API Gateway の場合は APIGatewayProxyEvent、直接 Invoke する場合はまた別の構造です。handler のテストを書くためには、呼び出し元に応じた event の JSON をまるごと組み立てる必要があります。
型定義を見ると、必須プロパティがかなりあることに気づくでしょう。テスト対象のロジックに関係ないフィールドまで埋める必要があるのは、書く側にとってなかなかの負担です。
つらいポイント 2: AWS SDK のモックが煩雑
Amazon DynamoDB をはじめとする AWS サービスの呼び出しが handler に直接書かれていると、テスト時には SDK のモックが必要になります。aws-sdk-client-mock のようなライブラリを使えば実現はできますが、セットアップのコード量はそれなりになります。
import { mockClient } from "aws-sdk-client-mock";
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";
import { handeler } from '$PROJECT_DIR/handler'
const ddbMock = mockClient(DynamoDBDocumentClient);
beforeEach(() => {
ddbMock.reset();
});
test("returns user", async () => {
ddbMock.on(GetCommand).resolves({
Item: { userId: "123", name: "Taro" },
});
const event: ALBEvent = {
requestContext: {
elb: { targetGroupArn: "arn:aws:..." },
},
httpMethod: "GET",
path: "/users",
queryStringParameters: { userId: "123" },
headers: {},
body: null,
isBase64Encoded: false,
};
const result = await handler(event);
expect(result.statusCode).toBe(200);
});
テスト対象のロジックは「userId で DynamoDB から取得して返す」というシンプルなものですが、テストコード側ではモックのセットアップと event の組み立てに大部分を費やしています。テストしたい本質に対して、周辺のお膳立てが多すぎます。
つらいポイント 3 : ランタイムアップデートのつらさ
Lambda のランタイムも、他のシステムと同様に 言語自体の EOL (End of Life) に従ってアップデートが必要です。Node.js 18→20→22 のように言語バージョンが EOL を迎えるたびに更新していく必要がありますが、テストコードが整備されていないとどうなるでしょうか。
答えは明白で、ランタイムを上げてデプロイし、手動で動作確認するしかありません。
Lambda が数個であればまだしも、数百個、数千個ある環境ではどうでしょう。ランタイムのアップデートだけで何人月もかかる作業になりかねません。テストがないコードは、変更に対して足枷となります。その足枷はアップデートの先送りを生み、先送りは技術的負債を加速させます。
handler が特殊である理由
なぜこれほどテストが書きづらいのか、handler の構造的な特殊性に原因があります。ここで一歩引いて、Lambda handler の構造的な特殊性について考えてみましょう。Lambda の実行モデルを簡略化すると、次のようになります。
handler の構造と役割
もっとシンプルに表現すれば、Lambda Runtime API → handler(event, context) → ビジネスロジック → AWS サービスという構造です。handler は、Lambda Runtime API から呼び出される関数です。引数として受け取る event と context は Lambda 固有のオブジェクトであり、戻り値のフォーマットもイベントソースごとに決まっています。
参考)https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/runtimes-api.html
つまり handler は、Web フレームワークでいう Controller のような役割を担っているのですが、その実行環境が Lambda Runtime に閉じている点が決定的に違います。
Node.js の Express.js であれば supertest で HTTP リクエストを投げてテストできます。一方で Lambda handler をテストするには、Lambda Runtime が行うイベントの変換をテストコード側で再現する必要があります。handler のテストが書きづらいのは、handler そのものが Lambda というインフラとの接合部品だからです。
この構造を理解すると、テスト戦略の方向性が見えてきます。接合部品のテストが大変なら、接合部品を薄くするか、接合そのものを別の仕組みに任せるかのどちらかです。
テスタブルな Lambda の設計戦略
Thin Handler パターン
最初のアプローチは、handler を可能な限り薄くすることです。handler にはイベントの変換とルーティングだけを担わせ、ビジネスロジックは独立した関数やクラスに切り出します。
先ほどのコードをリファクタリングしてみましょう。
リファクタリング前 (先ほどのコード)
リファクタリング後 (3 つのファイルに分割されます)
この分離によって、getUser は ALBEvent にも APIGatewayProxyEvent にも依存しません。DynamoDB のモックも UserRepository のコンストラクタ注入で行えます。テストは handler を経由せずに書けるようになります。
テストピラミッドの Lambda 適用
Lambda のテストも、一般的なテストピラミッドの考え方が適用できます。
- Unit Test : usecase や repository を個別にテストする。handler は関与しない。
- Integration Test : handler を含めた結合テスト。event のモックは必要だが、ビジネスロジックの正しさは Unit Test で担保済み。
- E2E Test : 実際にデプロイした Lambda に対するスモークテスト。
Thin Handler パターンによってビジネスロジックのテストはだいぶ書きやすくなります。しかし、handler 層のテスト、つまり「event を正しく変換してビジネスロジックに渡せるか」のテストは、依然として Lambda 固有の event 構造に縛られます。
ここからは、この制約そのものを取り払うアプローチを紹介します。
Lambda Web Adapter という選択肢
handler を書かない世界
Lambda Web Adapter (LWA) は、AWS が公開している OSS の Lambda Extension です。
LWA は Lambda Runtime API と HTTP サーバーの間を仲介します。つまり、Lambda Runtime からのイベントを HTTP リクエストに変換して、Lambda 内部で動いている HTTP サーバーに転送してくれます。
なお、LWA のデプロイ方法は 2 つあります。Docker イメージに含める方法と、Lambda Layer として追加する方法です。Layer を使う場合は、通常の Zip パッケージの Lambda にも LWA を適用できます。本記事では、ランタイム非依存というメリットも併せて得られる Docker イメージベースのアプローチを中心に解説します。
開発者がやることは、Node.js Express や Python Flask、Java Spring Boot のような普通の HTTP サーバーを書くだけです。handler は書きません。
Before と After
何が変わるか
テストの観点で整理すると、変化は大きく 5 つあります。
- handler を書かないので、handler のテスト問題がそもそも発生しない
- ローカルで npm run dev するだけでサーバーが起動し、ブラウザや curl で動作確認できる
- テストは supertest や vitest など、Web アプリケーション開発で使い慣れたツールがそのまま使える
- Docker イメージベースでデプロイするため、Lambda ランタイムに依存しない
- 同じコードを Lambda にも Amazon ECS にもローカルにもデプロイできるポータビリティがある
なお、Docker イメージベースにしても言語ランタイムのアップデート自体がなくなるわけではありません。Node.js のバージョンアップは、Dockerfile の FROM を書き換えるか Lambda ランタイムの設定を変更するかの違いであり、変更作業のコスト自体は変わりません。
ランタイムアップデートの運用負荷を本質的に軽減するのは、テストが書きやすい構造になっていることです。LWA によって handler が不要になり、supertest 等でテストが書けるようになれば、アップデート後の動作確認は手動ではなくテストの実行で済みます。恐怖の正体はランタイムの変更作業そのものではなく、変更後に壊れていないかを確認する手段がないことです。
Cold Start について
LWA を使うと、Lambda 起動時に HTTP サーバーの立ち上げが加わるため、Cold Start に数十ミリ秒程度のオーバーヘッドが発生します。HTTP API の用途であれば許容範囲に収まることが多いですが、レイテンシにシビアな要件がある場合は検証をお勧めします。
Express + Lambda Web Adapter のテスト戦略
ここからは、Express と LWA を組み合わせた場合のテスト戦略を見ていきます。実装の詳細よりも、従来の handler ベースと比較して何がどう変わるかに焦点を当てます。
プロジェクト構成
project/
├── src/
│ ├── app.ts # Express アプリケーション
│ ├── routes/
│ │ └── userRoutes.ts
│ ├── usecase/
│ │ └── getUser.ts
│ └── repository/
│ └── userRepository.ts
├── test/
│ ├── routes/
│ │ └── userRoutes.test.ts
│ └── usecase/
│ └── getUser.test.ts
├── Dockerfile
└── cdk/
見てのとおり、Lambda 固有のファイルが存在しません。handler.ts がないですね。
Express アプリケーション
import express from "express";
import { userRoutes } from "/routes/userRoutes";
const app = express();
app.use(express.json());
app.use("/users", userRoutes);
const port = process.env.PORT || 8080;
app.listen(port, () => {
console.log(Server is running on port ${port});
});
export { app };
-----------------------------------
import { Router } from "express";
import { getUser } from "$PROJECT_DIR/usecase/getUser";
export const userRoutes = Router();
userRoutes.get("/", async (req, res) => {
const userId = req.query.userId as string;
if (!userId) {
res.status(400).json({ message: "userId is required" });
return;
}
try {
const user = await getUser(userId);
res.json(user);
} catch {
res.status(404).json({ message: "User not found" });
}
});
このコードには Lambda の影も形もありません。Express の知識だけで読めますし、書けます。
テストコード
import request from "supertest";
import { app } from "$PROJECT_DIR/src/app";
import { getUser } from "$PROJECT_DIR/src/usecase/getUser";
import { vi, describe, it, expect } from "vitest";
vi.mock("$PROJECT_DIR/src/usecase/getUser");
describe("GET /users", () => {
it("returns user when userId is provided", async () => {
vi.mocked(getUser).mockResolvedValue({
userId: "123",
name: "Taro",
});
const response = await request(app).get("/users").query({ userId: "123" });
expect(response.status).toBe(200);
expect(response.body.name).toBe("Taro");
});
it("returns 400 when userId is missing", async () => {
const response = await request(app).get("/users");
expect(response.status).toBe(400);
});
});
従来の handler テストと比較してみてください。ALBEvent を組み立てる必要がなくなり、supertest で HTTP リクエストを投げるだけです。テストコードが、テストしたい内容そのものを表現しています。
usecase 層のテストは Thin Handler パターンの場合と同じです。handler を経由しないテストが書けるという利点は、LWA でも変わりません。
Dockerfile
FROM public.ecr.aws/docker/library/node:24-slim AS builder
WORKDIR /app
COPY package.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM public.ecr.aws/docker/library/node:24-slim
WORKDIR /app
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.0.0 /lambda-adapter /opt/extensions/lambda-adapter
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
CMD ["node", "dist/app.js"]
LWA は /opt/extensions/ にバイナリを配置するだけで Lambda Extension として自動的に起動します。Dockerfile の中で COPY --from を使って LWA の公式イメージからコピーしています。
ランタイムアップデート時のワークフローは次のようになります。
- Dockerfile の FROM のベースイメージを更新する(例: node:20-slim → node:24-slim)
- ローカルでテストを実行する(npm test)
- 問題なければデプロイする
Lambda のマネジメントコンソールでランタイムを変更する必要はありません。CI/CD パイプラインに乗せれば、テストの通過を確認した上で自動デプロイすることもできます。
AWS CDK でのデプロイ
CDK で Lambda Web Adapter を使う場合、DockerImageFunction を使います。
import { DockerImageFunction, DockerImageCode } from "aws-cdk-lib/aws-lambda";
import { HttpApi, HttpMethod } from "aws-cdk-lib/aws-apigatewayv2";
import { HttpLambdaIntegration } from "aws-cdk-lib/aws-apigatewayv2-integrations";
import { Duration } from "aws-cdk-lib";
import { join } from "path";
const fn = new DockerImageFunction(this, "WebAdapterFunction", {
code: DockerImageCode.fromImageAsset(join(__dirname, "..", "..", "app")),
memorySize: 256,
timeout: Duration.seconds(30),
environment: {
TABLE_NAME: table.tableName,
AWS_LWA_PORT: "8080",
},
});
const api = new HttpApi(this, "HttpApi");
api.addRoutes({
path: "/{proxy+}",
methods: [HttpMethod.ANY],
integration: new HttpLambdaIntegration("LambdaIntegration", fn),
});
AWS_LWA_PORT は LWA が HTTP サーバーに転送する先のポートです。Express 側で listen しているポートと合わせてください。
deploy-time-build の活用
Docker イメージベースのデプロイでは、cdk synth のタイミングで Docker ビルドが走ります。開発中に cdk synth を頻繁に実行する場面では、毎回のビルドが気になることがあるかもしれません。
そんなときに検討したいのが、deploy-time-build という CDK コンストラクトです。
これは、Docker イメージやフロントエンドアプリのビルドを CDK のデプロイ時 (AWS CloudFormation のデプロイフェーズ) に行うためのライブラリです。
通常、CDK では cdk synth の段階でローカルマシン上の Docker ビルドが実行されます。deploy-time-build を使うと、このビルドプロセスを AWS CodeBuild 上で実行させることができます。
これにはいくつかのメリットがあります。
- ローカルマシンに Docker 環境が不要になる
- CI/CD 環境でのビルドが安定する (ローカル環境差異の排除)
- ARM / x86 のクロスプラットフォームビルドも CodeBuild 側で吸収できる
Lambda Web Adapter と合わせて Docker イメージベースの運用に移行する際に、ビルドパイプラインの選択肢として覚えておくとよいでしょう。
テスト戦略の選び方
ここまで、handler ベースのアプローチと Lambda Web Adapter ベースのアプローチを見てきました。最後に、それぞれの特性を整理します。
|
項目
|
handler ベース
|
Lambda Web Adapter ベース
|
|---|---|---|
|
テスト容易性
|
event 構造のモックが必要 |
supertest 等でそのまま可能 |
|
ローカル実行
|
SAM CLI / 手動 invoke |
npm run dev で起動 |
|
ランタイム依存
|
Lambda ランタイムに依存 |
Docker ベースなら Lambda ランタイムには非依存 (言語ランタイムのアップデートは必要) |
|
Cold Start
|
低い |
数十 ms のオーバーヘッドあり |
|
イベントソース
|
HTTP / Amazon SQS / Amazon EventBridge 等すべて |
HTTP 向きだが、SQSなどのイベントソースも対応 |
|
ポータビリティ
|
Lambda 専用 |
Lambda / ECS / ローカル |
どちらを選ぶか
すべての Lambda を Web Adapter に置き換えるべきかというと、そうではありません。
HTTP リクエストを処理する Lambda、つまり API Gateway や ALB の背後にある Lambda であれば、Web Adapter は非常に相性が良い選択肢です。ローカル実行、テスト容易性、ポータビリティのすべてが向上します。また、SQS や EventBridge などのイベントをトリガーとする Lambda に関しても、HTTP リクエストの形式で受け取るように記述することができます。ALB の背後にある Lambda にウェブレームワークを載せた構成で試して慣れてから段階的に適用することをおすすめします。
参考) https://github.com/aws/aws-lambda-web-adapter/tree/main/examples/sqs-expressjs
明日からできること
- 自分のプロジェクトの Lambda handler を見て、何行あるか数えてみてください。100 行を超えていたら、Fat Handler の可能性があります。
- ビジネスロジックを 1 つ切り出して、handler を経由しないテストを書いてみてください。テストの書きやすさが変わるはずです。
- 新規の HTTP API を作る機会があれば、Lambda Web Adapter を試してみてください。handler を書かない開発体験は、一度味わうと戻れなくなります。
まとめ
本記事では、Lambda handler のテスト戦略について、課題の整理から具体的な解決策まで紹介しました。
|
課題
|
解決策
|
|---|---|
|
handler にロジックが集中してテストしづらい
|
Thin Handler パターンでビジネスロジックを分離 |
|
event 構造のモックが煩雑
|
Lambda Web Adapter で handler 自体を不要にする |
|
ランタイムアップデートのたびに手動確認
|
Docker ベースにして Dockerfile の FROM を管理 |
|
cdk synth のたびに Docker ビルドが走る
|
deploy-time-build で CodeBuild に委譲 |
Lambda handler は、Lambda というインフラとコードをつなぐ接合部品です。この接合部品にビジネスロジックが詰め込まれていると、テストが書きづらくなり、ランタイムアップデートのたびに手動確認が必要になります。
Thin Handler パターンで接合部品を薄くするか、Lambda Web Adapter で接合そのものを別の仕組みに委ねるか。どちらの戦略を取るにしても、テスタブルなコードを書くという意識が出発点になります。
この記事が、テストコードのない Lambda 関数に向き合うきっかけになれば幸いです。
筆者プロフィール
淡路 大輔 (Awaji Daisuke / @gee0awa)
アマゾン ウェブ サービス ジャパン合同会社
ソリューションアーキテクト
現在は、公共部門のお客様向けの技術支援に従事しています。特にアプリケーションのユーザー体験 (UI/UX) を専門とし、アプリケーションのモダン化の支援を行っています。仕事以外では、家族と過ごす時間、料理を楽しんでいます。