はじめに
この記事は、AWSサービスと外部APIを組み合わせた構成のテストに悩んでいる方を対象にしています。
k8s CronJob + AWS SNS + Lambdaで構成したディスク監視バッチを実装したとき、最初は全部つないでE2Eで動かそうとしました。
しかし「Slackに通知が来ない」という現象が起きたとき、原因がどこにあるのか全く分かりませんでした。
CronJobの判定ロジックなのか、SNSのPublishなのか、LambdaのSNSトリガー受信なのか、Webhookの呼び出しなのか。
全部同時に動かしているので切り分けができません。
そこで、段階的に検証範囲を広げるアプローチに切り替えました。
LocalStackとWireMockを使い分けながら、単体テスト → Lambda結合テスト → E2Eテストの順に確認する構成です。
この記事ではその具体的な手順と、なぜその分割にしたかの判断理由を整理します。
この記事で紹介するサンプルプロジェクトは以下のリポジトリで公開しています。
構成と外部依存の全体像
対象の構成は次のとおりです。
k8s CronJob(毎時0分)
→ 外部APIからディスク使用率取得
→ Parameter Store の閾値と比較
→ 使用率 > 閾値の場合 → SNS Publish
→ Lambda 起動
→ Slack Webhook 通知
この構成では次の外部依存があります。
| 外部依存 | 種別 |
|---|---|
| AWS SNS | AWSサービス |
| AWS Lambda | AWSサービス |
| AWS Parameter Store | AWSサービス |
| AWS Secrets Manager | AWSサービス |
| 外部APIのHTTPエンドポイント | 外部HTTP API |
| Slack Webhook | 外部HTTP API |
これを全部同時にテストしようとすると、問題が起きたときの原因特定が困難になります。
なぜ段階的に分けるのか
全部つないで「動いた / 動かない」だけで確認すると、失敗したときに問題の範囲が絞れません。
例えば「Slackに通知が来ない」という現象が起きたとき、原因の候補は次のとおりです。
- CronJobの閾値判定がおかしい
- 外部APIからのディスク使用率取得が失敗している
- SNS Publishが失敗している
- LambdaがSNSトリガーを受け取っていない
- LambdaがWebhookを叩いていない
- Webhookのリクエスト形式が間違っている
段階を分けると「この段階まで通っている」という確信が積み上がり、問題の範囲を絞り込めます。
Lambda単体の結合テストが通っていれば、LambdaとSNSの連携は問題ないと判断できます。
使ったツール
LocalStack
AWSサービスをローカルで再現するツールです。
SNS・Lambda・Parameter Store・Secrets Managerをクラウドにデプロイせずに動作確認できます。
Lambda結合テストに使います。
「なぜLambda結合テストにLocalStackを使うのか」というと、SNS → Lambdaの配線が正しいかを確認するためです。
ロジックのテスト(単体テスト)が終わった後、次に確認すべきはAWSサービス同士がつながっているかです。
実際のAWSにデプロイすると確認のたびにコストと時間がかかるため、LocalStackでローカルに再現します。
LocalStackの基本的な使い方については LocalStackでAWSサービスをローカルで動かす で整理しています。
WireMock
HTTP APIのスタブサーバーです。
外部APIをWireMockで差し替え、任意のレスポンスを返せるようにします。
E2Eテストに使います。
WireMockを使う理由は次のとおりです。
- 異常系を安定して再現できる:接続エラーやタイムアウトは実際の外部APIでは意図的に発生させることが難しい。スタブ定義に書いておくだけで毎回同じ異常系を再現できます
- テストの再現性が上がる:実際の外部APIはレスポンスの値が変動することがある。ディスク使用率監視であれば実行タイミングによって使用率が変わり、アラートが飛ぶかどうかが変わってしまいます。WireMockなら返す値を固定できます
- 外部APIへの副作用を避けられる:テストのたびに実際のAPIを叩くと、課金が発生したり監視対象のデータが汚染されたりすることがある。WireMockはローカルで完結するためそのリスクがありません
- 外部APIが未完成でも先行してテストできる:APIの開発が終わっていなくてもスタブで代替できます
WireMockの基本的な使い方については WireMock を Docker で動かして外部 API をスタブする で整理しています。
段階1:単体テスト(外部依存なし)
ロジックが正しいかを確認する段階です。LocalStackもWireMockも不要です。
対象は外部依存のない純粋なロジックです。
- 閾値判定(使用率が閾値を超えているか)
- イベントデータの生成
- 通知文面の組み立て
これらはAWSにもSlackにも依存しないため、外部サービスなしでテストできます。
LocalStackもWireMockもあくまでスタブであり、本番のAWSや実際の外部APIとは挙動が異なる部分があります。
スタブを使った結合テストが通っても、本番環境での動作を完全に保証するわけではありません。
その点、外部依存のない純粋な関数のテストは本番との差異が生まれません。
ロジックを外部依存から切り離して純粋関数として書けるほど、ここに厚くテストを積めます。
結合・E2Eテストはスタブの限界を意識しながら補完的に使うものであり、単体テストで固められる範囲を広げることが基本です。
CronJob側(Go)の例です。
// 閾値判定のテスト
func TestShouldAlert(t *testing.T) {
assert.True(t, shouldAlert(92.0, 80.0)) // 使用率 > 閾値 → アラート
assert.False(t, shouldAlert(70.0, 80.0)) // 使用率 <= 閾値 → スキップ
assert.False(t, shouldAlert(80.0, 80.0)) // 使用率 = 閾値 → スキップ
}
// イベント生成のテスト
func TestBuildAlertEvent(t *testing.T) {
event := buildAlertEvent("web-01", "/var/log", 92.0, 80.0)
assert.Equal(t, "web-01", event.Host)
assert.Equal(t, "disk_high", event.AlertType)
assert.NotZero(t, event.DetectedAt)
}
Lambda側(Node.js)の例です。
test("ディスクアラートの文面にhostが含まれる", () => {
const event = {
host: "web-01",
usage_pct: 92.0,
threshold: 80.0,
detected_at: "2026-04-17T00:00:00+09:00",
};
const message = buildSlackMessage(event);
expect(message.text).toContain("web-01");
});
test("使用率がパーセント表記でフォーマットされている", () => {
const event = {
host: "web-01",
usage_pct: 92.0,
threshold: 80.0,
detected_at: "2026-04-17T00:00:00+09:00",
};
const message = buildSlackMessage(event);
expect(message.attachments[0].fields[0].value).toBe("92%");
});
この段階はDockerもLocalStackも不要なので、CIで高速に実行できます。
段階2:Lambda結合テスト(LocalStack)
ロジックが正しいことを確認したら、次にSNS → Lambdaの配線が正しいかを確認します。
LocalStackでSNSとLambdaをローカルに立ち上げ、SNS Publishを手動で実行してLambdaが起動するかを確認します。
# LocalStackを起動
docker compose up localstack
# SNSトピックを作成
aws --endpoint-url=http://localhost:4566 sns create-topic --name disk-high-alert
# LambdaをLocalStackにデプロイ
aws --endpoint-url=http://localhost:4566 lambda create-function \
--function-name slack-notifier \
--runtime nodejs18.x \
--handler index.handler \
--zip-file fileb://function.zip \
--role arn:aws:iam::000000000000:role/lambda-role
# SNSとLambdaをサブスクライブ
aws --endpoint-url=http://localhost:4566 sns subscribe \
--topic-arn arn:aws:sns:ap-northeast-1:000000000000:disk-high-alert \
--protocol lambda \
--notification-endpoint arn:aws:lambda:ap-northeast-1:000000000000:function:slack-notifier
# テストメッセージを発行
aws --endpoint-url=http://localhost:4566 sns publish \
--topic-arn arn:aws:sns:ap-northeast-1:000000000000:disk-high-alert \
--message '{"host":"web-01","usage_pct":92.0,"threshold":80.0,"alert_type":"disk_high","detected_at":"2026-04-17T00:00:00+09:00"}'
この段階で確認することは次のとおりです。
- SNSのPublishでLambdaが起動するか
- LambdaがSNSメッセージをデシリアライズできるか
- Slack Webhookへのリクエストが発行されるか
この段階が通れば、少なくともローカル再現環境におけるLambdaとSNSの連携には問題がないという確信が持てます。
E2Eテストでの失敗がLambda側の問題でない可能性を高め、原因の切り分けがしやすくなります。
ただし、LocalStackはAWS本番の完全代替ではないため、本番相当の最終確認は別途必要です。
段階3:E2Eテスト(LocalStack + WireMock)
k8s CronJobも含めた全フローを確認します。
外部APIはWireMockで差し替えます。
正常系(使用率を返す)と異常系(高使用率・接続エラー)のレスポンスをスタブとして定義します。
実際の外部APIを使わずWireMockを使う理由は2つあります。
1つは接続エラーなどの異常系を安定して再現するためです。
もう1つは実際の外部APIへのアクセスが発生しないため、テストが外部サービスの状態に左右されないためです。
{
"request": {
"method": "GET",
"urlPattern": "/api/hosts/web-01/disk"
},
"response": {
"status": 200,
"body": "{\"host\": \"web-01\", \"usage_pct\": 92.0}",
"headers": { "Content-Type": "application/json" }
}
}
{
"request": {
"method": "GET",
"urlPattern": "/api/hosts/web-error/disk"
},
"response": {
"fault": "CONNECTION_RESET_BY_PEER"
}
}
CronJobを起動して、ディスク使用率取得 → 閾値判定 → SNS Publish → Lambda → Slack Webhookまでのフローが通るかを確認します。
テスト戦略の整理
| テスト種別 | 確認すること | 必要なもの |
|---|---|---|
| 単体テスト(CronJob) | 閾値判定・イベント生成のロジック | なし |
| 単体テスト(Lambda) | 通知文面の生成ロジック | なし |
| Lambda結合テスト | SNS → Lambda の配線 | LocalStack |
| E2Eテスト | 全フロー・ディスクAPI異常系 | LocalStack + WireMock |
単体テストは外部依存なしで完結させます。CIで常時実行できます。
Lambda結合テストはLocalStackのみで完結します。CIで実行可能ですがやや重いです。
E2Eテストはすべてを起動するためローカルまたはマージ前CIで実行します。
まとめ
最初から全部つないでE2Eで動かそうとすると、失敗したときに原因が分かりません。
段階を分けることで「ここまでは正しい」という確信を積み上げながら進めます。
LocalStackとWireMockはそれぞれ違う役割を持ちます。
- LocalStack:AWSサービス同士の配線を確認するために使う
- WireMock:外部APIの異常系を安定して再現するために使う
どちらか一方だけでは検証できない範囲をそれぞれが補います。
先に外部依存のないロジックを単体テストで固めておくことで、結合・E2Eで失敗したときの原因の絞り込みが速くなります。