This article is a machine translation of the contents of the following URL, which I wrote in Japanese:
Introduction
On November 11, 2026, AWS officially released AWS Emulator. It is a tool that lets you point AWS SDK and AWS CLI calls at an emulator running locally or in CI, without an AWS account. The official documentation lists CI/CD as one of its use cases.
"Continuous integration and delivery (CI/CD) – Run integration tests in your pipeline with no AWS account and no AWS resources."
Source: https://docs.aws.amazon.com/emulator/latest/userguide/what-is-service.html
However, at the time of writing I could not find a GitHub Actions workflow example in the official documentation or the repository. There is also no prebuilt container image (a request for one is open as Issue #14 in the official repository). So I built it into GitHub Actions myself and tried to run it as an integration test for every PR.
In the previous article, I checked how far CDK and Terraform configurations run on a local Docker setup. This article is the sequel, and it uses CDK.
Here is what I tried.
- Install the official deb package on a GitHub Actions runner and start it
- Run tests that call S3, DynamoDB, and SQS through the SDK, and tests for an API deployed with CDK (API Gateway + Lambda + DynamoDB), without any AWS credentials
- Open PRs that I broke on purpose and see whether CI turns red
- Check whether caching the deb helps
This article assumes basic knowledge of GitHub Actions and of the AWS SDK / CLI.
Overview
When you create a PR, a GitHub Actions runner starts. On the runner, the workflow installs and starts the emulator (Steps 1 and 2), then points the tests at the emulator with the environment variable AWS_ENDPOINT_URL (Step 3). The tests have two layers. Layer 1 calls APIs directly through the SDK to check the emulator's behavior (Step 4). Layer 2 tests an application deployed with CDK, and it is the template for a per-PR integration test (Step 5). The result comes back to the PR checks as green or red, and nothing is sent to AWS.
The application in Layer 2 is the sample I used in the previous article. Please read it as a template for replacing it with your own application.
How This Differs from Traditional CI
For integration tests of applications running on AWS, I think most teams either deploy to real AWS and check there, or settle for mocks. AWS Emulator sits between the two.
| CI that deploys to real AWS | Mock-based CI | CI with AWS Emulator | |
|---|---|---|---|
| Credentials | Pass a real role, for example with OIDC | Not needed | Not needed |
| Wait time | Waiting for deployment | Almost none | About 2 minutes for the whole job |
| Cost and leftovers | Resource costs, and resources you forget to delete | None | No additional charge. Everything disappears when the job ends |
| What you can check | Real AWS behavior and permissions | Only what you wrote | API behavior and errors. IAM is not evaluated the same way as in AWS |
Test Environment
- GitHub Actions
ubuntu-latest(actuallyubuntu-26.04, x86_64) - AWS Emulator 1.0.353 (fetched as
latest;Release date: 2026.11.14) - AWS CDK CLI 2.1148.1, aws-cdk-lib 2.275.0 (the same combination as in the previous verification), Python 3.12, Node.js 22
- Verification date: 2026-11-14
The emulator is updated several times a day. The results in this article are for this version.
CI Steps 1-2: Install and Start the Emulator
A deb is a package format, a file for installing software on Linux distributions such as Ubuntu and Debian. It is similar to .exe and .msi on Windows. AWS distributes it officially, and after apt-get install the aws-emulator command is available. amd64 in the file name means it is for common 64-bit CPUs.
The workflow installs this deb, starts the emulator in the background, and waits until the health check passes.
# Download the official deb and install it.
# EMULATOR_DEB_URL is the URL of the deb under the official LTS/latest
- name: Install AWS Emulator
run: |
curl -fsSLo aws-emulator.deb "$EMULATOR_DEB_URL"
sudo apt-get install -y ./aws-emulator.deb
aws-emulator -v # Record the installed version in the log
# Start in the background and wait until the health check passes
- name: Start AWS Emulator
run: |
# -p: port number
# --telemetry off: do not send usage data
# --idle-timeout 1800: stop automatically if there is no API call for 1800 seconds
# (so the emulator does not keep running if the job stops midway)
# nohup and the trailing &: keep the process alive after this step ends
nohup aws-emulator -p 9297 --telemetry off --idle-timeout 1800 \
> "$RUNNER_TEMP/emulator.log" 2>&1 &
# Check once per second, up to 60 times, until /__admin/health succeeds.
# --cacert: trust the self-signed CA that the emulator creates
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
# If it has not started after 60 seconds, print the emulator log and fail
cat "$RUNNER_TEMP/emulator.log"
exit 1
The install took 16 to 37 seconds (it varies from run to run), and most of that was unpacking by apt (10 to 15 seconds). Startup took about 1 second, and the process started with nohup was still usable from later steps. The output of aws-emulator -v is as follows.
Version: 1.0.353
Release date: 2026.11.14
Commit: ca8a89dedfa8e4b5e67a3c90dc736f921ba409a1
Right after startup, the log may show curl: option --cacert: is badly used here. It is a message from the period when ca.pem has not been created yet, and the check succeeds in about 1 second, so it is not a problem.
CI Step 3: Switch the Target with Environment Variables
The workflow writes the environment variables to GITHUB_ENV and passes them on to later steps.
- name: Point SDK and CLI at the emulator
run: |
# Where the emulator puts the credentials, config, and CA it creates at every startup
d="$HOME/.aws/emulator/9297"
{
# The destination. When this is set, the SDK and CLI connect to the emulator instead of real AWS
echo "AWS_ENDPOINT_URL=https://127.0.0.1:9297"
# The credentials and config files that the emulator creates at startup
echo "AWS_SHARED_CREDENTIALS_FILE=$d/emulator_credentials"
echo "AWS_CONFIG_FILE=$d/config"
# Trust the self-signed CA. The AWS CLI and boto3 use this AWS_CA_BUNDLE
echo "AWS_CA_BUNDLE=$d/ca.pem"
# The Node.js SDK does not use AWS_CA_BUNDLE, so set this too (see the Node.js section below)
echo "NODE_EXTRA_CA_CERTS=$d/ca.pem"
# Do not go looking for credentials in the EC2 instance metadata
echo "AWS_EC2_METADATA_DISABLED=true"
# Keep APIs whose host name gets a prefix (Cloud Map, Step Functions, and so on)
# from being sent to a different host (official guidance)
echo "AWS_DISABLE_HOST_PREFIX_INJECTION=true"
# Region. The emulator is used with a single Region
echo "AWS_REGION=us-east-1"
echo "AWS_DEFAULT_REGION=us-east-1"
} >> "$GITHUB_ENV" # Pass them to later steps as environment variables
Place this step after the emulator has started and the startup wait has finished, because the credentials file and the CA are recreated at every startup. The job holds no real AWS credentials and does not use aws-actions/configure-aws-credentials.
$ aws sts get-caller-identity
{
"UserId": "AIDAH5I7GEMO7ADXNFAZU",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:user/emulator"
}
Even though there were no AWS credentials, the call was answered as the emulator's account (000000000000). Creating and listing an S3 bucket also succeeded afterwards.
CI Step 4: Run Tests with the SDK (Layer 1)
Layer 1 is not a test of an application. It is the stage where I check the emulator's behavior directly through the SDK, and see whether CI can catch behavior that mocks make hard to see. I wrote five tests with boto3 and pytest (I added the comments for this article, and the code behaves the same as the code I verified).
First, the common code.
Common code (the top of tests/test_integration.py)
import time
import uuid
import boto3
import pytest
from botocore.exceptions import ClientError
def _suffix():
# A random 8-character string so that bucket and table names do not collide.
# A single run creates buckets and tables many times, so the names are varied
return uuid.uuid4().hex[:8]
# One boto3 client is shared by the whole test run (scope="session").
# The endpoint and credentials are not written in the code. boto3 reads them automatically from
# the environment variables set in "CI Step 3: Switch the Target with Environment Variables"
# (AWS_ENDPOINT_URL, AWS_SHARED_CREDENTIALS_FILE, and so on).
# So the same code would also point at real AWS if you removed the environment variables
@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: Round Trip and a Missing Key
For S3, I check whether creating, writing, and reading work while keeping state, and whether reading a key that does not exist returns the same NoSuchKey as AWS.
S3 tests (test_s3_roundtrip, test_s3_missing_key_is_nosuchkey)
def test_s3_roundtrip(s3):
bucket = f"ci-{_suffix()}"
# Create a bucket
s3.create_bucket(Bucket=bucket)
# Write an object
s3.put_object(Bucket=bucket, Key="a.txt", Body=b"hello")
# Read the same key. If the content comes back, "create, write, read" works while keeping state
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)
# Reading a key that does not exist raises an exception (ClientError)
with pytest.raises(ClientError) as e:
s3.get_object(Bucket=bucket, Key="nope")
# The kind of error is in Error.Code of the response. Check that it is NoSuchKey, as in AWS
assert e.value.response["Error"]["Code"] == "NoSuchKey"
DynamoDB: Conditional Write
For DynamoDB, I try a conditional write. If you write twice with the condition "write only if an item with the same ID does not exist yet", the first write succeeds and the second raises ConditionalCheckFailedException. This is the basic behavior of logic that prevents duplicate registration, and with mocks it takes effort to reproduce even the evaluation of the condition expression.
DynamoDB test (test_dynamodb_conditional_write)
def test_dynamodb_conditional_write(ddb):
table = f"ci-{_suffix()}"
# Create an on-demand table whose only key is an ID (string)
ddb.create_table(
TableName=table,
BillingMode="PAY_PER_REQUEST",
AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}],
KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}],
)
# Print the table status right after creation, so that it is recorded in the log.
# (In the first run, a write right after creation failed with ResourceNotFoundException,
# so I made the status visible)
status = ddb.describe_table(TableName=table)["Table"]["TableStatus"]
print(f"table status right after create_table: {status}")
# Wait until the table becomes ACTIVE. Check once per second, up to 30 times.
# Do not write right after creating the table; wait first
ddb.get_waiter("table_exists").wait(
TableName=table, WaiterConfig={"Delay": 1, "MaxAttempts": 30}
)
item = {"id": {"S": "1"}}
# Conditional write: write only if an item with the same ID does not exist yet. The first write succeeds
ddb.put_item(
TableName=table, Item=item,
ConditionExpression="attribute_not_exists(id)",
)
# Write the same ID again. The item already exists, so the condition fails and an exception is raised
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: Message Redelivery
For SQS, I try message redelivery. The visibility timeout is a mechanism that hides a received message from other receives for a certain time. If you do not delete the message during that time, it becomes visible again and is redelivered. A process that forgets to delete messages handles the same message over and over. This is a point that an article from the AWS organization on DEV.to gives as an example of something that "passes with mocks but fails with an emulator".
SQS test (test_sqs_redelivery_after_visibility_timeout)
def test_sqs_redelivery_after_visibility_timeout(sqs):
"""If the message is not deleted, it is redelivered after the visibility timeout.
The same point as the DEV.to article from the AWS organization (an example of a defect mocks miss)."""
# Create a queue with a 2-second visibility timeout (to keep the wait short)
q = sqs.create_queue(
QueueName=f"ci-{_suffix()}",
Attributes={"VisibilityTimeout": "2"},
)["QueueUrl"]
sqs.send_message(QueueUrl=q, MessageBody="order-1")
# First receive. From the moment it is received, the message is hidden from other receives for 2 seconds.
# ReceiptHandle identifies this receive (the value changes when the message is redelivered)
first = sqs.receive_message(QueueUrl=q, WaitTimeSeconds=1)["Messages"][0]
handle1 = first["ReceiptHandle"]
# Wait 3 seconds without deleting (longer than the 2-second visibility timeout)
time.sleep(3)
# Second receive. The message should be visible again.
# In case it takes a while to become visible, repeat the receive up to 5 times
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, "The message was not redelivered"
# The same message was redelivered
assert second["Body"] == "order-1"
# It is a different receive, so ReceiptHandle differs from the first one
assert second["ReceiptHandle"] != handle1
Unimplemented API: Record the Status
The last one is about unimplemented APIs. The official documentation says that unimplemented APIs return 501. However, the result changes when an API gets implemented in a release, so I call list_work_groups of Athena, pass the test if the status is either 200 or 501, and only record the status.
Unimplemented API test (test_unimplemented_api_status_is_recorded)
def test_unimplemented_api_status_is_recorded():
"""An unimplemented API is expected to return 501. It may get implemented in a release,
so only print and record the status (it is shown with -s)."""
athena = boto3.client("athena")
try:
# If it is implemented, the call simply succeeds (HTTP 200)
athena.list_work_groups()
status = 200
except ClientError as e:
# If it is not implemented, an error is raised. Take the HTTP status from the response
status = e.response["ResponseMetadata"]["HTTPStatusCode"]
# If you run pytest with -s, this output is shown in the log
print(f"athena list_work_groups -> HTTP {status}")
assert status in (200, 501)
Here is the result. All five passed.
============================= 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 ===============================
With SQS, the message was redelivered after waiting 3 seconds, and ReceiptHandle changed. I had expected Athena to return 501 (not implemented), but it returned HTTP 200. It seems to be implemented in this version.
The DynamoDB test failed once, in the first run. A put_item right after creating the table raised ResourceNotFoundException. I do not know the cause. The emulator log only had a record of CreateTable, with no record of the failure. Since I added a wait until the table becomes ACTIVE (the table_exists waiter), all 10 runs have succeeded. However, the status right after creation showed ACTIVE, so the wait may only be hiding the problem.
CI Step 4 (Supplement): Node.js Needs NODE_EXTRA_CA_CERTS (Layer 1)
The emulator's certificate is self-signed. The AWS CLI and boto3 look at AWS_CA_BUNDLE, but the Node.js SDK did not. Let me try the same S3 call with Node.js.
import { S3Client, ListBucketsCommand } from "@aws-sdk/client-s3";
// The SDK reads the endpoint from AWS_ENDPOINT_URL and the Region from AWS_REGION.
// forcePathStyle: use path-style requests, which do not put the bucket name in the host name
const client = new S3Client({ forcePathStyle: true });
// Get the list of buckets. If the connection to the emulator works, the number of buckets created so far is returned
const r = await client.send(new ListBucketsCommand({}));
console.log("buckets:", (r.Buckets ?? []).length);
With NODE_EXTRA_CA_CERTS set, it succeeded (buckets: 3). If only this environment variable is removed, it fails with the following error.
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 was still set at that time. It failed anyway, so the Node.js SDK does not use AWS_CA_BUNDLE. The emulator's CA is not among the CAs that Node.js trusts, so I add it with NODE_EXTRA_CA_CERTS. For the Java SDK, the official documentation describes a setting for truststore.p12 (I did not try it).
CI Step 5: Deploy with CDK and Test the API (Layer 2)
This is the part closest to a per-PR integration test. In CI, I deploy with CDK the small API from the previous article, which registers and retrieves items (POST /i and GET /i/{k} of API Gateway, Lambda, and DynamoDB), and call it. CDK is pinned to the combination that worked last time (CLI 2.1148.1, aws-cdk-lib 2.275.0) and installed with npm ci.
The Lambda function that the emulator runs does not automatically receive an endpoint or credentials. To call DynamoDB from Lambda, I set environment variables on the Lambda function as well. The paths of the credentials and the CA are under the runner's home directory, so I pass them to CDK at deploy time with -c emuDir=....
# Run the CDK bootstrap (which creates things like the S3 bucket CDK uses) and deploy against the emulator
- name: CDK bootstrap and deploy
working-directory: cdk
env:
CI: "true" # Run as CI
run: |
# Specify the emulator's account (000000000000) as the account ID
npx cdk bootstrap aws://000000000000/us-east-1 --no-notices
# $CDK_CTX is the argument passed to CDK in the form -c emuDir=<emulator directory>
# (collected in an environment variable). The code below receives it
# --require-approval never: go ahead without asking for confirmation of IAM changes
npx cdk deploy --require-approval never --no-notices $CDK_CTX
// Receive the value passed at deploy time with -c emuDir=<emulator directory>
const emuDir: string | undefined = this.node.tryGetContext('emuDir');
// Environment variables for Lambda. The table name is always passed
const environment: Record<string, string> = { TABLE_NAME: items.tableName };
if (emuDir) {
// Only when running on the emulator, pass the endpoint and credentials to Lambda.
// The emulator does not pass these to the Lambda execution environment automatically
environment.AWS_ENDPOINT_URL = 'https://127.0.0.1:9297';
environment.AWS_SHARED_CREDENTIALS_FILE = `${emuDir}/emulator_credentials`;
environment.AWS_CONFIG_FILE = `${emuDir}/config`;
// Node.js inside Lambda also has to trust the self-signed CA (see the Node.js section above)
environment.NODE_EXTRA_CA_CERTS = `${emuDir}/ca.pem`;
}
Last time, deployment failed because the statementId of AWS::Lambda::Permission exceeded 100 characters, and because a stage name was duplicated in the same stack. This time I avoided both by shortening the logical IDs and using a single API. I have not separated out whether they are fixed in the current version.
The whole application to deploy is below. It is long, so it is collapsed. The places I break in the "Breaking It with a PR" section are grantReadWriteData in the stack and json(201, ...) in Lambda.
CDK stack (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);
// In CI, pass -c emuDir=.
// It is needed so that the Lambda function run by the emulator can call the emulator's DynamoDB
const emuDir: string | undefined = this.node.tryGetContext('emuDir');
// The table that stores items. Its only key is an ID (string).
// DESTROY: delete the table too on cdk destroy (because it is a throwaway CI environment)
const items = new ddb.Table(this, 'Items', {
partitionKey: { name: 'id', type: ddb.AttributeType.STRING },
billingMode: ddb.BillingMode.PAY_PER_REQUEST,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
// Environment variables for Lambda (the part explained in the first half of this section)
const environment: Record = { 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`;
}
// The Lambda function that handles the API. The code is taken from the lambda/ directory
const appFn = new lambda.Function(this, 'App', {
runtime: lambda.Runtime.NODEJS_22_X,
handler: 'items.handler',
code: lambda.Code.fromAsset('lambda'),
environment,
});
// Grant Lambda read and write permissions on the table.
// In PR 1 of "Breaking It with a PR", I deleted this one line to reproduce a forgotten permission
items.grantReadWriteData(appFn);
// API Gateway. The stage name is v1.
// POST /i saves an item and GET /i/{k} retrieves one. The same Lambda function handles both
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));
// Output the API ID and the table name so that the tests can get them after deployment
new cdk.CfnOutput(this, 'ApiId', { value: api.restApiId });
new cdk.CfnOutput(this, 'TableName', { value: items.tableName });
}
}
Lambda code (lambda/items.js)
The code I used in the verification also returned information about the execution environment, for investigating failures. That part is omitted here.
const { DynamoDBClient, PutItemCommand, GetItemCommand } = require('@aws-sdk/client-dynamodb');
// The SDK reads the endpoint and credentials from the Lambda environment variables
const ddb = new DynamoDBClient({});
// A helper that builds the response returned to API Gateway
const json = (statusCode, obj) => ({
statusCode,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(obj),
});
exports.handler = async (event) => {
try {
// POST /i: take id and text from the request body and save them to 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 } },
}));
// Return 201 (Created) when saved. In PR 2 of "Breaking It with a PR", I changed this to 200
return json(201, { saved: id });
}
// GET /i/{k}: use k in the path as the ID and retrieve the item from DynamoDB
const id = event.pathParameters && event.pathParameters.k;
const r = await ddb.send(new GetItemCommand({
TableName: process.env.TABLE_NAME,
Key: { id: { S: id } },
}));
// Return 200 if the item exists, 404 if not
return r.Item ? json(200, { id, text: r.Item.text.S }) : json(404, { message: 'not found' });
} catch (e) {
// If the DynamoDB call fails (for example, missing permissions), return 500 and the kind of error.
// In PR 1 of "Breaking It with a PR", AccessDeniedException was returned here
return json(500, { error: e.name, message: e.message });
}
};
The tests call the deployed API over HTTP. I wrote three tests.
| Test | What it checks |
|---|---|
test_post_then_get |
Save with POST, then GET returns the same content (Lambda, API Gateway, and DynamoDB are connected end to end) |
test_get_missing_item_is_404 |
GET of an ID that does not exist returns 404 |
test_item_is_stored_in_dynamodb |
The content saved with POST is also stored when you read DynamoDB directly, without going through the API |
The API is called through the emulator's endpoint, with the Host header specifying the target. The code is below.
API tests (tests/test_api.py)
import os
import uuid
import boto3
import pytest
import requests
# The name of the stack under test
STACK = os.environ.get("STACK_NAME", "EmulatorCiApp")
@pytest.fixture(scope="module")
def outputs():
# Get the outputs of the deployed stack (ApiId and TableName) from CloudFormation.
# This queries the emulator's 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):
# The emulator's endpoint (https://127.0.0.1:9297)
base = os.environ["AWS_ENDPOINT_URL"]
# The emulator receives API Gateway requests at a single endpoint.
# Which API is meant is specified with the Host header
host = f"{outputs['ApiId']}.execute-api.us-east-1.amazonaws.com"
# The self-signed CA (the same file as AWS_CA_BUNDLE)
ca = os.environ["AWS_CA_BUNDLE"]
def _call(method, path, **kwargs):
return requests.request(
method,
# Append the stage name (v1) and the path to the endpoint
f"{base}/v1{path}",
headers={"Host": host},
verify=ca,
timeout=30,
**kwargs,
)
return _call
def test_post_then_get(call):
# Use a random ID so that it does not collide with other tests
item_id = f"item-{uuid.uuid4().hex[:8]}"
# Save with POST. 201 and the saved ID are returned
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}
# Retrieve with GET. The saved content comes back as it is
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):
# Getting an ID that was never saved returns 404.
# This test does not use POST, so changing the status code that POST returns does not affect it
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
# Read DynamoDB directly, without going through the API.
# Check that the item was actually stored in the table, not only that the API responded
ddb = boto3.client("dynamodb")
got = ddb.get_item(
TableName=outputs["TableName"], Key={"id": {"S": item_id}}
)
assert got["Item"]["text"]["S"] == "stored"
| Operation | Result |
|---|---|
cdk bootstrap and cdk deploy
|
Succeeded on the first try. 29 to 40 seconds for bootstrap and deploy together (Total time of deploy was 13 to 16 seconds) |
| POST, GET, GET of an ID that does not exist | 201, 200, 404. The save to DynamoDB was also confirmed with get_item
|
| Redeploy (no changes) |
no changes (4 to 7 seconds) |
cdk diff --fail |
There were no differences |
cdk destroy |
destroyed (10 to 14 seconds) |
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 ===============================
With Terraform last time, running plan right after apply showed a diff in DynamoDB, but with CDK there was no diff on either the redeploy or cdk diff. cdk diff creates a CloudFormation change set to get the diff, so creating change sets seems to be supported.
The redeploy, cdk diff, and destroy steps have continue-on-error: true, so the job stays green even if they fail. The conclusion of a failed step is also shown as success, so I write the outcome of each step to the job Summary and check it there.
Breaking It with a PR (Layer 2)
The value of CI is that it turns red and lets you notice when a PR has a defect. So I opened a PR that should pass and PRs that I broke on purpose. The broken PRs were closed without being merged.
The PR that only changed the README was green (about 2 minutes 14 seconds). The job's environment variables did not include AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, or AWS_PROFILE, so it ran without any AWS credentials.
This is the list of runs started by PRs. V10a is the README-only PR, V10b is the PR that removes the grant (PR 1 in the table below), and V10c is the PR that changes the returned status (PR 2). V10b and V10c turned red, and they turned green again after a revert.
I broke it with two PRs of different kinds.
| PR 1: Remove the grant (an IaC change) | PR 2: Change the returned status (an application code change) | |
|---|---|---|
| Change | Delete the grant to DynamoDB (grantReadWriteData) |
Change the status code that POST returns in Lambda from 201 to 200 |
| Result | Red. All 3 API tests failed | Red. 2 of 3 failed (the test that checks 404 passed) |
| Error |
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. For GET, dynamodb:GetItem. The API returned 500 |
assert 200 == 201 |
| Run time until failure | 84 seconds | 81 seconds |
| After revert | Back to green (123 seconds) | Back to green (128 seconds) |
This is the screen of the run that turned red for PR 1.
For PR 1, the emulator detected the missing permission of the Lambda role, with a message in the AWS format. However, the official documentation says that it does not evaluate IAM policies the same way AWS does. What I could confirm is only that "CI noticed a forgotten permission".
Whether an error in application code is detected depends on whether a test checks that part. In PR 2, the 404 test passed because it is not affected by the change to POST.
Does Caching the deb Help?
The deb is about 174 MiB, so I wondered whether fetching it in every job would be slow, and tried caching it with actions/cache.
Without the cache, the download took about 1 second and the install 20 seconds. With the cache, the restore took about 3 seconds and the install 16 seconds. The download was faster than the restore, most of the time is spent unpacking with apt, and the install time varies between 16 and 37 seconds, so the difference between 20 and 16 seconds is within the margin of error. I saw no benefit, so I do not use the cache.
Practical Usage
In this verification, I could run integration tests of IaC deployment and of an application that uses the SDK for every PR. The API deployed with CDK passed in about 2 minutes from deployment to deletion, and both a forgotten permission and a wrong returned status were detected at the PR stage.
On the other hand, the official documentation says that IAM is not evaluated the same way as in AWS. Green only shows that "it passed on the emulator", so confirming that the permissions are correct needs to be done separately.
The workflow holds no real AWS credentials (including OIDC). The official documentation also advises against putting real credentials in test jobs. The state is only in memory, so design the job on the assumption that it is recreated for every job. latest is updated several times a day, so the results can change.
When you write tests, note that you should wait for ACTIVE after creating a table before writing to it, and that the Node.js SDK needs NODE_EXTRA_CA_CERTS.
Alignment with Official Guidance
I compared this with the items on testing and deployment in the AWS Well-Architected Framework and the DevOps Guidance. This is my own organization of them, and AWS does not link these items to AWS Emulator.
QA.FT.2 (integration tests) of the DevOps Guidance contains the following sentence.
"Consider using dedicated emulation, containers, or cloud-based test environments to make tests more efficient, consistent, and safe."
Source: https://docs.aws.amazon.com/wellarchitected/latest/devops-guidance/qa.ft.2-validate-system-interactions-and-data-flows-with-integration-tests.html
It is a document published in September 2023 and does not refer to AWS Emulator, but it fits this usage in that it lists emulation as a means of integration testing. I also thought the following items apply.
- OPS05-BP08 (Use multiple environments): provide developers with sandbox environments with minimized controls. It fits, because neither an AWS account nor cost is needed
- OPS05-BP02 (Test and validate changes): complete each set of tests within 10 minutes. In this verification, the whole job took about 2 minutes
- REL08-BP02 (Integrate functional testing as part of your deployment): use the same deployment mechanism in all environments. CDK could be pointed at the emulator with environment variables alone
- OPS06-BP02 (Test deployments): create temporary parallel environments. It corresponds to a throwaway environment for each PR
These items also ask for testing in an environment close to production. The emulator does not evaluate IAM the same way AWS does, so that part needs to be confirmed on real AWS.
Conclusion
Even though the official documentation has no workflow example, installing and starting the official deb and switching the target with environment variables let me run integration tests for every PR. No AWS credentials were needed, the API deployed with CDK passed in about 2 minutes, and the PRs I broke turned red so that I could notice.
However, green only shows that "it passed on the emulator", and confirming on real AWS is still needed. AWS Emulator is updated several times a day, so the results in this article are for 1.0.353 (2026-11-14). The post by Swami Sivasubramanian on Werner Vogels' blog says that more details will be explained at re:Invent, so the content may change if official guidance on CI is published. If you try it with your own setup and find a difference that is not in the official record, I think reporting it in the GitHub Issues will lead to improvements.
References
- 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
- Releases of aws/aws-emulator https://github.com/aws/aws-emulator/releases
- Issue #14: a request for an official prebuilt container image 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 Use multiple environments https://docs.aws.amazon.com/wellarchitected/latest/framework/ops_dev_integ_multi_env.html
- OPS05-BP02 Test and validate changes https://docs.aws.amazon.com/wellarchitected/latest/framework/ops_dev_integ_test_val_chg.html
- REL08-BP02 Integrate functional testing as part of your deployment https://docs.aws.amazon.com/wellarchitected/latest/framework/rel_tracking_change_management_functional_testing.html
- OPS06-BP02 Test deployments https://docs.aws.amazon.com/wellarchitected/latest/framework/ops_mit_deploy_risks_test_val_chg.html
- [AWS] How Far Can CDK/Terraform Stacks Run on AWS Emulator AWSEmulator https://dev.to/aws-builders/aws-how-far-can-cdkterraform-stacks-run-on-aws-emulator-awsemulator-56dm







Top comments (2)
The deliberately broken PRs make the limits of a green run much easier to see. Since the emulator updates several times a day, would you pin the exact package version or digest for the required PR check and run a separate scheduled job against the latest release? Keeping those results distinct would help tell a regression in the application from a change in the emulator, while still surfacing compatibility drift.
Thank you, that is a good point. Pinning the exact version for the required PR check, and running a separate scheduled job against the latest release, is a sensible way to separate an application regression from an emulator change.
I checked the first half in this verification: a version-specific deb (for example 1.0.353) could be installed from the official download location, and it gave the same results as latest. I did not include that in the article, and I have not tried a scheduled job against latest, so I cannot say how it behaves in practice. I did not try pinning by digest either; the official documentation describes verifying the package with its signature file.
Since the emulator updates several times a day, I agree that keeping the two results separate is worth doing.