Node.js Serverless on AWS Lambda · Running Node.js Serverless in Production

Testing Lambda Functions

Why a test suite that only runs in the cloud slows every change, and how to structure Node.js functions for fast, reliable tests.

Scenario

Example app: the team's only tests deploy the whole stack to a shared AWS account and call the API. A full run takes 25 minutes, often fails because of other developers' deployments, and nobody runs it before pushing.

What you see in production

Simple bugs reach production regularly: a wrong field name in the event, an unhandled empty list, a missing permission. Developers say the handlers are "impossible to unit test" because they create AWS clients and call them directly.

The naive code

import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
const client = new DynamoDBClient({});

export const handler = async (event) => {
  const body = JSON.parse(event.body);
  if (!body.items.length) return { statusCode: 400 };   // crashes when items is missing
  await client.send(new PutItemCommand({ /* ... */ }));
  return { statusCode: 201 };
};

Reproduce it

Separating business logic from the AWS adapter makes most behaviour testable in milliseconds:

Testing logic with a fake repositoryTry it yourself
// Business logic: no AWS imports, dependencies passed in.
function createOrderService(repo) {
  return {
    async create(input) {
      if (!Array.isArray(input.items) || input.items.length === 0) return { status: 400, error: "items required" };
      const total = input.items.reduce((s, i) => s + i.qty * i.price, 0);
      await repo.save({ id: `ord-${input.items.length}-${total}`, total });
      return { status: 201, total };
    },
  };
}

// Tests with an in-memory fake (a real test runner would use node:test or Vitest).
const saved = [];
const service = createOrderService({ save: async (o) => saved.push(o) });
const cases = [
  ["missing items", {}, 400],
  ["empty items", { items: [] }, 400],
  ["valid order", { items: [{ qty: 2, price: 10 }, { qty: 1, price: 5 }] }, 201],
];
for (const [name, input, expected] of cases) {
  const result = await service.create(input);
  console.log(result.status === expected ? "PASS" : "FAIL", name, JSON.stringify(result));
}
console.log("saved:", saved);

Output

PASS missing items {"status":400,"error":"items required"}
PASS empty items {"status":400,"error":"items required"}
PASS valid order {"status":201,"total":25}
saved: [ { id: 'ord-2-25', total: 25 } ]

Root cause

When handlers mix event parsing, business rules and AWS SDK calls, the only way to exercise them is against real infrastructure, which is slow, flaky and shared. Test feedback then comes too late and too rarely to catch simple mistakes.

The fix

Use a testing pyramid adapted to serverless:

  1. Unit tests for business logic with dependencies injected (fakes or aws-sdk-client-mock for SDK v3 clients), using node:test or Vitest. Milliseconds per test.
  2. Handler tests with real sample events (generate them with sam local generate-event or keep JSON fixtures) to verify parsing and response shapes for each event source.
  3. Integration tests in the cloud against an ephemeral stack per branch or developer (sam deploy or CDK with a stack-name suffix), testing IAM permissions, event source mappings and service limits that emulators cannot reproduce.
  4. Use sam local invoke or sam local start-api for quick manual checks, and sam sync --watch to iterate against real services.
// handler.mjs: a thin adapter
import { createOrderService } from "./orders.mjs";
import { dynamoOrderRepo } from "./repo.mjs";
const service = createOrderService(dynamoOrderRepo());
export const handler = async (event) => {
  const result = await service.create(JSON.parse(event.body ?? "{}"));
  return { statusCode: result.status, body: JSON.stringify(result) };
};

Trade-offs

  • Mocks can drift from real service behaviour; keep a smaller set of cloud integration tests as the source of truth.
  • Ephemeral stacks cost money and need cleanup automation.
  • Local emulators (LocalStack, SAM local) speed iteration but do not reproduce IAM, quotas or scaling exactly.

Detect it in production

  • Track escaped defects by category: logic bugs suggest missing unit tests; permission and integration bugs suggest missing cloud tests.
  • CI duration and flakiness metrics for the test suites.
  • Coverage of event fixtures for every event source a function handles.

Key points

  • Keep handlers thin; test business logic without AWS.
  • Test handlers with real sample events.
  • Run integration tests in ephemeral cloud stacks for permissions and wiring.

Further reading: Testing serverless functions · Using sam local invoke

Practice questions

How do you make a Lambda handler unit-testable without AWS?

Answer

Keep the handler as a thin adapter that parses the event and calls business logic whose dependencies (repositories, clients) are passed in. Tests then use fakes or SDK client mocks and run in milliseconds.

What can only be tested reliably in a real AWS environment?

Answer

IAM permissions, event source mapping configuration, service quotas and limits, VPC and network access, and the real behaviour of managed services. Run these as integration tests in ephemeral stacks.

How do you get realistic test events for a handler?

Answer

Generate them with sam local generate-event for the event source (for example sam local generate-event sqs receive-message), or capture sanitised real events from logs, and keep them as fixtures.

Open in the app to mark lessons complete, take notes and track your progress.