AWS Lambda Unit Testing: A Practical Guide for Node.js
Published on · Updated on
Rebuilt the guide around Node.js handler tests, injected dependencies, realistic event fixtures, SQS partial failures, and the limits of local tests.
A useful AWS Lambda unit test calls your handler or application logic with a representative event and controlled dependencies. It checks input handling, business rules, and error responses without calling AWS.
This guide uses Node.js and an Amazon SQS event to show the approach. AWS distinguishes mock tests, local emulation, and cloud tests in its Lambda testing guidance. For complete workflows in a deployed environment, see our guide to end-to-end testing AWS applications.
What a Lambda unit test can prove
A Lambda function is ordinary application code behind a handler. Lambda passes the handler an event object and, optionally, a context object. The event's structure depends on the source: API Gateway, Amazon S3, EventBridge, and Amazon SQS do not send interchangeable payloads.
A unit test can call the handler directly with a JSON fixture and replace effects such as database writes, HTTP requests, or SDK calls with controlled test doubles. It can establish that your code interprets the fields it uses and returns the result you expect for that input.
| Test layer | What it checks | What it still cannot establish |
|---|---|---|
| Unit test | Application logic and handler behavior with fakes | AWS service configuration, deployed permissions, trigger delivery, or runtime packaging |
| Local emulation | How code runs under a local Lambda-like runtime or with a simulated service | That the deployed AWS integration, IAM role, and event source mapping work |
| Deployed integration or end-to-end test | Real service connections and configured workflows in an isolated AWS environment | Every production condition, failure mode, or traffic level |
Separate the handler from its dependencies
Keep the event adapter small: read the fields your function needs, call application code, and translate results into the response expected by the trigger. Pass side-effecting operations into a handler factory. That lets a unit test supply a fake function instead of importing real AWS infrastructure or making network calls.
This example handles records from a standard SQS queue. It extracts an order ID from each record body, calls an injected function, and reports records that could not be processed. The production implementation of saveOrder could use an AWS SDK client; the test supplies an in-memory fake.
At startup, compose the factory with the concrete saveOrder operation and export the returned handler. The tests import the factory directly and avoid the production adapter.
export function createHandler({ saveOrder }) {
return async function handler(event) {
if (!Array.isArray(event?.Records)) {
throw new TypeError("Expected an SQS Records array");
}
const batchItemFailures = [];
for (const record of event.Records) {
try {
const payload = JSON.parse(record.body);
if (typeof payload.orderId !== "string" || payload.orderId.length === 0) {
throw new TypeError("orderId is required");
}
await saveOrder(payload.orderId);
} catch {
batchItemFailures.push({ itemIdentifier: record.messageId });
}
}
return { batchItemFailures };
};
}
The example catches an error per record so one bad item does not cause the handler invocation itself to throw. The returned batchItemFailures value only has the intended effect when the SQS event source mapping enables ReportBatchItemFailures. Without that setting, Lambda does not use the partial response to retry individual messages. A thrown exception marks the full batch as failed.
In production, record the failed message ID and safe error context so you can diagnose repeated failures. Avoid logging message bodies when they may contain sensitive data.
For a FIFO queue, stop processing after the first failed record and include that record plus every unprocessed record in batchItemFailures; continuing after a failure can violate ordering. AWS explains both behaviors in its SQS error-handling documentation. SQS can deliver a message more than once, so code that writes data or performs another lasting effect should also be safe to retry.
Write tests with controlled fakes
For Node.js projects using a compatible runtime, the built-in node:test runner and node:assert module are enough for this example; no test-framework dependency is required. Run the file with node --test. If your project already uses Jest, Vitest, or another runner, keep using its established conventions.
A fixture needs the fields your code reads. In this example, each message body is itself a JSON string, while messageId is used to identify failed records in the partial response.
import test from "node:test";
import assert from "node:assert/strict";
import { createHandler } from "./handler.mjs";
test("passes the order ID from an SQS record to the application", async () => {
const saved = [];
const handler = createHandler({
saveOrder: async (orderId) => saved.push(orderId),
});
const response = await handler({
Records: [
{
messageId: "message-1",
body: JSON.stringify({ orderId: "order-42" }),
},
],
});
assert.deepEqual(saved, ["order-42"]);
assert.deepEqual(response, { batchItemFailures: [] });
});
test("reports the message ID when processing fails", async () => {
const saved = [];
const handler = createHandler({
saveOrder: async (orderId) => {
saved.push(orderId);
if (orderId === "order-bad") throw new Error("storage unavailable");
},
});
const response = await handler({
Records: [
{
messageId: "message-1",
body: JSON.stringify({ orderId: "order-good" }),
},
{
messageId: "message-2",
body: JSON.stringify({ orderId: "order-bad" }),
},
],
});
assert.deepEqual(saved, ["order-good", "order-bad"]);
assert.deepEqual(response, {
batchItemFailures: [{ itemIdentifier: "message-2" }],
});
});
The tests verify a successful record and the failure response for one record in a batch. They do not call SQS, wait for a retry, or prove that a successfully processed order is not duplicated after redelivery. Add tests for business-relevant boundaries—such as malformed bodies, missing identifiers, duplicate delivery, or a downstream failure—when those cases matter to your function.
Keep assertions focused on decisions and externally visible results. A test that duplicates every internal step can make harmless refactors painful, while a test that only asserts “no exception” may miss an incorrect response or an omitted side effect.
Use coverage to find gaps, not set a magic target
There is no universal coverage percentage that makes a Lambda safe. Use a coverage report to locate untested code, then judge whether important behavior has meaningful assertions.
Create realistic event fixtures
Store representative JSON under a test fixture directory, or build a small fixture in the test when that is clearer. Copy the relevant shape from AWS's documentation for the integration you use, then remove fields your code does not inspect only if the fixture still represents the contract under test.
- Amazon SQS: the event has a
Recordsarray; each record includes a message ID and a body string. The message body is not automatically parsed as JSON. - Amazon S3: normal notifications use a
Recordsarray, and object keys are URL encoded. S3'ss3:TestEventhas a different top-level shape, so handle it separately if your function may receive it. See the S3 event message structure. - API Gateway: match the configured payload format and test the fields your handler reads, including headers, path parameters, and a possibly encoded body when relevant.
- EventBridge: assert the event source, detail type, and fields under
detailthat drive your logic.
Do not use aws s3api put-bucket-notification-configuration to create a unit-test fixture. That command changes a real bucket's notification configuration; it does not generate a local JSON file. Keep unit-test inputs as ordinary test data. Use an isolated AWS account or stack when you need to verify that S3 actually sends the configured notification to a deployed function.
When you change a trigger's payload format or add new business behavior, update the fixture and the focused assertions together. Avoid one huge “realistic” event shared by unrelated tests: smaller fixtures show which fields matter and make failures easier to understand.
Mock the current SDK at a clear boundary
aws-sdk-mock targets the AWS SDK for JavaScript v2; its documentation describes it as best suited to v2. AWS ended v2 support on September 8, 2025, and recommends v3 for new work. The modular v3 clients use service command objects and client.send(command), so a v2 mock is not a drop-in way to intercept v3 calls. See AWS's SDK v2 end-of-support notice and the SDK v3 request pattern.
Prefer injecting a small application operation such as saveOrder when you are testing business logic. When the behavior you need to verify is the SDK adapter itself, replace its send function with a fake and assert the command and input, or use a library that explicitly supports v3 clients and commands, such as aws-sdk-client-mock. Its documentation shows command-level stubbing. Keep the mock behavior scoped to the test and reset it between tests so results do not leak across cases.
In deployed code, initialize reusable SDK clients outside the handler so Lambda can reuse them when it reuses an execution environment. Do not put per-request data in mutable module-level state. AWS describes both practices in its Lambda best practices.
Choose local or cloud tests for the question
A local emulator such as AWS SAM can run a function locally with a Lambda-like runtime and a supplied event. This is useful for checking packaging, runtime behavior, and the handler against an event fixture. A mock or emulator does not reproduce every managed service behavior or validate deployed IAM and trigger configuration. AWS makes that boundary explicit in its testing guidance and explains local testing with SAM.
Use a deployed test when the question depends on AWS wiring: for example, whether a queue invokes the function, whether its role can access a table, or whether an S3 notification reaches the correct alias. Keep cloud tests isolated from production data and resources, and clean up what each run creates. The Lambda deployment walkthrough shows a first deployed console invocation.
Cold-start latency, scaling, permission policy correctness, and deployed trigger delivery are different questions from unit behavior. Measure performance with an appropriate deployed workload, review IAM and infrastructure configuration directly, and reserve unit tests for deterministic code behavior.
Run focused tests in CI
Run unit tests on each change and make a failing test fail the job. Use coverage output to locate code that tests do not reach, not as a substitute for reviewing what the assertions prove. Run local emulation or deployed integration checks at the stage that has the required runtime, service access, and isolated AWS resources.
Keep cloud credentials and account configuration out of unit-test setup. If a unit suite unexpectedly needs AWS credentials, find the import or setup path that creates a real client call and move that effect behind an injected boundary. This keeps the fast suite repeatable and makes its limits clear.
A practical checklist
- Call the handler with fixtures that match the trigger format and the fields the code consumes.
- Inject database, HTTP, clock, and AWS service operations so unit tests can use controlled fakes.
- Cover success, validation boundaries, and meaningful downstream failures; await asynchronous work before asserting.
- For SQS partial failures, test returned message IDs and configure
ReportBatchItemFailureson the event source mapping. For FIFO queues, stop at the first failure and include unprocessed records. - Use SAM or another emulator for useful local runtime checks, then use isolated deployed tests for AWS wiring, IAM, and real service behavior.
- Use the current AWS SDK for JavaScript v3 in new Node.js work, and choose SDK mocks that understand v3 clients and commands.
The strongest test plan is the one that assigns each question to the right layer: unit tests for code decisions, local tools for runtime feedback, and deployed tests for AWS configuration and integration. Add only the checks needed to provide evidence for the behavior your function must deliver.