Unit Testing AWS Lambda Functions Without a Framework
I recently added 151 unit tests to a serverless push notification backend. No Jest, no vitest — just Node’s built-in test runner, tsx, and sinon. The process surfaced some rough edges around how Lambda functions initialize, how modules load, and what happens when your test runner compiles your imports behind your back.
The easy stuff: pure functions
Business logic that never touches AWS is free real estate. Schema validation, string parsing, data transformation — zero mocking needed. We got over 100 tests from Zod schemas, a token classification function, and a handful of payload formatters before writing a single stub.
import { isInvalidToken } from "./token-verification";
it("marks expired token as invalid", () => {
assert.equal(isInvalidToken(apnsStatus("ExpiredToken")), true);
});
That was the easy part. Then I hit the module loading iceberg.
Lambda functions initialize clients at import time
Every handler in this codebase does the same thing — creates its AWS SDK clients at the top of the file:
const dynamo = new DynamoDBClient({ region: requireEnv("AWS_REGION") });
This causes two problems the moment you try to test anything. First, requireEnv() throws if environment variables aren’t set, so you need process.env populated before the module even loads. Second, tsx compiles export function to CJS with immutable property descriptors, so sinon.stub(module, 'saveRegistration') throws outright.
Imports hoist. You can’t fix env vars before them.
This one is obvious in hindsight but I stared at a red terminal for a while:
process.env.AWS_REGION = "us-east-1"; // I thought this runs first
import { handler } from "./handler"; // nope — imports hoist above everything
ES module import statements are evaluated before any other code in the file. By the time my process.env assignment runs, the handler module already loaded and crashed on the missing variable. The fix: use require() for anything that depends on runtime state. require() respects execution order.
Stubbing functions when the exports are locked down
After tsx compiles your code, exported functions become unwritable CJS properties. Sinon can’t touch them. I tried the obvious approaches and they all failed.
What worked was reaching directly into require.cache before the handler module loaded:
const dynamoPath = require.resolve("./dynamo");
require("./dynamo"); // force it into the cache
require.cache[dynamoPath].exports = {
...originalExports,
saveRegistration: sinon.stub().resolves(),
};
const { handleUpdateSubscription } = require("./handlers");
When the handler later does require("./dynamo"), Node returns the already-cached module — with my stubs in place. It feels like a hack because it is a hack, but it works reliably.
Module-local clients need prototype stubs
If a client is declared as a local variable inside a module, you can’t swap it — there’s no export to intercept. The way around this is stubbing the class prototype before the module loads:
sinon.stub(DynamoDBClient.prototype, "send");
const { getSubscriptionCounts } = require("./subscription-counts");
The module’s new DynamoDBClient(...) inherits from the stubbed prototype, so send resolves to your stub.
Coverage without dependencies
Node 22+ ships --experimental-test-coverage. It prints a plain text table of line, branch, and function coverage straight to stdout. I piped that into $GITHUB_STEP_SUMMARY and every workflow run got a coverage report — no Codecov account, no third-party dependency.
The whole exercise reminded me that the hard part of testing Lambda functions isn’t the test framework. It’s understanding when your modules actually load, what the bundler does to your exports, and how Node’s module cache works. Once that clicks, everything else follows.