Serverless Framework Testing: Mocking, Emulation & CI/CD Patterns
The Serverless Framework has become the de facto standard for deploying multi-cloud serverless applications. But most teams using it focus on deployment and skip the testing layer — or end up with a fragile test setup that requires a live AWS environment to run. A well-structured Serverless Framework project can have fast, isolated tests at every level: unit tests that run in milliseconds, local integration tests using serverless-offline and DynamoDB Local, and a CI/CD pipeline that validates everything before deployment.
This guide covers the complete testing stack for Serverless Framework projects, including the plugins, patterns, and environment management strategies that make it work reliably.
Project Structure for Testability
Start with a project structure that separates business logic from framework boilerplate:
my-service/
src/
handlers/
users.js # Lambda handler — thin, delegates to services
orders.js
services/
userService.js # Business logic — pure, testable
orderService.js
repositories/
userRepository.js # Data access — injectable
orderRepository.js
tests/
unit/
services/
userService.test.js
handlers/
users.test.js
integration/
users.integration.test.js
serverless.yml
jest.config.js
jest.integration.config.jsThe key principle: Lambda handlers should be thin adapters that parse the event, call a service, and format the response. All business logic lives in services that accept plain JavaScript parameters and return plain values — no AWS SDK calls, no event objects.
// src/services/userService.js
class UserService {
constructor(userRepository) {
this.userRepo = userRepository;
}
async createUser({ name, email }) {
if (!name || !email) {
throw new Error('name and email are required');
}
const existing = await this.userRepo.findByEmail(email);
if (existing) {
throw new Error(`User with email ${email} already exists`);
}
const user = {
id: `user-${Date.now()}`,
name,
email,
createdAt: new Date().toISOString()
};
await this.userRepo.save(user);
return user;
}
async getUser(userId) {
const user = await this.userRepo.findById(userId);
if (!user) {
return null;
}
return user;
}
}
module.exports = UserService;// src/handlers/users.js
const UserService = require('../services/userService');
const UserRepository = require('../repositories/userRepository');
// Module-level instances (initialized once per container, not per invocation)
let userService;
function getService() {
if (!userService) {
userService = new UserService(new UserRepository());
}
return userService;
}
// Export for testing
module.exports._setService = (svc) => { userService = svc; };
module.exports.createUser = async (event) => {
try {
const body = JSON.parse(event.body || '{}');
const user = await getService().createUser(body);
return {
statusCode: 201,
body: JSON.stringify(user)
};
} catch (err) {
if (err.message.includes('required') || err.message.includes('already exists')) {
return {
statusCode: 400,
body: JSON.stringify({ error: err.message })
};
}
console.error('Unexpected error:', err);
return {
statusCode: 500,
body: JSON.stringify({ error: 'Internal server error' })
};
}
};
module.exports.getUser = async (event) => {
try {
const userId = event.pathParameters?.userId;
const user = await getService().getUser(userId);
if (!user) {
return {
statusCode: 404,
body: JSON.stringify({ error: 'User not found' })
};
}
return {
statusCode: 200,
body: JSON.stringify(user)
};
} catch (err) {
return {
statusCode: 500,
body: JSON.stringify({ error: 'Internal server error' })
};
}
};Unit Testing Services
Service unit tests are fast, isolated, and cover all business logic branches:
// tests/unit/services/userService.test.js
const UserService = require('../../../src/services/userService');
function makeRepo(overrides = {}) {
return {
findByEmail: jest.fn().mockResolvedValue(null),
findById: jest.fn().mockResolvedValue(null),
save: jest.fn().mockResolvedValue(undefined),
...overrides
};
}
describe('UserService.createUser', () => {
test('creates user with valid input', async () => {
const repo = makeRepo();
const service = new UserService(repo);
const user = await service.createUser({
name: 'Alice',
email: 'alice@example.com'
});
expect(user.id).toMatch(/^user-/);
expect(user.name).toBe('Alice');
expect(user.email).toBe('alice@example.com');
expect(user.createdAt).toBeDefined();
expect(repo.save).toHaveBeenCalledWith(user);
});
test('throws when name is missing', async () => {
const service = new UserService(makeRepo());
await expect(service.createUser({ email: 'alice@example.com' }))
.rejects.toThrow('name and email are required');
});
test('throws when email is missing', async () => {
const service = new UserService(makeRepo());
await expect(service.createUser({ name: 'Alice' }))
.rejects.toThrow('name and email are required');
});
test('throws when email already exists', async () => {
const existingUser = { id: 'user-old', name: 'Old Alice', email: 'alice@example.com' };
const repo = makeRepo({
findByEmail: jest.fn().mockResolvedValue(existingUser)
});
const service = new UserService(repo);
await expect(service.createUser({ name: 'New Alice', email: 'alice@example.com' }))
.rejects.toThrow('already exists');
});
});
describe('UserService.getUser', () => {
test('returns user when found', async () => {
const expectedUser = { id: 'user-123', name: 'Alice' };
const repo = makeRepo({
findById: jest.fn().mockResolvedValue(expectedUser)
});
const service = new UserService(repo);
const user = await service.getUser('user-123');
expect(user).toEqual(expectedUser);
expect(repo.findById).toHaveBeenCalledWith('user-123');
});
test('returns null when user not found', async () => {
const service = new UserService(makeRepo());
const user = await service.getUser('nonexistent');
expect(user).toBeNull();
});
});Unit Testing Handlers
Handler unit tests mock the service layer and verify HTTP response formatting:
// tests/unit/handlers/users.test.js
const { createUser, getUser, _setService } = require('../../../src/handlers/users');
function makeService(overrides = {}) {
return {
createUser: jest.fn(),
getUser: jest.fn(),
...overrides
};
}
function makeEvent(overrides = {}) {
return {
body: null,
pathParameters: {},
headers: {},
...overrides
};
}
describe('createUser handler', () => {
test('returns 201 with created user', async () => {
const newUser = { id: 'user-123', name: 'Alice', email: 'alice@example.com' };
_setService(makeService({ createUser: jest.fn().mockResolvedValue(newUser) }));
const event = makeEvent({ body: JSON.stringify({ name: 'Alice', email: 'alice@example.com' }) });
const result = await createUser(event);
expect(result.statusCode).toBe(201);
const body = JSON.parse(result.body);
expect(body.name).toBe('Alice');
});
test('returns 400 when service throws validation error', async () => {
_setService(makeService({
createUser: jest.fn().mockRejectedValue(new Error('name and email are required'))
}));
const event = makeEvent({ body: JSON.stringify({ name: 'Alice' }) });
const result = await createUser(event);
expect(result.statusCode).toBe(400);
expect(JSON.parse(result.body).error).toContain('required');
});
test('returns 400 when duplicate email', async () => {
_setService(makeService({
createUser: jest.fn().mockRejectedValue(new Error('User with email alice@example.com already exists'))
}));
const event = makeEvent({ body: JSON.stringify({ name: 'Alice', email: 'alice@example.com' }) });
const result = await createUser(event);
expect(result.statusCode).toBe(400);
});
test('handles malformed JSON body gracefully', async () => {
_setService(makeService({ createUser: jest.fn().mockRejectedValue(new Error('required')) }));
const event = makeEvent({ body: '{invalid json' });
const result = await createUser(event);
// Should not throw, should return an error status
expect(result.statusCode).toBeGreaterThanOrEqual(400);
});
});Local Integration Tests with serverless-offline
serverless-offline emulates API Gateway locally and loads your actual handler code:
npm install --save-dev serverless-offline# serverless.yml
service: my-service
plugins:
- serverless-offline
provider:
name: aws
runtime: nodejs18.x
region: us-east-1
environment:
DYNAMODB_TABLE: ${self:service}-${sls:stage}-users
DYNAMODB_ENDPOINT: ${env:DYNAMODB_ENDPOINT, ""}
functions:
createUser:
handler: src/handlers/users.createUser
events:
- http:
path: users
method: post
cors: true
getUser:
handler: src/handlers/users.getUser
events:
- http:
path: users/{userId}
method: get
cors: trueRun the local server for manual testing:
npx serverless offline start --stage local --port 3000For automated integration tests, start serverless-offline as part of your test setup:
// jest.integration.config.js
module.exports = {
testMatch: ['**/tests/integration/**/*.test.js'],
globalSetup: './tests/integration/setup.js',
globalTeardown: './tests/integration/teardown.js',
testTimeout: 30000
};// tests/integration/setup.js
const { spawn } = require('child_process');
module.exports = async () => {
await new Promise((resolve, reject) => {
const proc = spawn('npx', [
'serverless', 'offline', 'start',
'--stage', 'test',
'--port', '3000',
'--noTimeout'
], {
stdio: 'pipe',
env: {
...process.env,
DYNAMODB_ENDPOINT: 'http://localhost:8000'
}
});
global.__SLS_OFFLINE_PROC__ = proc;
proc.stdout.on('data', (data) => {
if (data.toString().includes('server ready')) {
resolve();
}
});
proc.on('error', reject);
setTimeout(() => reject(new Error('serverless-offline timeout')), 30000);
});
};// tests/integration/teardown.js
module.exports = async () => {
if (global.__SLS_OFFLINE_PROC__) {
global.__SLS_OFFLINE_PROC__.kill();
}
};DynamoDB Local with jest-dynalite
jest-dynalite provides a fast, in-process DynamoDB emulator using Jest's setup hooks. No Docker required.
npm install --save-dev jest-dynaliteCreate the configuration:
// jest-dynalite-config.js
module.exports = {
tables: [
{
TableName: 'my-service-test-users',
KeySchema: [{ AttributeName: 'id', KeyType: 'HASH' }],
AttributeDefinitions: [{ AttributeName: 'id', AttributeType: 'S' }],
BillingMode: 'PAY_PER_REQUEST',
// Optional: seed data
data: [
{ id: 'user-seed-1', name: 'Seed User', email: 'seed@example.com' }
]
}
]
};Add to your Jest config:
// jest.config.js
module.exports = {
testEnvironment: '@shelf/jest-dynalite/environment',
// ... other config
};Now repository tests run with a real DynamoDB implementation:
// tests/unit/repositories/userRepository.test.js
const UserRepository = require('../../../src/repositories/userRepository');
// jest-dynalite sets MOCK_DYNAMODB_ENDPOINT automatically
process.env.AWS_ACCESS_KEY_ID = 'test';
process.env.AWS_SECRET_ACCESS_KEY = 'test';
process.env.AWS_REGION = 'us-east-1';
process.env.DYNAMODB_TABLE = 'my-service-test-users';
describe('UserRepository', () => {
let repo;
beforeEach(() => {
repo = new UserRepository();
});
test('saves and retrieves a user', async () => {
const user = { id: 'user-test-1', name: 'Alice', email: 'alice@example.com' };
await repo.save(user);
const retrieved = await repo.findById('user-test-1');
expect(retrieved).toEqual(user);
});
test('returns null for missing user', async () => {
const result = await repo.findById('nonexistent');
expect(result).toBeNull();
});
test('finds user by email', async () => {
const user = { id: 'user-email-1', name: 'Bob', email: 'bob@example.com' };
await repo.save(user);
const found = await repo.findByEmail('bob@example.com');
expect(found.id).toBe('user-email-1');
});
test('returns null when email not found', async () => {
const result = await repo.findByEmail('ghost@example.com');
expect(result).toBeNull();
});
});Mocking External Services
Serverless applications frequently call external APIs — Stripe, Twilio, SendGrid, third-party data providers. Use nock to mock HTTP calls in integration tests:
npm install --save-dev nock// tests/integration/payments.integration.test.js
const nock = require('nock');
const axios = require('axios');
const BASE_URL = 'http://localhost:3000';
beforeAll(() => {
// Mock Stripe API
nock('https://api.stripe.com')
.post('/v1/charges')
.reply(200, {
id: 'ch_test_123',
status: 'succeeded',
amount: 9999
});
});
afterAll(() => {
nock.cleanAll();
});
test('POST /orders creates order and charges payment', async () => {
const response = await axios.post(`${BASE_URL}/orders`, {
userId: 'user-seed-1',
items: [{ sku: 'WIDGET-1', qty: 1, price: 99.99 }],
paymentToken: 'tok_visa'
});
expect(response.status).toBe(201);
expect(response.data.paymentId).toBe('ch_test_123');
expect(response.data.status).toBe('confirmed');
});For AWS service calls that go through the SDK, use aws-sdk-mock or point the SDK at LocalStack:
// tests/integration/setup.js — LocalStack variant
process.env.AWS_ENDPOINT_URL = 'http://localhost:4566';
process.env.AWS_ACCESS_KEY_ID = 'test';
process.env.AWS_SECRET_ACCESS_KEY = 'test';
process.env.AWS_DEFAULT_REGION = 'us-east-1';Environment Management
Managing environments (dev, staging, prod) is where Serverless Framework testing gets complicated. A robust approach uses separate serverless.yml stages with per-stage variable overrides:
# serverless.yml
provider:
name: aws
runtime: nodejs18.x
stage: ${opt:stage, 'dev'}
environment:
STAGE: ${sls:stage}
DYNAMODB_TABLE: ${self:service}-${sls:stage}-users
LOG_LEVEL: ${self:custom.logLevel.${sls:stage}, 'info'}
custom:
logLevel:
local: debug
dev: debug
staging: info
prod: warn
dynamodb:
stages:
- local
start:
port: 8000
inMemory: true
migrate: trueUse .env files per stage, never committed to source control:
# .env.local
DYNAMODB_ENDPOINT=http://localhost:8000
STRIPE_SECRET_KEY=sk_test_...
SENDGRID_API_KEY=SG.test...
# .env.test
DYNAMODB_ENDPOINT=http://localhost:8000
STRIPE_SECRET_KEY=sk_test_...Load them in your test setup:
// Load environment before tests
require('dotenv').config({ path: `.env.${process.env.NODE_ENV || 'test'}` });Full Integration Test with serverless-offline + DynamoDB Local
Here's a complete integration test that exercises the full stack locally:
// tests/integration/users.integration.test.js
const axios = require('axios');
const { DynamoDBClient, DeleteItemCommand } = require('@aws-sdk/client-dynamodb');
const BASE_URL = 'http://localhost:3000';
const TABLE = 'my-service-test-users';
const dynamodb = new DynamoDBClient({
endpoint: process.env.DYNAMODB_ENDPOINT || 'http://localhost:8000',
region: 'us-east-1',
credentials: { accessKeyId: 'test', secretAccessKey: 'test' }
});
const createdUsers = [];
afterAll(async () => {
// Clean up created test users
for (const userId of createdUsers) {
await dynamodb.send(new DeleteItemCommand({
TableName: TABLE,
Key: { id: { S: userId } }
})).catch(() => {});
}
});
describe('Users API — Integration', () => {
test('POST /users creates a new user', async () => {
const payload = {
name: 'Integration Test User',
email: `integration-${Date.now()}@test.com`
};
const response = await axios.post(`${BASE_URL}/users`, payload);
expect(response.status).toBe(201);
expect(response.data.id).toBeDefined();
expect(response.data.name).toBe(payload.name);
expect(response.data.email).toBe(payload.email);
createdUsers.push(response.data.id);
});
test('GET /users/:id retrieves created user', async () => {
// Create a user first
const created = await axios.post(`${BASE_URL}/users`, {
name: 'Retrieve Test',
email: `retrieve-${Date.now()}@test.com`
});
createdUsers.push(created.data.id);
// Retrieve it
const response = await axios.get(`${BASE_URL}/users/${created.data.id}`);
expect(response.status).toBe(200);
expect(response.data.id).toBe(created.data.id);
expect(response.data.name).toBe('Retrieve Test');
});
test('POST /users returns 400 for duplicate email', async () => {
const email = `dup-${Date.now()}@test.com`;
const first = await axios.post(`${BASE_URL}/users`, { name: 'First', email });
createdUsers.push(first.data.id);
try {
await axios.post(`${BASE_URL}/users`, { name: 'Second', email });
fail('Expected 400 error');
} catch (err) {
expect(err.response.status).toBe(400);
}
});
test('GET /users/:id returns 404 for missing user', async () => {
try {
await axios.get(`${BASE_URL}/users/nonexistent-user-id`);
fail('Expected 404 error');
} catch (err) {
expect(err.response.status).toBe(404);
}
});
});CI/CD Deployment Strategy
A reliable CI/CD pipeline for Serverless Framework projects has clear stages:
# .github/workflows/deploy.yml
name: Test and Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Unit tests
run: npm test -- --testPathPattern=unit --coverage
- name: Start DynamoDB Local
run: |
docker run -d -p 8000:8000 amazon/dynamodb-local
sleep 3
- name: Integration tests
run: npm run test:integration
env:
NODE_ENV: test
DYNAMODB_ENDPOINT: http://localhost:8000
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_DEFAULT_REGION: us-east-1
deploy-staging:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: npm ci
- name: Deploy to staging
run: npx serverless deploy --stage staging
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
smoke-test-staging:
needs: deploy-staging
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: npm ci
- name: Smoke tests against staging
run: npm run test:smoke
env:
API_URL: ${{ secrets.STAGING_API_URL }}
NODE_ENV: staging
deploy-production:
needs: smoke-test-staging
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: npm ci
- name: Deploy to production
run: npx serverless deploy --stage prod
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID_PROD }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY_PROD }}Continuous Monitoring After Deployment
Deployment is not the end. After each production deploy, your critical endpoints should be automatically verified. HelpMeTest lets you run your integration tests on a schedule against your production API, giving you ongoing assurance that the deployed function behaves exactly as your tests specify. When a downstream dependency changes or a new deployment introduces a regression, you'll know within minutes rather than when a user reports it.
Testing SQS-Triggered Functions
For SQS-triggered functions, serverless-offline-sqs extends serverless-offline:
npm install --save-dev serverless-offline-sqsplugins:
- serverless-offline-sqs
- serverless-offline
custom:
serverless-offline-sqs:
autoCreate: true
apiVersion: '2012-11-05'
endpoint: http://0.0.0.0:9324
region: us-east-1
accessKeyId: root
secretAccessKey: root
skipCacheInvalidation: false// tests/integration/queue.integration.test.js
const { SQSClient, SendMessageCommand } = require('@aws-sdk/client-sqs');
const sqs = new SQSClient({
endpoint: 'http://localhost:9324',
region: 'us-east-1',
credentials: { accessKeyId: 'root', secretAccessKey: 'root' }
});
test('SQS message triggers order processing', async () => {
const order = { id: 'order-sqs-test', userId: 'user-123', total: 29.99 };
await sqs.send(new SendMessageCommand({
QueueUrl: 'http://localhost:9324/queue/orders',
MessageBody: JSON.stringify(order)
}));
// Wait for processing
await new Promise(resolve => setTimeout(resolve, 2000));
// Assert side effects
});Key Takeaways
A complete Serverless Framework test suite has five components:
- Service unit tests — cover all business logic with mocked repositories
- Handler unit tests — verify HTTP response formatting and error handling
- Repository tests — use jest-dynalite for fast, real DynamoDB behavior
- Integration tests — use serverless-offline + DynamoDB Local for full request/response testing
- Smoke tests — minimal post-deployment verification against staging and production
The plugin ecosystem around Serverless Framework (serverless-offline, serverless-dynamodb, serverless-offline-sqs, jest-dynalite) gives you all the emulation you need to run a complete test suite without a live AWS environment. Combined with clear environment separation and a staged deployment pipeline, you can ship serverless code with confidence.