Serverless Integration Testing with LocalStack
Unit tests with mocked AWS SDK calls tell you that your function logic is correct. They don't tell you whether your DynamoDB queries actually work, whether your S3 key paths are right, or whether your SQS message format triggers the right Lambda. LocalStack fills that gap: it runs the AWS service APIs locally so your integration tests hit real infrastructure without paying AWS rates or requiring network access.
This guide covers LocalStack setup, test patterns for common services, and how to integrate LocalStack into CI.
What LocalStack Provides
LocalStack emulates these AWS services locally:
- Lambda — run functions via local invoker
- DynamoDB — full table/index/query support
- S3 — bucket operations, presigned URLs
- SQS — standard and FIFO queues
- SNS — topics, subscriptions
- API Gateway — HTTP and REST APIs
- IAM — role/policy emulation
- SSM Parameter Store — configuration management
- Secrets Manager — secrets storage
Community edition (free) covers most services. Pro adds more complex services (RDS, Kinesis, ECS).
Setup
Docker Compose
# docker-compose.localstack.yml
version: '3.8'
services:
localstack:
image: localstack/localstack:3
ports:
- "4566:4566" # LocalStack gateway (all services)
environment:
- SERVICES=lambda,dynamodb,s3,sqs,sns,iam
- DEFAULT_REGION=us-east-1
- AWS_DEFAULT_REGION=us-east-1
- LOCALSTACK_AUTH_TOKEN=${LOCALSTACK_AUTH_TOKEN:-} # Required for Pro
volumes:
- "/var/run/docker.sock:/var/run/docker.sock" # Required for Lambda
- ./localstack/init:/etc/localstack/init/ready.d # Initialization scripts
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4566/_localstack/health"]
interval: 5s
timeout: 10s
retries: 10AWS Client Configuration for LocalStack
// test/helpers/localstack.js
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
const { S3Client } = require('@aws-sdk/client-s3');
const { SQSClient } = require('@aws-sdk/client-sqs');
const { LambdaClient } = require('@aws-sdk/client-lambda');
const LOCALSTACK_ENDPOINT = 'http://localhost:4566';
const TEST_REGION = 'us-east-1';
const clientConfig = {
endpoint: LOCALSTACK_ENDPOINT,
region: TEST_REGION,
credentials: {
accessKeyId: 'test',
secretAccessKey: 'test',
},
};
module.exports = {
dynamoDB: new DynamoDBClient(clientConfig),
s3: new S3Client(clientConfig),
sqs: new SQSClient(clientConfig),
lambda: new LambdaClient(clientConfig),
};Infrastructure Setup for Tests
Create infrastructure before your tests run. Use the AWS CLI configured for LocalStack or use SDK calls:
// test/helpers/setup-localstack.js
const {
CreateTableCommand, DeleteTableCommand
} = require('@aws-sdk/client-dynamodb');
const {
CreateBucketCommand, DeleteBucketCommand
} = require('@aws-sdk/client-s3');
const {
CreateQueueCommand, DeleteQueueCommand
} = require('@aws-sdk/client-sqs');
const { dynamoDB, s3, sqs } = require('./localstack');
async function createTestTable(tableName) {
await dynamoDB.send(new CreateTableCommand({
TableName: tableName,
KeySchema: [
{ AttributeName: 'pk', KeyType: 'HASH' },
{ AttributeName: 'sk', KeyType: 'RANGE' },
],
AttributeDefinitions: [
{ AttributeName: 'pk', AttributeType: 'S' },
{ AttributeName: 'sk', AttributeType: 'S' },
],
BillingMode: 'PAY_PER_REQUEST',
}));
}
async function createTestBucket(bucketName) {
await s3.send(new CreateBucketCommand({ Bucket: bucketName }));
}
async function createTestQueue(queueName) {
const result = await sqs.send(new CreateQueueCommand({ QueueName: queueName }));
return result.QueueUrl;
}
module.exports = { createTestTable, createTestBucket, createTestQueue };DynamoDB Integration Tests
// users.repository.integration.test.js
const {
DynamoDBDocumentClient, PutCommand, GetCommand, QueryCommand
} = require('@aws-sdk/lib-dynamodb');
const { createTestTable } = require('./helpers/setup-localstack');
const { dynamoDB } = require('./helpers/localstack');
const { UserRepository } = require('../src/users.repository');
const TABLE_NAME = `test-users-${Date.now()}`;
const docClient = DynamoDBDocumentClient.from(dynamoDB);
beforeAll(async () => {
await createTestTable(TABLE_NAME);
});
describe('UserRepository integration', () => {
const repo = new UserRepository(TABLE_NAME, docClient);
test('creates and retrieves a user', async () => {
const user = { id: 'user-1', email: 'test@example.com', createdAt: Date.now() };
await repo.create(user);
const retrieved = await repo.getById('user-1');
expect(retrieved).toEqual(user);
});
test('query by email returns correct user', async () => {
await repo.create({ id: 'user-2', email: 'alice@example.com', createdAt: Date.now() });
await repo.create({ id: 'user-3', email: 'bob@example.com', createdAt: Date.now() });
const results = await repo.findByEmail('alice@example.com');
expect(results).toHaveLength(1);
expect(results[0].id).toBe('user-2');
});
test('update modifies only specified fields', async () => {
await repo.create({ id: 'user-4', email: 'original@example.com', name: 'Original' });
await repo.update('user-4', { name: 'Updated' });
const updated = await repo.getById('user-4');
expect(updated.name).toBe('Updated');
expect(updated.email).toBe('original@example.com'); // Unchanged
});
test('delete removes the user', async () => {
await repo.create({ id: 'user-5', email: 'delete@example.com' });
await repo.delete('user-5');
const result = await repo.getById('user-5');
expect(result).toBeNull();
});
test('handles concurrent writes without corruption', async () => {
const writes = Array.from({ length: 10 }, (_, i) =>
repo.create({ id: `concurrent-${i}`, email: `user${i}@example.com` })
);
await Promise.all(writes);
// Verify all 10 were written
const count = await repo.countAll();
expect(count).toBeGreaterThanOrEqual(10);
});
});S3 Integration Tests
// file.service.integration.test.js
const {
PutObjectCommand, GetObjectCommand, HeadObjectCommand
} = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const { createTestBucket } = require('./helpers/setup-localstack');
const { s3 } = require('./helpers/localstack');
const { FileService } = require('../src/file.service');
const BUCKET_NAME = `test-files-${Date.now()}`;
beforeAll(async () => {
await createTestBucket(BUCKET_NAME);
});
describe('FileService integration', () => {
const service = new FileService(BUCKET_NAME, s3);
test('upload stores file and returns correct key', async () => {
const key = await service.upload('test.txt', Buffer.from('hello world'), 'text/plain');
expect(key).toBeDefined();
// Verify it's actually in S3
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
expect(head.ContentType).toBe('text/plain');
expect(head.ContentLength).toBe(11);
});
test('download retrieves correct file content', async () => {
await s3.send(new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: 'test-download.txt',
Body: 'file content here',
}));
const content = await service.download('test-download.txt');
expect(content.toString()).toBe('file content here');
});
test('presigned URL is generated correctly', async () => {
await s3.send(new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: 'presigned-test.txt',
Body: 'presigned content',
}));
const url = await service.getPresignedUrl('presigned-test.txt', 3600);
expect(url).toContain('presigned-test.txt');
expect(url).toContain('X-Amz-Expires=3600');
});
test('uploading to correct prefix structure', async () => {
const key = await service.uploadUserFile('user-123', 'avatar.png', imageBuffer);
expect(key).toMatch(/^users\/user-123\//);
});
});SQS Integration Tests
// order.processor.integration.test.js
const {
SendMessageCommand, ReceiveMessageCommand, DeleteMessageCommand
} = require('@aws-sdk/client-sqs');
const { createTestQueue } = require('./helpers/setup-localstack');
const { sqs } = require('./helpers/localstack');
const { OrderProcessor } = require('../src/order.processor');
let queueUrl;
beforeAll(async () => {
queueUrl = await createTestQueue('test-orders');
});
describe('OrderProcessor integration', () => {
const processor = new OrderProcessor(queueUrl, sqs);
test('enqueues order and it appears in queue', async () => {
const order = { id: 'order-1', items: [{ id: 'item-1', qty: 2 }] };
await processor.enqueue(order);
const response = await sqs.send(new ReceiveMessageCommand({
QueueUrl: queueUrl,
MaxNumberOfMessages: 1,
WaitTimeSeconds: 1,
}));
expect(response.Messages).toHaveLength(1);
const received = JSON.parse(response.Messages[0].Body);
expect(received.id).toBe('order-1');
// Clean up
await sqs.send(new DeleteMessageCommand({
QueueUrl: queueUrl,
ReceiptHandle: response.Messages[0].ReceiptHandle,
}));
});
test('processor handles malformed message without crashing', async () => {
await sqs.send(new SendMessageCommand({
QueueUrl: queueUrl,
MessageBody: 'not json at all',
}));
const messages = await processor.receiveMessages(1);
const results = await processor.processAll(messages);
// Should report failure for malformed messages
expect(results.failed).toHaveLength(1);
expect(results.succeeded).toHaveLength(0);
});
test('dead letter queue receives failed messages', async () => {
const dlqUrl = await createTestQueue('test-orders-dlq');
const processorWithDLQ = new OrderProcessor(queueUrl, sqs, { dlqUrl });
const invalidMessage = { id: null, items: [] }; // Will fail processing
await processorWithDLQ.enqueue(invalidMessage);
await processorWithDLQ.processNext();
// Message should be in DLQ after max retries
await sleep(100);
const dlqMessages = await sqs.send(new ReceiveMessageCommand({
QueueUrl: dlqUrl,
MaxNumberOfMessages: 1,
WaitTimeSeconds: 1,
}));
expect(dlqMessages.Messages).toHaveLength(1);
});
});Lambda Integration Tests with LocalStack
// lambda.integration.test.js
const {
InvokeCommand, CreateFunctionCommand
} = require('@aws-sdk/client-lambda');
const { lambda } = require('./helpers/localstack');
const fs = require('fs');
const path = require('path');
beforeAll(async () => {
// Deploy function to LocalStack Lambda
const zipBuffer = fs.readFileSync(path.join(__dirname, '../dist/function.zip'));
await lambda.send(new CreateFunctionCommand({
FunctionName: 'test-my-function',
Runtime: 'nodejs20.x',
Handler: 'handler.handler',
Role: 'arn:aws:iam::000000000000:role/test-role',
Code: { ZipFile: zipBuffer },
Environment: {
Variables: {
USERS_TABLE: 'test-users',
AWS_ENDPOINT_URL: 'http://localstack:4566',
},
},
}));
});
test('Lambda function processes valid event correctly', async () => {
const event = {
pathParameters: { userId: 'test-user' },
};
const response = await lambda.send(new InvokeCommand({
FunctionName: 'test-my-function',
Payload: Buffer.from(JSON.stringify(event)),
}));
const payload = JSON.parse(Buffer.from(response.Payload).toString());
expect(payload.statusCode).toBe(200);
});CI Configuration
GitHub Actions
# .github/workflows/integration-tests.yml
name: Integration Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
localstack:
image: localstack/localstack:3
ports:
- 4566:4566
env:
SERVICES: lambda,dynamodb,s3,sqs,sns
DEFAULT_REGION: us-east-1
options: >-
--health-cmd "curl -f http://localhost:4566/_localstack/health"
--health-interval 5s
--health-timeout 10s
--health-retries 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Run integration tests
run: npm run test:integration
env:
LOCALSTACK_ENDPOINT: http://localhost:4566
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_DEFAULT_REGION: us-east-1Test Isolation
Each test run should start with clean infrastructure. Options:
- Random resource names: append
Date.now()or UUID to table/bucket names — resources don't conflict but they accumulate - Per-test cleanup: delete and recreate in
beforeEach/afterEach— clean but slow - Transaction isolation: for DynamoDB, use
TransactWriteItemsin tests and roll back — complex but fast
For most teams, random names + cleanup in afterAll is the right balance:
const UNIQUE_SUFFIX = Date.now();
const TABLE_NAME = `test-users-${UNIQUE_SUFFIX}`;
beforeAll(async () => {
await createTestTable(TABLE_NAME);
});
afterAll(async () => {
await dynamoDB.send(new DeleteTableCommand({ TableName: TABLE_NAME }));
});What LocalStack Can't Test
LocalStack is not a perfect AWS replica:
- IAM enforcement is limited in the free tier — don't rely on LocalStack for security testing
- Performance characteristics differ — LocalStack is single-process, not distributed
- Service limits aren't enforced — tests that exceed DynamoDB item size limits may pass locally but fail in AWS
- Cross-service events (e.g., S3 triggering Lambda) need Pro or careful manual setup
For security and IAM testing, use AWS with a dedicated test account. For performance testing, use AWS with real load.
LocalStack's value is functional integration testing: does your code correctly use the API, construct the right queries, handle the right error types? That's the majority of integration test value, and LocalStack handles it well.