Serverless Framework Testing: Unit, Integration, and End-to-End Tests

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:

  1. Unit tests — handler business logic, no AWS SDK
  2. Integration tests — handler + AWS services via LocalStack or DynamoDB Local
  3. Local E2E tests — full service via serverless-offline
  4. 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.js

serverless.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_REQUEST

Unit 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-offline

Start the local server:

serverless offline --stage test
<span class="hljs-comment"># Service running at http://localhost:3000

Write 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:3000

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

Read more