A useful Lambda unit test calls your handler or business logic in isolation, supplies known input, replaces external dependencies with controlled test doubles, and checks the result. This guide builds one executable example with Node.js’s built-in test runner and the AWS SDK for JavaScript v3. The tests run without AWS credentials or network access.

Start with the Lambda event and handler model if those concepts are new. Unit tests assume you already know what event shape your function accepts.

Choose the right test boundary

Unit, local, and integration tests answer different questions. A mock can show that your code sends the expected command; it cannot prove that a deployed function has the right IAM permissions or that its trigger delivers the event you expect.

Test typeWhat it checksTypical setup
UnitOne handler or business-logic module in isolationCall the function directly and stub its dependencies
Local invocationFunction startup, runtime behavior, and event handling in a Lambda-like environmentRun the function with AWS SAM CLI; service calls may still reach real AWS resources
IntegrationTwo or more components working together, including AWS configurationDeploy to an isolated AWS test environment and exercise its resources

AWS defines unit tests as isolated tests of a code block, and recommends integration tests for interactions between your function and managed services. Use SAM CLI for local Lambda invocation when you need to exercise the runtime or event processing. Use a deployed test when you need evidence about IAM, triggers, networking, or service configuration; AWS end-to-end testing practices cover that wider boundary.

Build a handler that is easy to test

Keep the Lambda entry point thin. Put the event-specific work in a function that accepts the AWS client as a dependency. Production code passes a real SDK client; a unit test passes a fake. This avoids patching SDK internals and keeps the test independent of AWS credentials.

The example handles an HTTP API request for an order. Assume the DynamoDB table has a string partition key named orderId. Install the specific SDK v3 clients used by the function:

npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb

Node.js treats .mjs files as ECMAScript modules; a package.json with "type": "module" is another option for .js files. See the Node.js module guide. For Lambda, choose a currently supported Node.js runtime. The handler setting for index.mjs with an exported handler is index.handler; see AWS’s Node.js handler guide. The same explicit .mjs convention works for the test files below.

Put request logic in a factory

Create src/orders.mjs. The factory takes the client and table name, then returns an async handler:

import { GetCommand } from "@aws-sdk/lib-dynamodb";

export function createOrderHandler({ ddb, tableName }) {
  return async function handler(event) {
    const orderId = event?.pathParameters?.orderId;

    if (typeof orderId !== "string" || orderId.length === 0) {
      return json(400, { message: "orderId is required" });
    }

    const result = await ddb.send(
      new GetCommand({
        TableName: tableName,
        Key: { orderId },
      }),
    );

    if (!result.Item) {
      return json(404, { message: "Order not found" });
    }

    return json(200, result.Item);
  };
}

function json(statusCode, value) {
  return {
    statusCode,
    headers: { "content-type": "application/json" },
    body: JSON.stringify(value),
  };
}

The handler returns a JSON-string body in the HTTP response shape expected by an API Gateway HTTP API Lambda integration. The SDK v3 document client uses a command object and send(); it returns DynamoDB items as ordinary JavaScript values. See AWS’s DynamoDB document client guide.

Wire the production Lambda handler

At the project root, create index.mjs to construct the real client once outside the handler, then inject it. Set ORDERS_TABLE in the function’s environment and grant its execution role dynamodb:GetItem on that table:

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { createOrderHandler } from "./src/orders.mjs";

const tableName = process.env.ORDERS_TABLE;
if (!tableName) {
  throw new Error("ORDERS_TABLE must be set");
}

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));

export const handler = createOrderHandler({ ddb, tableName });

Bundle or package the SDK clients with the function so you control the versions used in deployment. Lambda Node.js runtimes include a version of SDK v3, but AWS recommends packaging the clients your code uses to avoid depending on a runtime-provided version that can vary.

Write the unit tests

Create test/orders.test.mjs. Node’s built-in node:test runner and node:assert/strict need no additional test framework. The test context’s mock.method() replaces and tracks a method, then restores it when that test finishes.

import assert from "node:assert/strict";
import { test } from "node:test";
import { GetCommand } from "@aws-sdk/lib-dynamodb";
import { createOrderHandler } from "../src/orders.mjs";

const orderEvent = {
  pathParameters: { orderId: "order-123" },
};

function fakeDdb() {
  return {
    async send() {
      throw new Error("Unexpected call in test");
    },
  };
}

test("returns an order and sends the expected DynamoDB command", async (t) => {
  const ddb = fakeDdb();
  const send = t.mock.method(ddb, "send", async (command) => {
    assert.ok(command instanceof GetCommand);
    assert.deepEqual(command.input, {
      TableName: "orders-test",
      Key: { orderId: "order-123" },
    });
    return { Item: { orderId: "order-123", status: "PAID" } };
  });
  const handler = createOrderHandler({ ddb, tableName: "orders-test" });

  const response = await handler(orderEvent);

  assert.equal(response.statusCode, 200);
  assert.deepEqual(JSON.parse(response.body), {
    orderId: "order-123",
    status: "PAID",
  });
  assert.equal(send.mock.callCount(), 1);
});

test("rejects an invalid request without calling DynamoDB", async (t) => {
  const ddb = fakeDdb();
  const send = t.mock.method(ddb, "send");
  const handler = createOrderHandler({ ddb, tableName: "orders-test" });

  const response = await handler({ pathParameters: {} });

  assert.equal(response.statusCode, 400);
  assert.deepEqual(JSON.parse(response.body), {
    message: "orderId is required",
  });
  assert.equal(send.mock.callCount(), 0);
});

test("returns 404 when the order does not exist", async (t) => {
  const ddb = fakeDdb();
  t.mock.method(ddb, "send", async () => ({}));
  const handler = createOrderHandler({ ddb, tableName: "orders-test" });

  const response = await handler(orderEvent);

  assert.equal(response.statusCode, 404);
  assert.deepEqual(JSON.parse(response.body), {
    message: "Order not found",
  });
});

test("lets an AWS client error reject the async handler", async (t) => {
  const ddb = fakeDdb();
  const failure = new Error("DynamoDB unavailable");
  const send = t.mock.method(ddb, "send", async () => {
    throw failure;
  });
  const handler = createOrderHandler({ ddb, tableName: "orders-test" });

  await assert.rejects(handler(orderEvent), (error) => error === failure);
  assert.equal(send.mock.callCount(), 1);
});

Run this exact test file with:

node --test test/orders.test.mjs

Await the handler and use assert.rejects() for failures. An unawaited promise can let a test finish before the behavior under test completes. Here, the final test confirms that a service error is not silently converted into a success response; if the application should map that error to a particular HTTP response, implement and test that policy explicitly.

Test the event shape for each trigger

The example above covers an HTTP API route with one path parameter. For another trigger, build a fixture from that integration’s event contract and test the behavior that matters to your function. Queue and stream handlers should cover multiple records and malformed records; scheduled handlers should cover the fields your code reads. A hand-written event checks your code against that fixture only; it does not prove that a deployed trigger sends the same shape.

Keep AWS service mocks at one boundary

The success case checks that the handler creates a GetCommand with the expected table and key before the fake returns an item. This verifies how the application uses the SDK boundary without mocking SDK internals. The fake does not exercise the SDK’s network request, DynamoDB marshalling, or service response handling.

Keep assertions focused on behavior

Give each case its own fake and expected data, await every async operation, assert observable behavior, and let the test framework clean up its mocks. Add cases for meaningful branches such as missing input, absent records, and service failures. Coverage reports can reveal unexecuted lines, but a high percentage alone does not show whether the assertions check useful behavior.

Run the same unit tests in CI

After installing dependencies from the committed lockfile, run node --test test/orders.test.mjs in continuous integration with the same Node.js major version used for development. Keep deployed integration tests in a separate step that provisions or targets an isolated AWS environment.

Debug failures at the boundary that failed

For a unit-test failure, read the assertion output first: it shows whether the input, SDK command, or returned value differed from the expectation. For a local or deployed failure, inspect the SAM output or the function’s CloudWatch Logs and confirm the event shape, environment variables, role permissions, and resource names for that environment.

Measure performance separately

A unit test with a mocked client does not measure Lambda startup, network latency, or AWS service performance. If invocation duration or cold-start behavior matters, measure it with a repeatable workload against the deployed runtime and resources.

FAQs

Can you unit test a Lambda function without deploying it?

Yes. Import a handler factory or business-logic function, pass controlled test dependencies, and call it with a representative event, as the example above does.

Keep unit tests small and specific

A slim handler factory and a controlled dependency make Lambda code easier to test and change. Start with assertions on the event, command, and result that define the function’s behavior.