Simulating AWS Services Locally for Test-Driven Development(TDD)

Thinking About Running SES, SNS, and DynamoDB Locally During TDD? This Is The Way!

When developing applications that interact with AWS services like SES, SNS, and DynamoDB, testing can become challenging. You don’t want to hit actual AWS services during development and tests. Let’s explore two effective approaches to solve this problem.

Approach 1: Mocking AWS SDK Clients

When testing code that interacts with AWS services like SES, SNS, and DynamoDB, mocking is an effective approach that lets you simulate AWS responses without making actual API calls. This guide explores the mocking approach in detail.

Understanding the Mocking Approach

Mocking AWS services for testing involves replacing the actual AWS SDK clients with simulated versions that mimic their behavior. The example code uses the mock-require library to achieve this.

Implementation Details

Step 1: Create Mock Implementations

The provided code shows mock implementations for SES clients:

export function setupMockSES() {
  mockRequire("@aws-sdk/client-ses", {
    SESClient: class {
      constructor() {}
      send() {
        console.log("mock email sent");
        return Promise.resolve({ MessageId: "mock-message-id" });
      }
    },
    SendTemplatedEmailCommand: function (params: any) {
      console.log("mock email Template");
      return params;
    },
    SendRawEmailCommand: function (params: any) {
      console.log("mock email sent");
      return params;
    },
  });
}

What’s Happening Here:

  1. We’re creating a fake SESClient class with a send() method that returns a mock response
  2. We’re also mocking the command classes used with the client
  3. Console logs help you see when these mock methods are called during tests
  4. Each mock returns a predictable response resembling what the real AWS service would return

Step 2: Set Up Mocks Before Importing Handler Code

The critical part is to ensure mocks are set up before your code imports the AWS SDK:

import { setupMockSES, setupMockSNS } from "./helper";
setupMockSES();
setupMockSNS();

// Only AFTER setting up mocks, import the handler
// @ts-ignore
const {
  handler,
} = require("../../services/Customer_Mobile/cmr-signup/dist/handlers/register");

Why This Order Matters:

  1. When Node.js resolves imports, it caches the result
  2. If your code imports AWS SDK modules before the mocks are set up, it will use the real modules
  3. Using require() (instead of import) allows us to control exactly when the module is loaded

Step 3: Write Tests Using Mocked Services

Now your tests can call the handler function, and any AWS service calls made by the handler will use your mock implementations:

describe("Register Handler", () => {
  it("should register a new user and send confirmation email", async () => {
    // Test input
    const event = {
      body: JSON.stringify({
        email: "test@example.com",
        name: "Test User",
        // other required fields
      }),
    };

    // Call the handler (which will use mocked AWS services)
    const result = await handler(event);

    // Assert on the response
    expect(result.statusCode).toBe(200);
    const body = JSON.parse(result.body);
    expect(body.success).toBe(true);
  });
});

Advanced Mocking Techniques

Mock Different Responses

You can configure your mocks to return different responses for testing various scenarios:

export function setupMockDynamoDBWithCustomResponse(response) {
  mockRequire("@aws-sdk/client-dynamodb", {
    DynamoDBClient: class {
      constructor() {}
      send() {
        return Promise.resolve(response);
      }
    },
    GetItemCommand: class {
      constructor(params) {
        this.input = params;
      }
    },
  });
}

Test a successful lookup:

setupMockDynamoDBWithCustomResponse({
  Item: {
    id: { S: "123" },
    name: { S: "Test User" },
  },
});

Test a “not found” scenario:

setupMockDynamoDBWithCustomResponse({ Item: undefined });

Best Practices for AWS Service Mocking

  1. Restore Mocks: If you use different mock configurations in different tests, restore the original modules:

    afterEach(() => {
      mockRequire.stopAll();
    });
    
  2. Keep Mocks Simple: Don’t try to recreate all AWS functionality; focus on what your code uses

  3. Use TypeScript for Mocks: For better type safety, use TypeScript interfaces from AWS SDK:

    import { SendTemplatedEmailCommandInput } from '@aws-sdk/client-ses';
    
    SendTemplatedEmailCommand: function(params: SendTemplatedEmailCommandInput) {
      return params;
    }
    

Approach 2: LocalStack

Unlike mocking, LocalStack provides actual service endpoints that behave like real AWS services. This allows you to:

  • Test with real AWS SDK interactions
  • Develop and test without an internet connection
  • Avoid AWS costs during development
  • Test complex interactions between multiple AWS services

Setting Up LocalStack

Docker Compose Configuration

Here’s a standard Docker Compose configuration for LocalStack:

version: "3.8"
services:
  localstack:
    container_name: localstack
    image: localstack/localstack
    ports:
      - "4566:4566"
      - "4510-4559:4510-4559"
    environment:
      - SERVICES=dynamodb,ses,sns,sqs,lambda,apigateway
      - DEBUG=1
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock"

Serverless Framework Integration

Add these configurations to your serverless.yml:

plugins:
  - serverless-localstack

custom:
  localstack:
    stages:
      - local
    host: http://localhost
    edgePort: 4566
    autostart: true

Working with AWS Services in LocalStack

SES (Simple Email Service)

Configuring SES Client

import { SESClient, SendTemplatedEmailCommand } from "@aws-sdk/client-ses";

// Configure SESClient to use LocalStack
const sesClient = new SESClient({
  endpoint: "http://localhost:4566",
  region: "us-east-1",
});

Sending an Email

// Create the parameters for sending templated email
const params = {
  Destination: {
    ToAddresses: [email],
  },
  Template: "welcome-template",
  TemplateData: JSON.stringify({ name }),
  Source: "no-reply@example.com",
};

// Send the email
const command = new SendTemplatedEmailCommand(params);
const result = await sesClient.send(command);

Creating SES Templates

aws --endpoint-url=http://localhost:4566 ses create-template \
  --cli-input-json '{
    "Template": {
      "TemplateName": "welcome-template",
      "SubjectPart": "Welcome to Our Service",
      "HtmlPart": "<h1>Hello {{name}}</h1><p>Welcome aboard!</p>",
      "TextPart": "Hello {{name}}. Welcome aboard!"
    }
  }'

Testing with LocalStack

Using the AWS CLI

# List SES templates
aws --endpoint-url=http://localhost:4566 ses list-templates

API Testing Example

# Test an API endpoint deployed to LocalStack
curl -X POST \
  http://localhost:4566/restapis/[API_ID]/local/_user_request_/send-email \
  -H 'Content-Type: application/json' \
  -d '{"email":"test@example.com","name":"Test User"}'

Best Practices for LocalStack Development

Create a Shared AWS Client Configuration

// utils/aws-clients.ts
const isLocal = process.env.STAGE === "local";
const localConfig = {
  endpoint: "http://localhost:4566",
  region: "us-east-1",
  credentials: { accessKeyId: "test", secretAccessKey: "test" },
};

export const getClientConfig = () => (isLocal ? localConfig : {});

// Usage
import { getClientConfig } from "./utils/aws-clients";
const sesClient = new SESClient(getClientConfig());

LocalStack is ideal when you need to test:

  • Actual AWS SDK integration
  • Serverless templates and CloudFormation
  • Interactions between multiple AWS services
  • More complex AWS behavior

Comparing the Approaches

Feature Mocking LocalStack
Setup Complexity Simple Moderate (Docker required)
Test Speed Very Fast Fast
Realism Low (simulated) High (real service APIs)
AWS Service Coverage Limited to what you mock Extensive
Configuration Needed Mock implementation Endpoint configuration
Best For Unit testing Integration testing

Conclusion

Both approaches have their place in your testing strategy:

  • Use mocking for fast unit tests and simple verification of AWS client interaction patterns
  • Use LocalStack for more realistic integration tests and when testing multiple interacting AWS services

Choose the approach that best fits your specific testing needs, or combine both for comprehensive test coverage.

8 Likes