Serverless Framework Testing: Unit, Integration, and End-to-End Tests
Testing Serverless Framework applications follows the same layered approach as any Lambda testing: unit tests for business logic, integration tests using LocalStack for AWS service simulation, and end-to-end tests against a dedicated staging deployment. The Serverless Framework adds configuration complexity (serverless.yml) that can itself be tested using serverless-offline for local HTTP testing.
Key Takeaways
serverless-offline runs your full service locally. It simulates API Gateway + Lambda locally, so you can send real HTTP requests to your functions without deploying to AWS.
Test serverless.yml separately from application code. Parse and validate your serverless configuration to catch typos and misconfigured events before deploying.
Use environment variables to configure test vs. production. Serverless Framework supports environment-specific configuration via --stage and provider.environment. Tests should use a dedicated test stage.
Deploy a short-lived staging environment for E2E tests. Use serverless deploy --stage test in CI, run E2E tests, then serverless remove --stage test. This gives you real AWS validation without polluting production.
Seed DynamoDB tables before integration tests. Write a setup script that creates tables and seeds test data, then clean up after. The Serverless Framework's DynamoDB Local plugin automates this.
Serverless Framework Testing Stack
A Serverless Framework application typically uses:
serverless.yml— service configuration (functions, events, resources)- Lambda handlers in Node.js or Python
- AWS services (DynamoDB, S3, SQS) configured as resources in
serverless.yml
Testing strategy:
- Unit tests — handler business logic, no AWS SDK
- Integration tests — handler + AWS services via LocalStack or DynamoDB Local
- Local E2E tests — full service via serverless-offline
- Deployed E2E tests — real AWS deployment, dedicated test stage
Project Structure
order-service/
functions/
createOrder/
handler.js
handler.test.js
getOrder/
handler.js
handler.test.js
services/
orderService.js
orderService.test.js
repositories/
orderRepository.js
orderRepository.test.js
tests/
integration/
orders.integration.test.js
e2e/
orders.e2e.test.js
serverless.yml
jest.config.jsserverless.yml Configuration
service: order-service
frameworkVersion: "3"
provider:
name: aws
runtime: nodejs20.x
region: us-east-1
stage: ${opt:stage, 'dev'}
environment:
ORDERS_TABLE: ${self:service}-${self:provider.stage}-orders
AWS_NODEJS_CONNECTION_REUSE_ENABLED: 1
iamRoleStatements:
- Effect: Allow
Action:
- dynamodb:PutItem
- dynamodb:GetItem
- dynamodb:UpdateItem
- dynamodb:DeleteItem
- dynamodb:Query
- dynamodb:Scan
Resource: "arn:aws:dynamodb:${self:provider.region}:*:table/${self:provider.environment.ORDERS_TABLE}"
functions:
createOrder:
handler: functions/createOrder/handler.main
events:
- http:
path: orders
method: post
cors: true
getOrder:
handler: functions/getOrder/handler.main
events:
- http:
path: orders/{orderId}
method: get
cors: true
resources:
Resources:
OrdersTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: ${self:provider.environment.ORDERS_TABLE}
AttributeDefinitions:
- AttributeName: orderId
AttributeType: S
KeySchema:
- AttributeName: orderId
KeyType: HASH
BillingMode: PAY_PER_REQUESTUnit Tests
// services/orderService.test.js
const { createOrder, getOrder } = require('./orderService');
jest.mock('../repositories/orderRepository', () => ({
saveOrder: jest.fn().mockResolvedValue(undefined),
findOrderById: jest.fn(),
}));
const { saveOrder, findOrderById } = require('../repositories/orderRepository');
describe('createOrder', () => {
beforeEach(() => jest.clearAllMocks());
it('creates order with generated ID and calculated total', async () => {
const order = await createOrder({
customerId: 'cust-123',
items: [
{ productId: 'p1', quantity: 2, price: 50.0 },
{ productId: 'p2', quantity: 1, price: 20.0 },
],
});
expect(order.orderId).toBeDefined();
expect(order.total).toBe(120.0);
expect(order.status).toBe('pending');
expect(saveOrder).toHaveBeenCalledWith(expect.objectContaining({ total: 120.0 }));
});
it('rejects when items is empty', async () => {
await expect(createOrder({ customerId: 'cust-1', items: [] }))
.rejects.toThrow('items cannot be empty');
});
});// functions/createOrder/handler.test.js
const { main } = require('./handler');
jest.mock('../../services/orderService', () => ({
createOrder: jest.fn(),
}));
const { createOrder } = require('../../services/orderService');
const makeApiGatewayEvent = (body) => ({
httpMethod: 'POST',
path: '/orders',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
describe('createOrder handler', () => {
it('returns 201 with created order', async () => {
createOrder.mockResolvedValue({ orderId: 'ord-123', total: 120 });
const response = await main(makeApiGatewayEvent({
customerId: 'cust-1',
items: [{ productId: 'p1', quantity: 2, price: 60 }],
}));
expect(response.statusCode).toBe(201);
expect(JSON.parse(response.body).orderId).toBe('ord-123');
});
it('returns 400 for validation errors', async () => {
createOrder.mockRejectedValue(new Error('items cannot be empty'));
const response = await main(makeApiGatewayEvent({
customerId: 'cust-1',
items: [],
}));
expect(response.statusCode).toBe(400);
});
it('includes CORS headers', async () => {
createOrder.mockResolvedValue({ orderId: 'ord-1', total: 10 });
const response = await main(makeApiGatewayEvent({
customerId: 'cust-1',
items: [{ productId: 'p1', quantity: 1, price: 10 }],
}));
expect(response.headers['Access-Control-Allow-Origin']).toBe('*');
});
});Integration Tests with DynamoDB Local
Install the DynamoDB Local plugin:
npm install --save-dev serverless-dynamodb# serverless.yml additions
plugins:
- serverless-dynamodb
- serverless-offline
custom:
dynamodb:
stages:
- test
- dev
start:
port: 8000
inMemory: true
migrate: true # Auto-create tables from Resources
seed: true
seed:
orders:
sources:
- table: order-service-test-orders
sources: [./tests/seeds/orders.json]// tests/seeds/orders.json
[
{
"orderId": "seed-order-1",
"customerId": "cust-seed-1",
"total": 100.0,
"status": "pending",
"createdAt": "2025-01-01T00:00:00Z"
}
]Integration tests against DynamoDB Local:
// tests/integration/orders.integration.test.js
const AWS = require('aws-sdk');
const { createOrder, getOrder } = require('../../services/orderService');
// Point DynamoDB at local
const dynamodb = new AWS.DynamoDB({
region: 'us-east-1',
endpoint: 'http://localhost:8000',
accessKeyId: 'test',
secretAccessKey: 'test',
});
process.env.ORDERS_TABLE = 'order-service-test-orders';
process.env.AWS_REGION = 'us-east-1';
describe('Order Service Integration', () => {
it('creates and retrieves an order from DynamoDB', async () => {
const order = await createOrder({
customerId: 'integration-test-cust',
items: [{ productId: 'p1', quantity: 1, price: 75.0 }],
});
expect(order.orderId).toBeDefined();
expect(order.total).toBe(75.0);
const retrieved = await getOrder(order.orderId);
expect(retrieved.orderId).toBe(order.orderId);
expect(retrieved.customerId).toBe('integration-test-cust');
});
it('seeded order is accessible', async () => {
const order = await getOrder('seed-order-1');
expect(order).not.toBeNull();
expect(order.total).toBe(100.0);
});
});Local E2E Tests with serverless-offline
Install serverless-offline:
npm install --save-dev serverless-offlineStart the local server:
serverless offline --stage test
<span class="hljs-comment"># Service running at http://localhost:3000Write E2E tests that hit the local server:
// tests/e2e/orders.e2e.test.js
const axios = require('axios');
const BASE_URL = process.env.API_BASE_URL || 'http://localhost:3000';
describe('Orders API E2E', () => {
describe('POST /orders', () => {
it('creates an order', async () => {
const response = await axios.post(`${BASE_URL}/orders`, {
customerId: 'e2e-test-customer',
items: [
{ productId: 'prod-1', quantity: 2, price: 25.0 },
{ productId: 'prod-2', quantity: 1, price: 50.0 },
],
});
expect(response.status).toBe(201);
expect(response.data.orderId).toBeDefined();
expect(response.data.total).toBe(100.0);
expect(response.data.status).toBe('pending');
});
it('returns 400 for missing customerId', async () => {
await expect(
axios.post(`${BASE_URL}/orders`, { items: [{ productId: 'p1', quantity: 1, price: 10 }] })
).rejects.toMatchObject({ response: { status: 400 } });
});
});
describe('GET /orders/:orderId', () => {
it('retrieves a created order', async () => {
const createResponse = await axios.post(`${BASE_URL}/orders`, {
customerId: 'test-cust',
items: [{ productId: 'p1', quantity: 1, price: 50.0 }],
});
const { orderId } = createResponse.data;
const getResponse = await axios.get(`${BASE_URL}/orders/${orderId}`);
expect(getResponse.status).toBe(200);
expect(getResponse.data.orderId).toBe(orderId);
});
it('returns 404 for unknown order', async () => {
await expect(
axios.get(`${BASE_URL}/orders/nonexistent-order-id`)
).rejects.toMatchObject({ response: { status: 404 } });
});
});
});CI Pipeline
# .github/workflows/serverless-tests.yml
name: Serverless Tests
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run test:unit
integration-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Java for DynamoDB Local
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- run: npm ci
- name: Start DynamoDB Local and run integration tests
run: |
npx serverless dynamodb install --stage test
npx serverless dynamodb start --stage test &
sleep 5
npm run test:integration
env:
AWS_REGION: us-east-1
e2e-local:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- run: npm ci
- name: Start serverless-offline and run E2E tests
run: |
npx serverless dynamodb install --stage test
npx serverless offline --stage test &
sleep 10
npm run test:e2e
env:
AWS_REGION: us-east-1
API_BASE_URL: http://localhost:3000Deployed E2E Tests
For testing against real AWS (run less frequently, e.g., on main branch merges):
e2e-deployed:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Deploy test stage
run: npx serverless deploy --stage ci
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Run E2E tests
run: npm run test:e2e
env:
API_BASE_URL: ${{ steps.deploy.outputs.api_url }}
- name: Remove test stage
if: always()
run: npx serverless remove --stage ci
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}Summary
Serverless Framework testing layers:
| Layer | Tool | When to Run |
|---|---|---|
| Unit tests | Jest/pytest, mocked dependencies | Every commit |
| Integration tests | DynamoDB Local, LocalStack | Every commit |
| Local E2E | serverless-offline | Every commit |
| Deployed E2E | Real AWS, short-lived stage | Main branch / pre-release |
The serverless-offline + DynamoDB Local combination gives you full stack local testing without AWS costs or deployment delays. Reserve deployed E2E tests for final validation before production releases.