Serverless Framework Testing: Mocking, Emulation & CI/CD Patterns

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.js

The 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: true

Run the local server for manual testing:

npx serverless offline start --stage local --port 3000

For 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-dynalite

Create 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: true

Use .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-sqs
plugins:
  - 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:

  1. Service unit tests — cover all business logic with mocked repositories
  2. Handler unit tests — verify HTTP response formatting and error handling
  3. Repository tests — use jest-dynalite for fast, real DynamoDB behavior
  4. Integration tests — use serverless-offline + DynamoDB Local for full request/response testing
  5. 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.

Read more

Start now free