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:
- We’re creating a fake
SESClientclass with asend()method that returns a mock response - We’re also mocking the command classes used with the client
- Console logs help you see when these mock methods are called during tests
- 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:
- When Node.js resolves imports, it caches the result
- If your code imports AWS SDK modules before the mocks are set up, it will use the real modules
- Using
require()(instead ofimport) 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
-
Restore Mocks: If you use different mock configurations in different tests, restore the original modules:
afterEach(() => { mockRequire.stopAll(); }); -
Keep Mocks Simple: Don’t try to recreate all AWS functionality; focus on what your code uses
-
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.