はじめに
2026年11月11日に、AWS Emulator が公式にリリースされました。AWS SDK や AWS CLI の呼び出しを、AWS アカウントなしで、ローカルや CI 上のエミュレーターに向けられるツールです。公式ドキュメントは、ユースケースのひとつに CI/CD を挙げています。
「Continuous integration and delivery (CI/CD) – Run integration tests in your pipeline with no AWS account and no AWS resources.」
出典: https://docs.aws.amazon.com/emulator/latest/userguide/what-is-service.html
ただ、公式ドキュメントとリポジトリに、GitHub Actions のワークフロー例は、執筆時点では見当たりませんでした。既製のコンテナイメージもありません(公式リポジトリの Issue #14 で要望が出ています)。そこで、自分で GitHub Actions に組み込んで、PR ごとの結合テストとして回せるかを試しました。
前回の記事では、手元の Docker で、CDK と Terraform の構成がどこまで動くかを確認しています。今回はその続きで、CDK を使います。
試したのは次のことです。
- 公式の deb パッケージを、GitHub Actions のランナーに入れて起動する
- SDK で S3、DynamoDB、SQS を呼ぶテストと、CDK でデプロイした API(API Gateway + Lambda + DynamoDB)のテストを、AWS の認証情報なしで回す
- 意図的に壊した PR を出して、CI が赤になるかを見る
- deb のキャッシュが効くかを試す
GitHub Actions と、AWS SDK / CLI の基本的な使い方は、前提とします。
全体像
PR を作ると、GitHub Actions のランナーが起動します。ランナー上でエミュレーターを入れて起動し(ステップ1、2)、環境変数 AWS_ENDPOINT_URL で、テストの向け先をエミュレーターに変えます(ステップ3)。テストは2段階です。層1は、SDK で API を直接呼ぶテストで、エミュレーターの挙動を確かめます(ステップ4)。層2は、CDK でデプロイしたアプリのテストで、PR ごとの結合テストの型になります(ステップ5)。結果は、PR のチェックに緑か赤で返り、AWS には何も送られません。
層2のアプリは、前回の記事で使った検証用のサンプルです。実際のアプリに置き換えるときの型として読んでください。
従来のCIとの違い
AWS 上で動くアプリの結合テストは、実 AWS にデプロイして確かめるか、モックで済ませるかのどちらかが多いと思います。AWS Emulator は、その中間にあたります。
| 実AWSにデプロイするCI | モック中心のCI | AWS EmulatorのCI | |
|---|---|---|---|
| 認証情報 | OIDC などで実ロールを渡す | 不要 | 不要 |
| 待ち時間 | デプロイ待ち | ほぼなし | ジョブ全体で約 2 分 |
| 費用と残骸 | リソース費用と、消し忘れ | なし | 追加料金なし。ジョブが終わると全て消える |
| 確かめられること | 実際の AWS の挙動と権限 | 自分が書いた範囲 | API の挙動とエラー。IAM は AWS と同じには評価されない |
検証環境
- GitHub Actions の
ubuntu-latest(実際はubuntu-26.04、x86_64) - AWS Emulator 1.0.353(
latestを取得。Release date: 2026.11.14) - AWS CDK CLI 2.1148.1、aws-cdk-lib 2.275.0(前回の検証と同じ組み合わせ)、Python 3.12、Node.js 22
- 検証日は 2026-11-14
エミュレーターは1日に何度も更新されます。結果は、この版のものです。
CI のステップ1〜2: エミュレーターを入れて起動する
deb は、Ubuntu や Debian などの Linux で使うインストール用のファイル(パッケージ)の形式で、Windows の .exe や .msi に近いものです。公式が配布していて、apt-get install で入れると aws-emulator コマンドが使えます。ファイル名の amd64 は、一般的な 64 ビット CPU 向けという意味です。
この deb を入れて、バックグラウンドで起動し、ヘルスチェックが通るまで待ちます。
# 公式の deb をダウンロードして、インストールする。
# EMULATOR_DEB_URL は、公式の LTS/latest にある deb の URL
- name: Install AWS Emulator
run: |
curl -fsSLo aws-emulator.deb "$EMULATOR_DEB_URL"
sudo apt-get install -y ./aws-emulator.deb
aws-emulator -v # 入った版を、ログに残す
# バックグラウンドで起動し、ヘルスチェックが通るまで待つ
- name: Start AWS Emulator
run: |
# -p: ポート番号
# --telemetry off: 利用状況の送信を止める
# --idle-timeout 1800: 1800 秒、API の呼び出しがなければ、自動で止まる
# (ジョブが途中で止まっても、エミュレーターが動き続けないようにする)
# nohup と末尾の &: ステップが終わっても、プロセスが残るようにする
nohup aws-emulator -p 9297 --telemetry off --idle-timeout 1800 \
> "$RUNNER_TEMP/emulator.log" 2>&1 &
# /__admin/health が成功するまで、1秒ごとに最大 60 回確認する。
# --cacert: エミュレーターが作る自己署名の CA を、信頼させる指定
for i in $(seq 60); do
curl -sf --cacert "$HOME/.aws/emulator/9297/ca.pem" \
https://127.0.0.1:9297/__admin/health > /dev/null && exit 0
sleep 1
done
# 60 秒たっても起動しなければ、エミュレーターのログを出して、失敗にする
cat "$RUNNER_TEMP/emulator.log"
exit 1
インストールは 16〜37 秒かかり(実行によってばらつきます)、大半は apt の展開(10〜15 秒)でした。起動は約 1 秒で、nohup で起動したプロセスは、後続のステップからも使えました。aws-emulator -v の出力は、次のとおりです。
Version: 1.0.353
Release date: 2026.11.14
Commit: ca8a89dedfa8e4b5e67a3c90dc736f921ba409a1
起動直後のログに curl: option --cacert: is badly used here と出ることがありますが、ca.pem がまだ作られていない間のメッセージで、約 1 秒で成功するので問題ありません。
CI のステップ3: 向け先を環境変数で切り替える
環境変数を GITHUB_ENV に書いて、後続のステップに引き継ぎます。
- name: Point SDK and CLI at the emulator
run: |
# エミュレーターが、起動のたびに作る、認証情報、設定、CA の置き場所
d="$HOME/.aws/emulator/9297"
{
# 接続先。これが設定されていると、SDK と CLI は、実 AWS ではなく、エミュレーターに接続する
echo "AWS_ENDPOINT_URL=https://127.0.0.1:9297"
# エミュレーターが、起動時に作る、認証情報と設定のファイル
echo "AWS_SHARED_CREDENTIALS_FILE=$d/emulator_credentials"
echo "AWS_CONFIG_FILE=$d/config"
# 自己署名の CA を信頼させる。AWS CLI と boto3 は、この AWS_CA_BUNDLE を使う
echo "AWS_CA_BUNDLE=$d/ca.pem"
# Node.js の SDK は、AWS_CA_BUNDLE を使わないので、こちらにも設定する(後述の Node.js の章)
echo "NODE_EXTRA_CA_CERTS=$d/ca.pem"
# EC2 のインスタンスメタデータから、認証情報を探しに行かせない
echo "AWS_EC2_METADATA_DISABLED=true"
# ホスト名にプレフィックスが付く API(Cloud Map、Step Functions など)が、
# 別のホストに飛ばないようにする(公式の案内)
echo "AWS_DISABLE_HOST_PREFIX_INJECTION=true"
# リージョン。エミュレーターは、1つのリージョンで使う
echo "AWS_REGION=us-east-1"
echo "AWS_DEFAULT_REGION=us-east-1"
} >> "$GITHUB_ENV" # 後続のステップに、環境変数として引き継ぐ
このステップは、起動と起動待ちのあとに置きます。認証情報のファイルと CA が、起動のたびに作り直されるためです。ジョブには、実 AWS の認証情報を置かず、aws-actions/configure-aws-credentials も使いません。
$ aws sts get-caller-identity
{
"UserId": "AIDAH5I7GEMO7ADXNFAZU",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:user/emulator"
}
AWS の認証情報がないのに、エミュレーターのアカウント(000000000000)として応答しました。続けて S3 のバケットの作成と一覧も成功しています。
CI のステップ4: SDK でテストを回す(層1)
層1は、アプリではなく、エミュレーターの挙動を SDK で直接確かめる段階です。モックでは見えにくい挙動を、CI で拾えるかを見ます。boto3 と pytest で、5つのテストを書きました(コメントは記事用に足したもので、動きは検証したコードと同じです)。
まず、共通のコードです。
共通のコード(tests/test_integration.py の冒頭)
import time
import uuid
import boto3
import pytest
from botocore.exceptions import ClientError
def _suffix():
# バケット名やテーブル名が重ならないようにするための、ランダムな8文字。
# 1回の実行の中で、バケットやテーブルを何度も作るため、名前を変えている
return uuid.uuid4().hex[:8]
# boto3 のクライアントは、テスト全体で1つを使い回す(scope="session")。
# エンドポイントと認証情報は、コードに書かない。「向け先を環境変数で切り替える」で設定した環境変数
# (AWS_ENDPOINT_URL、AWS_SHARED_CREDENTIALS_FILE など)を、boto3 が自動で読む。
# そのため、同じコードが、環境変数を外せば、実 AWS にも向く
@pytest.fixture(scope="session")
def s3():
return boto3.client("s3")
@pytest.fixture(scope="session")
def ddb():
return boto3.client("dynamodb")
@pytest.fixture(scope="session")
def sqs():
return boto3.client("sqs")
S3: 往復と、存在しないキー
S3 では、作成、書き込み、読み出しが状態を保って通るかと、存在しないキーを読んだときに、AWS と同じ NoSuchKey が返るかを見ます。
S3 のテスト(test_s3_roundtrip、test_s3_missing_key_is_nosuchkey)
def test_s3_roundtrip(s3):
bucket = f"ci-{_suffix()}"
# バケットを作る
s3.create_bucket(Bucket=bucket)
# オブジェクトを書き込む
s3.put_object(Bucket=bucket, Key="a.txt", Body=b"hello")
# 同じキーを読み出す。書いた内容が返れば、「作る、書く、読む」が、状態を保って通っている
body = s3.get_object(Bucket=bucket, Key="a.txt")["Body"].read()
assert body == b"hello"
def test_s3_missing_key_is_nosuchkey(s3):
bucket = f"ci-{_suffix()}"
s3.create_bucket(Bucket=bucket)
# 存在しないキーを読むと、例外(ClientError)になる
with pytest.raises(ClientError) as e:
s3.get_object(Bucket=bucket, Key="nope")
# エラーの種類は、レスポンスの Error.Code に入っている。AWS と同じ NoSuchKey かを確認する
assert e.value.response["Error"]["Code"] == "NoSuchKey"
DynamoDB: 条件付き書き込み
DynamoDB では、条件付き書き込みを試します。「同じ ID のアイテムがまだないときだけ書く」条件で2回書くと、1回目は成功し、2回目は ConditionalCheckFailedException になります。二重登録を防ぐ処理の基本の動きで、モックでは条件式の評価まで再現するのに手間がかかります。
DynamoDB のテスト(test_dynamodb_conditional_write)
def test_dynamodb_conditional_write(ddb):
table = f"ci-{_suffix()}"
# ID(文字列)だけをキーにした、オンデマンド課金のテーブルを作る
ddb.create_table(
TableName=table,
BillingMode="PAY_PER_REQUEST",
AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}],
KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}],
)
# 作成直後のテーブルの状態を、ログに出して記録する。
# (最初の実行で、直後の書き込みが ResourceNotFoundException になったため、状態を確認できるようにした)
status = ddb.describe_table(TableName=table)["Table"]["TableStatus"]
print(f"table status right after create_table: {status}")
# ACTIVE になるまで待つ。1秒ごとに、最大 30 回確認する。
# 作ってすぐ書き込むのではなく、待ってから書き込む
ddb.get_waiter("table_exists").wait(
TableName=table, WaiterConfig={"Delay": 1, "MaxAttempts": 30}
)
item = {"id": {"S": "1"}}
# 条件付き書き込み。「同じ ID のアイテムが、まだないときだけ」書く。1回目は成功する
ddb.put_item(
TableName=table, Item=item,
ConditionExpression="attribute_not_exists(id)",
)
# 同じ ID を、もう一度書く。アイテムが既にあるので、条件を満たさず、例外になる
with pytest.raises(ClientError) as e:
ddb.put_item(
TableName=table, Item=item,
ConditionExpression="attribute_not_exists(id)",
)
assert e.value.response["Error"]["Code"] == "ConditionalCheckFailedException"
SQS: メッセージの再配信
SQS では、メッセージの再配信を試します。ビジビリティタイムアウトは、受信したメッセージが一定時間ほかの受信から見えなくなる仕組みで、この間に削除しないと、メッセージが再び見えて再配信されます。削除を忘れた処理は、同じメッセージを何度も処理します。AWS organization の DEV.to の記事が、「モックでは通るが、エミュレーターでは失敗する」例として挙げている観点です。
SQS のテスト(test_sqs_redelivery_after_visibility_timeout)
def test_sqs_redelivery_after_visibility_timeout(sqs):
"""メッセージを削除しないと、ビジビリティタイムアウト後に再配信される。
AWS organization の DEV.to 記事(モックが見逃す不具合の例)と同じ観点。"""
# ビジビリティタイムアウトを 2 秒にしたキューを作る(待ち時間を短くするため)
q = sqs.create_queue(
QueueName=f"ci-{_suffix()}",
Attributes={"VisibilityTimeout": "2"},
)["QueueUrl"]
sqs.send_message(QueueUrl=q, MessageBody="order-1")
# 1回目の受信。受信した時点で、メッセージは 2 秒間、ほかの受信から見えなくなる。
# ReceiptHandle は、この受信を識別する値(再配信されると、値が変わる)
first = sqs.receive_message(QueueUrl=q, WaitTimeSeconds=1)["Messages"][0]
handle1 = first["ReceiptHandle"]
# 削除せずに、3 秒待つ(ビジビリティタイムアウトの 2 秒を、超える)
time.sleep(3)
# 2回目の受信。メッセージが、再び見えるようになっているはず。
# 見えるまでに時間がかかる場合に備えて、最大 5 回、受信を繰り返す
second = None
for _ in range(5):
r = sqs.receive_message(QueueUrl=q, WaitTimeSeconds=1)
if r.get("Messages"):
second = r["Messages"][0]
break
assert second is not None, "再配信されなかった"
# 同じメッセージが、再配信されている
assert second["Body"] == "order-1"
# 受信が別なので、ReceiptHandle は、1回目と違う値になる
assert second["ReceiptHandle"] != handle1
未実装の API: ステータスを記録する
最後は未実装の API です。公式は、未実装の API は 501 を返すと案内しています。ただ、リリースで実装されると結果が変わるため、Athena の list_work_groups を呼び、200 か 501 のどちらかなら通して、ステータスを記録するだけにしています。
未実装 API のテスト(test_unimplemented_api_status_is_recorded)
def test_unimplemented_api_status_is_recorded():
"""未実装のAPIは501になる想定。リリースで実装される可能性があるため、
ステータスを出力して記録するだけにする(-s で表示される)。"""
athena = boto3.client("athena")
try:
# 実装済みなら、そのまま成功する(HTTP 200)
athena.list_work_groups()
status = 200
except ClientError as e:
# 未実装なら、エラーになる。レスポンスから、HTTP ステータスを取り出す
status = e.response["ResponseMetadata"]["HTTPStatusCode"]
# -s を付けて pytest を実行すると、この出力が、ログに表示される
print(f"athena list_work_groups -> HTTP {status}")
assert status in (200, 501)
実行結果です。5件とも成功しました。
============================= test session starts ==============================
platform linux -- Python 3.12.16, pytest-9.2.0, pluggy-1.7.0
collected 5 items
tests/test_integration.py::test_s3_roundtrip PASSED
tests/test_integration.py::test_s3_missing_key_is_nosuchkey PASSED
tests/test_integration.py::test_dynamodb_conditional_write table status right after create_table: ACTIVE
PASSED
tests/test_integration.py::test_sqs_redelivery_after_visibility_timeout PASSED
tests/test_integration.py::test_unimplemented_api_status_is_recorded athena list_work_groups -> HTTP 200
PASSED
============================== 5 passed in 6.48s ===============================
SQS は、3 秒待つと再配信されて、ReceiptHandle が変わりました。Athena は 501 になると思っていましたが、HTTP 200 でした。この版では実装済みのようです。
DynamoDB のテストは、最初の実行で一度失敗しました。テーブルを作った直後の put_item が ResourceNotFoundException になったのです。原因は分かっていません。ログには CreateTable の記録しかなく、失敗の記録はありませんでした。ACTIVE になるまで待つ処理(table_exists のウェイター)を入れてからは、10 回の実行がすべて成功しています。ただ、作成直後の状態は ACTIVE と出ていたので、待つ処理が問題を隠しているだけかもしれません。
CI のステップ4の補足: Node.js では NODE_EXTRA_CA_CERTS が要る(層1)
エミュレーターの証明書は、自己署名です。AWS CLI と boto3 は AWS_CA_BUNDLE を見ますが、Node.js の SDK では、そうなりませんでした。同じ S3 の呼び出しを、Node.js で試します。
import { S3Client, ListBucketsCommand } from "@aws-sdk/client-s3";
// エンドポイントは AWS_ENDPOINT_URL、リージョンは AWS_REGION から、SDK が読む。
// forcePathStyle: バケット名をホスト名に入れない、パススタイルにする
const client = new S3Client({ forcePathStyle: true });
// バケットの一覧を取得する。エミュレーターに接続できれば、これまでに作ったバケットの数が返る
const r = await client.send(new ListBucketsCommand({}));
console.log("buckets:", (r.Buckets ?? []).length);
NODE_EXTRA_CA_CERTS を設定したときは、成功しました(buckets: 3)。この環境変数だけを外すと、次のエラーになります。
Error: self-signed certificate
at TLSSocket.onConnectSecure (node:internal/tls/wrap:1787:34)
...
code: 'DEPTH_ZERO_SELF_SIGNED_CERT',
'$metadata': { attempts: 1, totalRetryDelay: 0 }
このとき AWS_CA_BUNDLE は設定したままでした。それでも失敗したので、Node.js の SDK は AWS_CA_BUNDLE を使っていません。エミュレーターの CA は Node.js が信頼する CA に含まれないため、NODE_EXTRA_CA_CERTS で追加します。Java の SDK では、公式が truststore.p12 の設定を案内しています(試していません)。
CI のステップ5: CDK でデプロイして API をテストする(層2)
ここからが、PR ごとの結合テストに近い部分です。前回の記事で動いた、アイテムを登録・取得する小さな API(API Gateway の POST /i と GET /i/{k}、Lambda、DynamoDB)を、CI で CDK からデプロイして呼び出します。CDK は、前回動いた組み合わせ(CLI 2.1148.1、aws-cdk-lib 2.275.0)に固定し、npm ci で入れます。
エミュレーターが実行する Lambda には、エンドポイントも認証情報も自動では渡されません。Lambda から DynamoDB を呼ぶには、環境変数を Lambda にも設定します。認証情報や CA のパスは、ランナーのホームディレクトリの下にあるので、デプロイ時に -c emuDir=... で CDK に渡しました。
# CDK の bootstrap(CDK が使う S3 バケットなどを作る)と deploy を、エミュレーターに対して実行する
- name: CDK bootstrap and deploy
working-directory: cdk
env:
CI: "true" # CI として実行する
run: |
# アカウント ID は、エミュレーターのアカウント(000000000000)を指定する
npx cdk bootstrap aws://000000000000/us-east-1 --no-notices
# $CDK_CTX は、-c emuDir=<エミュレーターのディレクトリ> の形の、CDK への引数
# (環境変数にまとめてある)。次のコードで受け取る
# --require-approval never: IAM の変更の確認を、聞かずに進める
npx cdk deploy --require-approval never --no-notices $CDK_CTX
// デプロイ時に、-c emuDir=<エミュレーターのディレクトリ> で渡された値を受け取る
const emuDir: string | undefined = this.node.tryGetContext('emuDir');
// Lambda に渡す環境変数。テーブル名は、いつも渡す
const environment: Record<string, string> = { TABLE_NAME: items.tableName };
if (emuDir) {
// エミュレーターの上で動かすときだけ、エンドポイントと認証情報を Lambda に渡す。
// エミュレーターは、Lambda の実行環境に、これらを自動では渡さないため
environment.AWS_ENDPOINT_URL = 'https://127.0.0.1:9297';
environment.AWS_SHARED_CREDENTIALS_FILE = `${emuDir}/emulator_credentials`;
environment.AWS_CONFIG_FILE = `${emuDir}/config`;
// Lambda の中の Node.js も、自己署名の CA を信頼させる必要がある(「Node.js では NODE_EXTRA_CA_CERTS が要る」の章)
environment.NODE_EXTRA_CA_CERTS = `${emuDir}/ca.pem`;
}
前回は、AWS::Lambda::Permission の statementId が100文字を超える問題と、同じスタックでのステージ名の重複で、デプロイが失敗しました。今回は、論理 ID を短くし、API を1つにして避けています。今の版で解消したのかは、切り分けていません。
デプロイするアプリの全体は、次のとおりです。長いので、折りたたんでいます。「PR で壊してみる」の章で壊す箇所は、スタックの grantReadWriteData と、Lambda の json(201, ...) です。
CDK のスタック(lib/app-stack.ts)
import * as cdk from 'aws-cdk-lib/core';
import { Construct } from 'constructs';
import * as ddb from 'aws-cdk-lib/aws-dynamodb';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigw from 'aws-cdk-lib/aws-apigateway';
export class AppStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// CI では -c emuDir=<エミュレーターのディレクトリ> を渡す。
// エミュレーターが実行する Lambda から、エミュレーターの DynamoDB を呼ぶために必要
const emuDir: string | undefined = this.node.tryGetContext('emuDir');
// アイテムを保存するテーブル。ID(文字列)だけをキーにする。
// DESTROY: cdk destroy のときに、テーブルも削除する(CI の使い捨て環境のため)
const items = new ddb.Table(this, 'Items', {
partitionKey: { name: 'id', type: ddb.AttributeType.STRING },
billingMode: ddb.BillingMode.PAY_PER_REQUEST,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
// Lambda に渡す環境変数(この章の前半で説明した部分)
const environment: Record<string, string> = { TABLE_NAME: items.tableName };
if (emuDir) {
environment.AWS_ENDPOINT_URL = 'https://127.0.0.1:9297';
environment.AWS_SHARED_CREDENTIALS_FILE = `${emuDir}/emulator_credentials`;
environment.AWS_CONFIG_FILE = `${emuDir}/config`;
environment.NODE_EXTRA_CA_CERTS = `${emuDir}/ca.pem`;
}
// API の処理を担当する Lambda。コードは lambda/ ディレクトリから取り込む
const appFn = new lambda.Function(this, 'App', {
runtime: lambda.Runtime.NODEJS_22_X,
handler: 'items.handler',
code: lambda.Code.fromAsset('lambda'),
environment,
});
// Lambda に、テーブルの読み書きの権限を付ける。
// 「PR で壊してみる」の PR 1 では、この1行を削除して、権限の付け忘れを再現した
items.grantReadWriteData(appFn);
// API Gateway。ステージ名は v1。
// POST /i でアイテムを保存し、GET /i/{k} でアイテムを取得する。どちらも同じ Lambda が処理する
const api = new apigw.RestApi(this, 'A', { deployOptions: { stageName: 'v1' } });
const res = api.root.addResource('i');
res.addMethod('POST', new apigw.LambdaIntegration(appFn));
res.addResource('{k}').addMethod('GET', new apigw.LambdaIntegration(appFn));
// テストが、デプロイ後の API の ID とテーブル名を取得できるように、出力しておく
new cdk.CfnOutput(this, 'ApiId', { value: api.restApiId });
new cdk.CfnOutput(this, 'TableName', { value: items.tableName });
}
}
Lambda のコード(lambda/items.js)
検証で使ったコードは、失敗したときの調査用に、実行環境の情報も返していました。ここでは、その部分を省いています。
const { DynamoDBClient, PutItemCommand, GetItemCommand } = require('@aws-sdk/client-dynamodb');
// エンドポイントと認証情報は、Lambda の環境変数から、SDK が読む
const ddb = new DynamoDBClient({});
// API Gateway に返すレスポンスを作る、補助関数
const json = (statusCode, obj) => ({
statusCode,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(obj),
});
exports.handler = async (event) => {
try {
// POST /i: リクエストの本文から id と text を取り出して、DynamoDB に保存する
if (event.httpMethod === 'POST') {
const { id, text } = JSON.parse(event.body || '{}');
await ddb.send(new PutItemCommand({
TableName: process.env.TABLE_NAME,
Item: { id: { S: id }, text: { S: text } },
}));
// 保存できたら 201(Created)を返す。「PR で壊してみる」の PR 2 では、これを 200 に変えた
return json(201, { saved: id });
}
// GET /i/{k}: パスの k を ID として、DynamoDB から取得する
const id = event.pathParameters && event.pathParameters.k;
const r = await ddb.send(new GetItemCommand({
TableName: process.env.TABLE_NAME,
Key: { id: { S: id } },
}));
// アイテムがあれば 200、なければ 404 を返す
return r.Item ? json(200, { id, text: r.Item.text.S }) : json(404, { message: 'not found' });
} catch (e) {
// DynamoDB の呼び出しに失敗したら(権限不足など)、500 とエラーの種類を返す。
// 「PR で壊してみる」の PR 1 では、ここに AccessDeniedException が返った
return json(500, { error: e.name, message: e.message });
}
};
テストは、デプロイ後の API を、HTTP で呼び出して確かめます。3つのテストを書きました。
| テスト | 確かめること |
|---|---|
test_post_then_get |
POST で保存して、GET で同じ内容が返る(Lambda、API Gateway、DynamoDB が通しでつながっている) |
test_get_missing_item_is_404 |
存在しない ID の GET が、404 になる |
test_item_is_stored_in_dynamodb |
POST した内容が、API を介さずに DynamoDB を直接読んでも、保存されている |
API は、Host ヘッダーで対象を指定して、エミュレーターのエンドポイントを呼びます。コードは、次のとおりです。
API のテスト(tests/test_api.py)
import os
import uuid
import boto3
import pytest
import requests
# テスト対象のスタックの名前
STACK = os.environ.get("STACK_NAME", "EmulatorCiApp")
@pytest.fixture(scope="module")
def outputs():
# デプロイしたスタックの出力(ApiId と TableName)を、CloudFormation から取得する。
# エミュレーターの CloudFormation に、問い合わせている
cf = boto3.client("cloudformation")
out = cf.describe_stacks(StackName=STACK)["Stacks"][0]["Outputs"]
return {o["OutputKey"]: o["OutputValue"] for o in out}
@pytest.fixture(scope="module")
def call(outputs):
# エミュレーターのエンドポイント(https://127.0.0.1:9297)
base = os.environ["AWS_ENDPOINT_URL"]
# エミュレーターは、API Gateway を1つのエンドポイントで受ける。
# どの API かは、Host ヘッダーで指定する
host = f"{outputs['ApiId']}.execute-api.us-east-1.amazonaws.com"
# 自己署名の CA(AWS_CA_BUNDLE と同じファイル)
ca = os.environ["AWS_CA_BUNDLE"]
def _call(method, path, **kwargs):
return requests.request(
method,
# エンドポイントに、ステージ名(v1)とパスを付ける
f"{base}/v1{path}",
headers={"Host": host},
verify=ca,
timeout=30,
**kwargs,
)
return _call
def test_post_then_get(call):
# 他のテストと ID が重ならないように、ランダムな ID を使う
item_id = f"item-{uuid.uuid4().hex[:8]}"
# POST で保存する。201 と、保存した ID が返る
r = call("POST", "/i", json={"id": item_id, "text": "HelloEmulator from CI"})
assert r.status_code == 201, r.text
assert r.json() == {"saved": item_id}
# GET で取得する。保存した内容が、そのまま返る
r = call("GET", f"/i/{item_id}")
assert r.status_code == 200, r.text
assert r.json() == {"id": item_id, "text": "HelloEmulator from CI"}
def test_get_missing_item_is_404(call):
# 保存していない ID を取得すると、404 になる。
# このテストは POST を使わないので、POST の返却ステータスを変えても影響を受けない
r = call("GET", f"/i/nope-{uuid.uuid4().hex[:8]}")
assert r.status_code == 404, r.text
def test_item_is_stored_in_dynamodb(call, outputs):
item_id = f"item-{uuid.uuid4().hex[:8]}"
r = call("POST", "/i", json={"id": item_id, "text": "stored"})
assert r.status_code == 201, r.text
# API を介さず、DynamoDB を直接読む。
# API のレスポンスだけでなく、実際にテーブルへ保存されたことを確かめる
ddb = boto3.client("dynamodb")
got = ddb.get_item(
TableName=outputs["TableName"], Key={"id": {"S": item_id}}
)
assert got["Item"]["text"]["S"] == "stored"
| 操作 | 結果 |
|---|---|
cdk bootstrap と cdk deploy
|
1回目で成功。bootstrap と deploy を合わせて 29〜40 秒(deploy の Total time は 13〜16 秒) |
| POST、GET、存在しない ID の GET | 201、200、404。DynamoDB への保存も、get_item で確認 |
| 再デプロイ(変更なし) |
no changes(4〜7 秒) |
cdk diff --fail |
There were no differences |
cdk destroy |
destroyed(10〜14 秒) |
tests/test_api.py::test_post_then_get PASSED
tests/test_api.py::test_get_missing_item_is_404 PASSED
tests/test_api.py::test_item_is_stored_in_dynamodb PASSED
============================== 3 passed in 0.39s ===============================
前回の Terraform では、apply の直後に plan を実行すると DynamoDB で差分が出ましたが、CDK では、再デプロイも cdk diff も差分なしでした。cdk diff は CloudFormation の変更セットを作って差分を取るので、変更セットの作成には対応しているようです。
再デプロイ、cdk diff、削除のステップには continue-on-error: true を付けているため、失敗してもジョブは緑のままです。失敗したステップの conclusion も success と出るので、各ステップの outcome を、ジョブの Summary に書き出して確認しています。
PR で壊してみる(層2)
CI の価値は、PR に不具合があるときに、赤になって気づけることです。そこで、通る PR と、意図的に壊した PR を出しました。壊した PR は、マージせずに閉じています。
README だけを変えた PR は、緑でした(約 2 分 14 秒)。ジョブの環境変数に、AWS_ACCESS_KEY_ID、AWS_SECRET_ACCESS_KEY、AWS_SESSION_TOKEN、AWS_PROFILE はなく、AWS の認証情報なしで回っています。
PR で起動した実行の一覧です。V10a が README だけの PR、V10b が権限付与を外した PR(次の表の PR 1)、V10c が返すステータスを変えた PR(PR 2)です。V10b と V10c は赤になり、revert すると緑に戻りました。
壊した PR は、変更の種類が違う2つです。
| PR 1: 権限付与を外す(IaC の変更) | PR 2: 返すステータスを変える(アプリのコードの変更) | |
|---|---|---|
| 変更 | DynamoDB への権限付与(grantReadWriteData)を削除 |
Lambda の POST の返却ステータスを 201 から 200 に変更 |
| 結果 | 赤。API のテスト3件とも失敗 | 赤。3件中2件が失敗(404 を確認するテストは成功) |
| エラー |
AccessDeniedException: User: arn:aws:sts::000000000000:assumed-role/… is not authorized to perform: dynamodb:PutItem on resource: … because no identity-based policy allows the dynamodb:PutItem action。GET は dynamodb:GetItem。API は 500 |
assert 200 == 201 |
| 失敗までの実行時間 | 84 秒 | 81 秒 |
| revert 後 | 緑に戻った(123 秒) | 緑に戻った(128 秒) |
PR 1 の赤になった実行の画面です。
PR 1 は、Lambda のロールの権限不足を、エミュレーターが AWS の形式のメッセージで検出しました。ただ、公式は、IAM を AWS と同じようには評価しないと案内しています。確かめられたのは、「権限の付け忘れに、CI で気づけた」ことまでです。
アプリのコードの誤りは、テストがその箇所を確認しているかで、検出できるかが決まります。PR 2 で 404 のテストが成功したのは、POST の変更の影響を受けないためです。
deb のキャッシュは効くか
deb は約 174 MiB あるので、毎ジョブ取得すると遅いのではと考えて、actions/cache でキャッシュしてみました。
結果は、キャッシュなしでダウンロード約 1 秒とインストール 20 秒、キャッシュありで復元約 3 秒とインストール 16 秒でした。ダウンロードのほうが復元より速く、時間の大半は apt の展開で、インストール時間は 16〜37 秒とばらつくため、20 秒と 16 秒の差は誤差の範囲です。効果が見えなかったので、キャッシュはしていません。
実務での使い方
今回の検証では、IaC のデプロイと、SDK を使うアプリの結合テストを、PR ごとに回せました。CDK でデプロイした API は、デプロイから削除まで約 2 分で通り、権限の付け忘れも、返すステータスの誤りも、PR の時点で検出できました。
一方、公式は、IAM を AWS と同じようには評価しないと案内しています。緑が示すのは「エミュレーターで通った」ことまでなので、権限が正しいことの確認は、別に必要です。
ワークフローには、実 AWS の認証情報(OIDC を含む)を置きません。公式も、テストジョブに実認証情報を置かないよう案内しています。状態はメモリ上だけなので、ジョブごとに作り直す前提で組みます。latest は1日に何度も更新されるため、結果が変わることがあります。
テストを書くときは、テーブルを作ったら ACTIVE を待ってから書き込むこと、Node.js の SDK では NODE_EXTRA_CA_CERTS を設定することに注意してください。
公式の指針との対応
AWS Well-Architected Framework と DevOps Guidance の、テストとデプロイに関する項目と照らしてみました。これは私の整理で、AWS が AWS Emulator と結びつけて述べているわけではありません。
DevOps Guidance の QA.FT.2(結合テスト)には、次の一文があります。
「Consider using dedicated emulation, containers, or cloud-based test environments to make tests more efficient, consistent, and safe.」
出典: https://docs.aws.amazon.com/wellarchitected/latest/devops-guidance/qa.ft.2-validate-system-interactions-and-data-flows-with-integration-tests.html
2023年9月公開の資料で、AWS Emulator を指したものではありませんが、結合テストの手段としてエミュレーションを挙げている点で、今回の使い方と合っています。ほかにも、次の項目が当てはまると考えました。
- OPS05-BP08(複数の環境を使用する): 開発者に最小限のコントロールのサンドボックス環境を提供する。AWS アカウントも費用も不要なので合います
- OPS05-BP02(変更をテストし、検証する): テストは各セットを10分以内に完了させる。今回はジョブ全体で約 2 分でした
- REL08-BP02(デプロイの一部として機能テストを統合する): すべての環境で同じデプロイの仕組みを使う。CDK は環境変数だけでエミュレーターに向けられました
- OPS06-BP02(デプロイをテストする): 一時的な並列環境を作る。PR ごとの使い捨て環境に、あたります
これらの項目は、本番に近い環境でのテストも求めています。エミュレーターは IAM を AWS と同じようには評価しないので、その部分は実 AWS で確認する必要があります。
さいごに
公式のドキュメントにワークフロー例がなくても、公式の deb を入れて起動し、環境変数で向け先を切り替えれば、PR ごとの結合テストを回せました。AWS の認証情報は要らず、CDK でデプロイした API は約 2 分で通り、壊した PR は赤になりCIの失敗が確認できました。
ただ、緑が示すのは「エミュレーターで通った」ことまでで、実 AWS での確認は引き続き必要です。AWS Emulator は1日に何度も更新されるので、本記事の結果は、1.0.353(2026-11-14)のものです。Werner Vogels 氏のブログに載った Swami Sivasubramanian 氏の投稿は、詳細を re:Invent で説明すると書いていて、CI に関する公式の案内が出れば、内容が変わるかもしれません。自分の構成で試して、公式の記録にない差異を見つけたら、GitHub の Issues に報告すると、改善につながると思います。
英語記事
[AWS] Running CI (Integration Testing) for each PR using AWS Emulator [GitHub Actions]
参考文献
- What is AWS Emulator?
https://docs.aws.amazon.com/emulator/latest/userguide/what-is-service.html - Getting started with AWS Emulator on Linux
https://docs.aws.amazon.com/emulator/latest/userguide/getting-started-linux.md - AWS Emulator CLI reference
https://docs.aws.amazon.com/emulator/latest/userguide/cli-reference.html - Pointing your AWS SDK at AWS Emulator
https://docs.aws.amazon.com/emulator/latest/userguide/point-sdk-to-emulator.html - aws/aws-emulator(GitHub)
https://github.com/aws/aws-emulator - aws/aws-emulator のリリース
https://github.com/aws/aws-emulator/releases - Issue #14: 公式の既製コンテナイメージの要望
https://github.com/aws/aws-emulator/issues/14 - Test your applications against AWS locally with AWS Emulator(AWS News Blog、2026-11-11)
https://aws.amazon.com/blogs/aws/test-your-applications-against-aws-locally-with-aws-emulator/ - How emulators might catch what you might miss(DEV.to、AWS organization、2026-11-11)
https://dev.to/aws/how-emulators-might-catch-what-you-might-miss-34hd - AWS Emulator and the engineer's job when agents write the code(All Things Distributed、2026-11-11)
https://www.allthingsdistributed.com/2026/11/aws-emulator-and-the-engineers-job-when-agents-write-the-code.html - DevOps Guidance: QA.FT.2 Validate system interactions and data flows with integration tests
https://docs.aws.amazon.com/wellarchitected/latest/devops-guidance/qa.ft.2-validate-system-interactions-and-data-flows-with-integration-tests.html - OPS05-BP08 複数の環境を使用する
https://docs.aws.amazon.com/ja_jp/wellarchitected/latest/framework/ops_dev_integ_multi_env.html - OPS05-BP02 変更をテストし、検証する
https://docs.aws.amazon.com/ja_jp/wellarchitected/latest/framework/ops_dev_integ_test_val_chg.html - REL08-BP02 デプロイの一部として機能テストを統合する
https://docs.aws.amazon.com/ja_jp/wellarchitected/latest/framework/rel_tracking_change_management_functional_testing.html - OPS06-BP02 デプロイをテストする
https://docs.aws.amazon.com/ja_jp/wellarchitected/latest/framework/ops_mit_deploy_risks_test_val_chg.html - 【AWS】AWS EmulatorでCDK/Terraformのスタックはどこまで動くか【AWSEmulator】(前回の記事)
https://qiita.com/Nana_777/items/3f3c71525d867b22c9f2






