Serverless Integration Testing Strategies
Serverless integration testing is harder than testing a monolith. Functions are isolated by design, services communicate through events and queues, and the infrastructure is someone else's computer. The same properties that make serverless scalable and cheap also make it annoying to test.
This guide covers the strategies that work: when to use real cloud services versus mocks, how to test across function boundaries, and how to handle async event-driven flows without flaky tests.
The Testing Pyramid for Serverless
The standard testing pyramid doesn't translate cleanly to serverless. A more useful model:
- Unit tests: Handler logic in isolation, all AWS SDK calls mocked. Fast, cheap, no infrastructure.
- Component tests: Handler + real AWS services (LocalStack or sandbox account). Tests one function end-to-end.
- Integration tests: Multiple functions interacting through real infrastructure — the hardest category.
- E2E tests: Full user journey through API Gateway → Lambda → downstream services.
The mistake teams make is skipping the component layer and jumping straight to E2E. Component tests catch 80% of real bugs at 20% of the complexity cost.
Real Services vs Mocks: When to Use Which
The choice between emulated and real AWS services is context-dependent. Here's a decision framework:
Use mocks (aws-sdk-client-mock) when:
- Testing pure business logic — input validation, data transformation, error handling
- You want sub-second test feedback in CI
- The AWS SDK interaction is simple (a single
PutItemorGetItem)
Use LocalStack when:
- Testing DynamoDB query patterns (GSIs, filters, pagination)
- Testing S3 bucket policies and presigned URL flows
- Testing SQS visibility timeouts and dead-letter queue routing
- You need infrastructure that behaves like AWS without real costs
Use a real sandbox AWS account when:
- Testing IAM permission boundaries (LocalStack doesn't enforce these)
- Testing VPC-attached Lambdas or PrivateLink endpoints
- Testing EventBridge routing rules and event patterns
- You need exact API behavior for edge cases (DynamoDB transactions under contention)
The answer for most teams: unit tests with mocks + component tests with LocalStack + a weekly full-stack test run against a real sandbox account.
Testing Across Function Boundaries
The core challenge: Function A publishes an event, Function B consumes it. How do you test the contract between them?
Strategy 1: Test the Contract Explicitly
Define the event schema and test both producer and consumer against it:
// shared/events/order-created.schema.js
const Ajv = require('ajv');
const ajv = new Ajv();
const schema = {
type: 'object',
required: ['version', 'orderId', 'customerId', 'items', 'totalAmount'],
properties: {
version: { type: 'string', enum: ['1.0'] },
orderId: { type: 'string', format: 'uuid' },
customerId: { type: 'string' },
items: {
type: 'array',
items: {
type: 'object',
required: ['productId', 'quantity', 'price'],
properties: {
productId: { type: 'string' },
quantity: { type: 'integer', minimum: 1 },
price: { type: 'number', minimum: 0 },
},
},
},
totalAmount: { type: 'number', minimum: 0 },
},
};
const validate = ajv.compile(schema);
module.exports = {
validate,
schema,
};Test the producer publishes valid events:
// tests/unit/create-order.contract.test.js
const { mockClient } = require('aws-sdk-client-mock');
const { EventBridgeClient, PutEventsCommand } = require('@aws-sdk/client-eventbridge');
const { validate } = require('../../shared/events/order-created.schema');
const ebMock = mockClient(EventBridgeClient);
let capturedEvent;
ebMock.on(PutEventsCommand).callsFake((input) => {
capturedEvent = JSON.parse(input.Entries[0].Detail);
return { FailedEntryCount: 0, Entries: [{ EventId: 'evt-001' }] };
});
const { handler } = require('../../src/handlers/create-order');
test('publishes valid OrderCreated event', async () => {
await handler({
body: JSON.stringify({
customerId: 'cust-123',
items: [{ productId: 'prod-A', quantity: 2, price: 29.99 }],
}),
});
expect(capturedEvent).toBeDefined();
const valid = validate(capturedEvent);
if (!valid) {
throw new Error(`Event failed schema validation: ${JSON.stringify(validate.errors)}`);
}
});Test the consumer handles those events:
// tests/unit/process-order.contract.test.js
const { handler } = require('../../src/handlers/process-order');
test('handles valid OrderCreated event', async () => {
const event = {
version: '1.0',
orderId: '550e8400-e29b-41d4-a716-446655440000',
customerId: 'cust-123',
items: [{ productId: 'prod-A', quantity: 2, price: 29.99 }],
totalAmount: 59.98,
};
// Wrapped in EventBridge envelope
const ebEvent = {
source: 'orders.service',
'detail-type': 'OrderCreated',
detail: event,
};
const result = await handler(ebEvent);
expect(result.status).toBe('processed');
});If the producer changes the schema and breaks validate(), the test fails immediately — before you deploy anything.
Strategy 2: Test the Full Chain with LocalStack
For more confidence, spin up both functions and route through a real SQS queue:
// tests/integration/order-chain.test.js
const {
SQSClient,
CreateQueueCommand,
ReceiveMessageCommand,
DeleteMessageCommand,
} = require('@aws-sdk/client-sqs');
const {
DynamoDBClient,
GetItemCommand,
} = require('@aws-sdk/client-dynamodb');
const { unmarshall } = require('@aws-sdk/util-dynamodb');
const LOCALSTACK = 'http://localhost:4566';
const REGION = 'us-east-1';
const CREDS = { accessKeyId: 'test', secretAccessKey: 'test' };
const sqs = new SQSClient({ endpoint: LOCALSTACK, region: REGION, credentials: CREDS });
const ddb = new DynamoDBClient({ endpoint: LOCALSTACK, region: REGION, credentials: CREDS });
// Import handlers with LocalStack endpoints injected
process.env.AWS_ENDPOINT_URL = LOCALSTACK;
process.env.ORDERS_TABLE = 'orders';
process.env.ORDER_QUEUE_URL = 'http://localhost:4566/000000000000/order-queue';
const { handler: createOrder } = require('../../src/handlers/create-order');
const { handler: processOrder } = require('../../src/handlers/process-order');
let queueUrl;
beforeAll(async () => {
const result = await sqs.send(new CreateQueueCommand({ QueueName: 'order-queue' }));
queueUrl = result.QueueUrl;
});
test('create-order publishes message that process-order can consume', async () => {
// Step 1: Create an order (publishes to SQS)
const createResponse = await createOrder({
body: JSON.stringify({ customerId: 'cust-456', items: [{ productId: 'p1', quantity: 1, price: 10 }] }),
});
expect(createResponse.statusCode).toBe(201);
const { orderId } = JSON.parse(createResponse.body);
// Step 2: Receive the SQS message
const messages = await sqs.send(new ReceiveMessageCommand({
QueueUrl: queueUrl,
MaxNumberOfMessages: 1,
WaitTimeSeconds: 5,
}));
expect(messages.Messages).toHaveLength(1);
const message = messages.Messages[0];
const payload = JSON.parse(message.Body);
expect(payload.orderId).toBe(orderId);
// Step 3: Process it
const sqsEvent = {
Records: [{
messageId: message.MessageId,
receiptHandle: message.ReceiptHandle,
body: message.Body,
}],
};
await processOrder(sqsEvent);
// Step 4: Verify state in DynamoDB
const item = await ddb.send(new GetItemCommand({
TableName: 'orders',
Key: { orderId: { S: orderId } },
}));
const order = unmarshall(item.Item);
expect(order.status).toBe('processed');
});Handling Async Events
Async flows are the hardest part of serverless testing. A function fires and forgets — how do you assert what happened?
Pattern: Poll with Timeout
Don't sleep. Poll with exponential backoff until the expected state appears or a timeout triggers a failure:
async function waitForCondition(checkFn, { timeout = 10000, interval = 500 } = {}) {
const deadline = Date.now() + timeout;
let lastError;
while (Date.now() < deadline) {
try {
const result = await checkFn();
if (result) return result;
} catch (err) {
lastError = err;
}
await new Promise(resolve => setTimeout(resolve, interval));
}
throw new Error(`Condition not met within ${timeout}ms. Last error: ${lastError?.message}`);
}
test('async order processing completes within 5 seconds', async () => {
const { orderId } = await triggerOrderCreation();
const order = await waitForCondition(
async () => {
const result = await ddb.send(new GetItemCommand({
TableName: 'orders',
Key: { orderId: { S: orderId } },
}));
if (!result.Item) return null;
const item = unmarshall(result.Item);
return item.status === 'processed' ? item : null;
},
{ timeout: 5000, interval: 200 }
);
expect(order.status).toBe('processed');
});Pattern: Event Capture with a Test Listener
For EventBridge flows, deploy a test listener Lambda in your sandbox account that writes events to DynamoDB with a TTL. Your test queries for specific events by correlation ID:
// Test listener Lambda (deployed once to sandbox account)
exports.handler = async (event) => {
await ddb.send(new PutItemCommand({
TableName: 'test-events',
Item: marshall({
eventId: randomUUID(),
correlationId: event.detail.correlationId,
eventType: event['detail-type'],
payload: JSON.stringify(event.detail),
ttl: Math.floor(Date.now() / 1000) + 3600, // 1 hour TTL
}),
}));
};In your test:
test('order-created triggers inventory-reserved event', async () => {
const correlationId = randomUUID();
await triggerOrderCreation({ correlationId });
const inventoryEvent = await waitForCondition(async () => {
const result = await ddb.send(new QueryCommand({
TableName: 'test-events',
IndexName: 'correlationId-index',
KeyConditionExpression: 'correlationId = :cid',
FilterExpression: 'eventType = :type',
ExpressionAttributeValues: marshall({
':cid': correlationId,
':type': 'InventoryReserved',
}),
}));
return result.Items?.length > 0 ? unmarshall(result.Items[0]) : null;
});
const payload = JSON.parse(inventoryEvent.payload);
expect(payload.status).toBe('reserved');
});Testing Error Scenarios and Dead Letter Queues
Always test the failure path. Configure your test to inject a bad message and verify it ends up in the DLQ:
test('malformed message routes to DLQ after max retries', async () => {
// Send a message that will always fail processing
await sqs.send(new SendMessageCommand({
QueueUrl: orderQueueUrl,
MessageBody: JSON.stringify({ invalid: 'payload' }),
}));
// With maxReceiveCount=3, message should move to DLQ after 3 failed attempts
// Simulate 3 receive-and-fail cycles
for (let i = 0; i < 3; i++) {
const messages = await sqs.send(new ReceiveMessageCommand({
QueueUrl: orderQueueUrl,
MaxNumberOfMessages: 1,
VisibilityTimeout: 0, // immediately visible again
}));
if (messages.Messages?.length > 0) {
// Process fails — don't delete the message, let it return to queue
try {
await processOrder({ Records: [{ body: messages.Messages[0].Body }] });
} catch {
// Expected failure
}
}
}
// Check DLQ has the message
const dlqMessages = await waitForCondition(async () => {
const result = await sqs.send(new ReceiveMessageCommand({
QueueUrl: dlqUrl,
MaxNumberOfMessages: 1,
}));
return result.Messages?.length > 0 ? result.Messages : null;
});
expect(dlqMessages).toHaveLength(1);
const body = JSON.parse(dlqMessages[0].Body);
expect(body.invalid).toBe('payload');
});Structuring Tests for Speed
Integration tests are slow by nature. Keep them manageable:
- Parallelize at the suite level — use Jest's
--maxWorkersor run independent test files concurrently. Each suite gets its own table prefix or SQS queue name. - Use table-per-test-run naming — prefix resource names with a timestamp or run ID to avoid conflicts between parallel runs:
const RUN_ID = process.env.TEST_RUN_ID || Date.now().toString();
const TABLE_NAME = `orders-test-${RUN_ID}`;- Clean up in
afterAll, notafterEach— teardown is expensive. Delete all test resources once per suite, not after every test. - Separate fast and slow tests — tag integration tests with
@slowin the test name and run them in a separate CI step that doesn't block PRs.
Continuous Testing in Production
Local integration tests against LocalStack are essential, but they miss real-world conditions: IAM policy changes, AWS service limits, latency spikes, and third-party API failures.
HelpMeTest runs scheduled tests against your live serverless endpoints, verifying the full user-visible behavior continuously. When an EventBridge rule stops routing events or a Lambda timeout changes breaks the 99th-percentile response time, you know within minutes rather than when a customer reports it.