Integration Testing Serverless APIs with Real AWS
Integration tests against real AWS catch issues that mocks and local emulators miss: IAM permission errors, DynamoDB capacity limits, API Gateway timeout mismatches, and cold start behavior. Deploy a dedicated test stack, make real HTTP calls, assert on real responses, and clean up test data in teardown. Control costs with auto-delete stacks and per-test data isolation.
There is a class of bugs that no mock can catch. Your IAM role is missing dynamodb:PutItem on the production table. Your Lambda timeout is 3 seconds but the downstream API regularly takes 4. Your API Gateway mapping template drops a header your handler depends on. Your DynamoDB table's provisioned capacity gets throttled under load.
These bugs only appear when your code runs against real AWS services. Integration tests — real deploys, real HTTP calls, real assertions — are how you find them before users do.
This guide covers when to use real AWS integration tests, how to set them up safely, and how to keep costs under control.
When to Use Real AWS vs Local Mocks
Local mocks (moto, LocalStack) are fast and free. Real AWS is slower and costs money. Use each for what it's good at:
| Scenario | Use |
|---|---|
| Business logic correctness | Unit tests + mocks |
| Event parsing and handler structure | SAM CLI local |
| IAM permission validation | Real AWS |
| DynamoDB query correctness | Real AWS |
| API Gateway integration (timeouts, CORS, auth) | Real AWS |
| SQS/SNS message delivery guarantees | Real AWS |
| Cost and throttling behavior | Real AWS |
| CI on every commit | Mocks (fast) |
| Pre-merge / nightly | Real AWS |
The rule of thumb: run mock-based tests on every commit, real AWS tests before merging to main or on a nightly schedule.
Setting Up a Dedicated Test Environment
Never run integration tests against your production stack. Create a dedicated test environment using AWS CloudFormation or SAM:
# Deploy a test stack
sam deploy \
--stack-name myapp-integration-test \
--parameter-overrides "Environment=test TableName=myapp-test-items" \
--capabilities CAPABILITY_IAM \
--no-fail-on-empty-changesetUse a separate AWS account if possible (AWS Organizations makes this easy). At minimum, use a separate CloudFormation stack with clearly named resources (myapp-test-*).
Capture the stack outputs for use in tests:
API_URL=$(aws cloudformation describe-stacks \
--stack-name myapp-integration-test \
--query "Stacks[0].Outputs[?OutputKey=='ApiUrl'].OutputValue" \
--output text)
TABLE_NAME=$(aws cloudformation describe-stacks \
--stack-name myapp-integration-test \
--query "Stacks[0].Outputs[?OutputKey=='TableName'].OutputValue" \
--output text)
export API_URL TABLE_NAMEIAM Setup for Integration Tests
Your test runner needs AWS credentials with enough permission to:
- Make HTTP calls to the deployed API (if it requires IAM auth)
- Read from DynamoDB to assert on stored data
- Clean up test data after each test
Create a dedicated IAM role for integration tests with least-privilege permissions:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "IntegrationTestDynamoDB",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem",
"dynamodb:Query",
"dynamodb:Scan"
],
"Resource": "arn:aws:dynamodb:us-east-1:123456789:table/myapp-test-*"
},
{
"Sid": "IntegrationTestS3",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::myapp-test-*",
"arn:aws:s3:::myapp-test-*/*"
]
}
]
}In CI, store these credentials as GitHub Actions secrets and assume the role using OIDC for keyless authentication:
permissions:
id-token: write
contents: read
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/integration-test-role
aws-region: us-east-1Making Real HTTP Calls to Deployed APIs
Use a standard HTTP client. Python's httpx or requests, Node.js's axios or the native fetch — any of them work. The important thing is to read the API URL from the deployed stack, not hardcode it.
Python Integration Tests with pytest
# tests/integration/conftest.py
import os
import boto3
import pytest
@pytest.fixture(scope="session")
def api_url():
url = os.environ.get("API_URL")
if not url:
# Fall back to CloudFormation output
cfn = boto3.client("cloudformation")
stack = cfn.describe_stacks(StackName="myapp-integration-test")
outputs = stack["Stacks"][0]["Outputs"]
url = next(o["OutputValue"] for o in outputs if o["OutputKey"] == "ApiUrl")
return url.rstrip("/")
@pytest.fixture(scope="session")
def dynamodb_table():
table_name = os.environ.get("TABLE_NAME", "myapp-test-items")
dynamodb = boto3.resource("dynamodb", region_name="us-east-1")
return dynamodb.Table(table_name)# tests/integration/test_items_api.py
import uuid
import httpx
import pytest
@pytest.fixture(autouse=True)
def cleanup_items(dynamodb_table):
created_ids = []
yield created_ids
# Teardown: delete all items created during the test
for item_id in created_ids:
dynamodb_table.delete_item(Key={"id": item_id})
def test_create_item_returns_201(api_url, cleanup_items):
item_id = str(uuid.uuid4())
cleanup_items.append(item_id)
response = httpx.post(
f"{api_url}/items",
json={"id": item_id, "name": "Integration Widget", "price": 29.99},
timeout=10,
)
assert response.status_code == 201
body = response.json()
assert body["id"] == item_id
def test_create_then_get_item(api_url, dynamodb_table, cleanup_items):
item_id = str(uuid.uuid4())
cleanup_items.append(item_id)
# Create
create_res = httpx.post(
f"{api_url}/items",
json={"id": item_id, "name": "Test Item"},
timeout=10,
)
assert create_res.status_code == 201
# Retrieve via API
get_res = httpx.get(f"{api_url}/items/{item_id}", timeout=10)
assert get_res.status_code == 200
assert get_res.json()["name"] == "Test Item"
# Verify directly in DynamoDB
db_item = dynamodb_table.get_item(Key={"id": item_id}).get("Item")
assert db_item is not None
assert db_item["name"] == "Test Item"
def test_missing_required_field_returns_400(api_url):
response = httpx.post(
f"{api_url}/items",
json={"name": "No ID Widget"},
timeout=10,
)
assert response.status_code == 400
assert "id" in response.json().get("error", "").lower()
def test_get_nonexistent_item_returns_404(api_url):
response = httpx.get(f"{api_url}/items/definitely-does-not-exist-{uuid.uuid4()}", timeout=10)
assert response.status_code == 404JavaScript Integration Tests with Jest
// tests/integration/items.integration.test.js
const axios = require("axios");
const { DynamoDBClient, DeleteItemCommand, GetItemCommand } = require("@aws-sdk/client-dynamodb");
const { v4: uuidv4 } = require("uuid");
const API_URL = process.env.API_URL;
const TABLE_NAME = process.env.TABLE_NAME || "myapp-test-items";
const dynamodb = new DynamoDBClient({ region: "us-east-1" });
const createdIds = [];
afterAll(async () => {
await Promise.all(
createdIds.map((id) =>
dynamodb.send(new DeleteItemCommand({
TableName: TABLE_NAME,
Key: { id: { S: id } },
}))
)
);
});
test("POST /items creates item and returns 201", async () => {
const id = uuidv4();
createdIds.push(id);
const res = await axios.post(`${API_URL}/items`, {
id,
name: "Integration Widget",
price: 29.99,
});
expect(res.status).toBe(201);
expect(res.data.id).toBe(id);
});
test("item is persisted in DynamoDB after creation", async () => {
const id = uuidv4();
createdIds.push(id);
await axios.post(`${API_URL}/items`, { id, name: "DynamoDB Verify" });
const { Item } = await dynamodb.send(new GetItemCommand({
TableName: TABLE_NAME,
Key: { id: { S: id } },
}));
expect(Item).not.toBeNull();
expect(Item.name.S).toBe("DynamoDB Verify");
});Cleaning Up Test Data
Test data cleanup is the most error-prone part of integration testing. Three patterns, in order of preference:
1. Per-test cleanup with fixtures (preferred)
The Python cleanup_items fixture above shows this pattern. Each test registers items it creates, and the fixture deletes them in teardown — even if the test fails.
2. Prefix-based batch cleanup
Give all test items a recognizable prefix and delete everything with that prefix in a cleanup job:
TEST_PREFIX = "inttest-"
def cleanup_all_test_items(table):
response = table.scan(
FilterExpression="begins_with(id, :prefix)",
ExpressionAttributeValues={":prefix": TEST_PREFIX},
)
with table.batch_writer() as batch:
for item in response["Items"]:
batch.delete_item(Key={"id": item["id"]})3. Stack deletion
For the cleanest teardown, delete and recreate the CloudFormation stack after each test run:
aws cloudformation delete-stack --stack-name myapp-integration-test
aws cloudformation wait stack-delete-complete --stack-name myapp-integration-testThis is the nuclear option — slow, but leaves zero residue.
Cost Control for Integration Test Suites
Real AWS tests cost real money. Keep costs manageable:
Use on-demand DynamoDB billing. Never use provisioned capacity for test tables. PAY_PER_REQUEST means you pay only for actual requests, with no minimum.
Set Lambda concurrency limits on the test stack. Add ReservedConcurrentExecutions: 10 to prevent runaway parallel test runs from inflating costs.
Auto-delete the stack after CI. Add a final CI step that deletes the CloudFormation stack:
- name: Teardown test stack
if: always()
run: |
aws cloudformation delete-stack --stack-name myapp-integration-test-${{ github.run_id }}Using the run ID in the stack name means parallel CI runs don't conflict.
Set AWS Budgets alerts. Create a budget for the test account with an alert at $50/month. Integration tests should never cost more than a few dollars per month.
Run integration tests on schedule, not on every push. Use a nightly workflow or run only on PRs targeting main:
on:
schedule:
- cron: "0 2 * * *" # 2 AM daily
pull_request:
branches: [main]Validating IAM Permissions
One of the most valuable things integration tests catch is IAM permission errors. Add an explicit IAM validation test:
def test_lambda_has_dynamodb_write_permission(api_url, dynamodb_table, cleanup_items):
"""Verify the Lambda role can write to DynamoDB by checking a round-trip."""
item_id = f"iam-test-{uuid.uuid4()}"
cleanup_items.append(item_id)
# This will fail with 500 if the Lambda lacks dynamodb:PutItem
res = httpx.post(f"{api_url}/items", json={"id": item_id, "name": "IAM Test"}, timeout=10)
assert res.status_code == 201, f"Expected 201 but got {res.status_code}: {res.text}"
# Verify the item actually exists (would fail if Lambda got a permission error and swallowed it)
item = dynamodb_table.get_item(Key={"id": item_id}).get("Item")
assert item is not None, "Item not found in DynamoDB — Lambda may have silently swallowed a permission error"Summary
Integration tests against real AWS are expensive and slow relative to unit tests, but they catch a category of bugs that mocks simply cannot: IAM permission misconfigurations, API Gateway mapping issues, DynamoDB throttling behavior, and real network timeout characteristics. Deploy a dedicated test stack, use least-privilege IAM credentials, clean up test data in teardown fixtures, and control costs with on-demand billing and stack auto-deletion. Run these tests nightly or on PRs to main — not on every commit.