Serverless Integration Testing with LocalStack

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: 10

AWS 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-1

Test Isolation

Each test run should start with clean infrastructure. Options:

  1. Random resource names: append Date.now() or UUID to table/bucket names — resources don't conflict but they accumulate
  2. Per-test cleanup: delete and recreate in beforeEach/afterEach — clean but slow
  3. Transaction isolation: for DynamoDB, use TransactWriteItems in 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.

Read more

Start now free