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=<emul...![[AWS] Running CI (Integration Testing) for each PR using AWS Emulator [GitHub Actions]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fqiita-user-contents.imgix.net%2Fhttps%253A%252F%252Fqiita-user-contents.imgix.net%252Fhttps%25253A%25252F%25252Fcdn.qiita.com%25252Fassets%25252Fpublic%25252Farticle-ogp-background-afbab5eb44e0b055cce1258705637a91.png%253Fixlib%253Drb-4.1.1%2526w%253D1200%2526blend64%253DaHR0cHM6Ly9xaWl0YS11c2VyLXByb2ZpbGUtaW1hZ2VzLmltZ2l4Lm5ldC9odHRwcyUzQSUyRiUyRnMzLWFwLW5vcnRoZWFzdC0xLmFtYXpvbmF3cy5jb20lMkZxaWl0YS1pbWFnZS1zdG9yZSUyRjAlMkYxOTI5NDklMkYxYmVjZGE4YTRmMWRiOWU5OTQyZDljOGMwYjZkZDA3NjkwMTM2NjgwJTJGbGFyZ2UucG5nJTNGMTc0MTQ4MzQxMD9peGxpYj1yYi00LjEuMSZhcj0xJTNBMSZmaXQ9Y3JvcCZtYXNrPWVsbGlwc2UmYmc9RkZGRkZGJmZtPXBuZzMyJnM9MDZlM2QxY2EzNTVhN2Y4N2M2ZTQxY2RlYzdmN2M0ODI%2526blend-x%253D120%2526blend-y%253D462%2526blend-w%253D90%2526blend-h%253D90%2526blend-mode%253Dnormal%2526mark64%253DaHR0cHM6Ly9xaWl0YS1vcmdhbml6YXRpb24taW1hZ2VzLmltZ2l4Lm5ldC9odHRwcyUzQSUyRiUyRnMzLWFwLW5vcnRoZWFzdC0xLmFtYXpvbmF3cy5jb20lMkZxaWl0YS1vcmdhbml6YXRpb24taW1hZ2UlMkYxZTIwZGI1ZTdlNDA3ZWFlN2I5NzBlYzk5OTg4MGRjOGMzY2MwZjY0JTJGb3JpZ2luYWwuanBnJTNGMTY2NzI3NDk2Mz9peGxpYj1yYi00LjEuMSZ3PTQ0Jmg9NDQmZml0PWNyb3AmbWFzaz1jb3JuZXJzJmNvcm5lci1yYWRpdXM9OCZiZz1GRkZGRkYmYm9yZGVyPTIlMkNGRkZGRkYmZm09cG5nMzImcz04OTI0NDcwMDk1MDMyZjUxYmZlNzE3ZjVkOGUzYmE3ZA%2526mark-x%253D186%2526mark-y%253D515%2526mark-w%253D40%2526mark-h%253D40%2526s%253D0db13b4aef231a19bc684897cf15021a%3Fixlib%3Drb-4.1.1%26w%3D1200%26fm%3Djpg%26mark64%3DaHR0cHM6Ly9xaWl0YS11c2VyLWNvbnRlbnRzLmltZ2l4Lm5ldC9-dGV4dD9peGxpYj1yYi00LjEuMSZ3PTk2MCZoPTMyNCZ0eHQ9JUUzJTgwJTkwQVdTJUUzJTgwJTkxQVdTJTIwRW11bGF0b3IlRTMlODElQTdQUiVFMyU4MSU5NCVFMyU4MSVBOCVFMyU4MSVBRUNJJUVGJUJDJTg4JUU3JUI1JTkwJUU1JTkwJTg4JUUzJTgzJTg2JUUzJTgyJUI5JUUzJTgzJTg4JUVGJUJDJTg5JUUzJTgyJTkyJUU1JTlCJTlFJUUzJTgxJTk5JUUzJTgwJTkwR2l0SHViQWN0aW9ucyVFMyU4MCU5MSZ0eHQtYWxpZ249bGVmdCUyQ3RvcCZ0eHQtY29sb3I9JTIzMUUyMTIxJnR4dC1mb250PUhpcmFnaW5vJTIwU2FucyUyMFc2JnR4dC1zaXplPTU2JnR4dC1wYWQ9MCZzPTZjNzM1OTE0ZTFiMzFhYjkwMDJlMDhlMTViY2JjMmEw%26mark-x%3D120%26mark-y%3D112%26blend64%3DaHR0cHM6Ly9xaWl0YS11c2VyLWNvbnRlbnRzLmltZ2l4Lm5ldC9-dGV4dD9peGxpYj1yYi00LjEuMSZ3PTgzOCZoPTU4JnR4dD0lNDBOYW5hXzc3NyZ0eHQtY29sb3I9JTIzMUUyMTIxJnR4dC1mb250PUhpcmFnaW5vJTIwU2FucyUyMFc2JnR4dC1zaXplPTM2JnR4dC1wYWQ9MCZzPTg1YzQ4MzUyMmVkMGVhNmYxYjhkZjU5MjVjYzhiNjQz%26blend-x%3D242%26blend-y%3D454%26blend-w%3D838%26blend-h%3D46%26blend-fit%3Dcrop%26blend-crop%3Dleft%252Cbottom%26blend-mode%3Dnormal%26txt64%3DS0RESeOCouOCuOODo-OCpOODq-mWi-eZuuOCu-ODs-OCv-ODvOagquW8j-S8muekvg%26txt-x%3D242%26txt-y%3D539%26txt-width%3D838%26txt-clip%3Dend%252Cellipsis%26txt-color%3D%25231E2121%26txt-font%3DHiragino%2520Sans%2520W6%26txt-size%3D28%26s%3D44bec030dfc5694f973ca7b04acf2d0e)



