DEV Community

Cover image for Lambda unit tests with Vitest: test TypeScript handlers
Sascha Mirow-Stanitzok
Sascha Mirow-Stanitzok

Posted on Originally published at sascha-ms.de

Lambda unit tests with Vitest: test TypeScript handlers

Lambda unit tests are the cheapest insurance you can buy in a serverless project. A test runs in milliseconds, costs nothing and tells you right away whether your handler answers a broken event with a 400 or with a stack trace. Still, I keep seeing Lambda functions that are only “tried out” in the console with a test event. That works until someone rewrites a condition and nobody notices that double bookings suddenly slip through.

In this article I build a small project for a fictional bike rental with you, from scratch: an HTTP handler that reserves bikes and an SQS handler that sends reminders. We test both with Vitest and aws-sdk-client-mock, without an AWS account and without Docker. At the end you have a template you can copy into your next project.

In short

With Vitest and aws-sdk-client-mock you test Lambda handlers in TypeScript quickly and without AWS access, by replacing the SDK clients and passing in time, IDs and table names from the outside. The key insight: a thin handler with injected dependencies is easier to test than any mocking trick. The biggest pitfall is believing that green unit tests also check IAM permissions and configuration.

What you will learn in this article

  • Set up Vitest for TypeScript Lambdas, including types for AWS events
  • Replace and verify DynamoDB and SNS calls with aws-sdk-client-mock
  • Shape handlers so that time and IDs are fixed in tests
  • Test SQS partial failures (batchItemFailures) with event files
  • Recognize what unit tests cannot cover

What Lambda unit tests can and cannot do

A unit test checks a small piece of logic in isolation. For Lambda that means: you call the handler function directly in Node.js, pass it an event you built yourself and look at the response. Everything that leaves your code, such as DynamoDB, SNS or an HTTP partner, is replaced by a mock. A mock is a stand-in that pretends to be the real service and remembers how it was called.

AWS itself describes a clear division of labor in its guide to testing serverless functions: mocks are good for fast tests of business logic with many variations and for failure cases that are hard to trigger in the cloud. They do not check IAM policies, quotas or configuration, though. That is why a serverless application always needs tests against real resources in a separate environment as well.

Pyramid for Lambda unit tests: many unit tests with mocks at the bottom, integration tests above, few end-to-end tests on top

Many fast unit tests locally, few integration and end-to-end tests in the cloud.

Where unit tests pay off most

My rule of thumb: wherever your code makes decisions. Validating input, choosing status codes, translating errors, reporting partial failures in a batch. Those are exactly the spots that break during refactorings, and in practice that is often where tests are missing. Pure wiring, such as “event in, item into DynamoDB”, is better tested once in the cloud, because that is where the real permissions apply.

Prerequisites

  • Node.js 22 (the examples were verified with Node.js 22.23)
  • Basic TypeScript knowledge and a first Lambda function, for example from my article Lambda Powertools for TypeScript
  • No AWS account for the tests. Only the deployment at the end needs credentials and Serverless Framework v4.

Step 1: Set up a project for Lambda unit tests

Create a folder and install the packages. The AWS SDK clients are runtime dependencies, everything for testing goes into devDependencies.

mkdir bike-rental && cd bike-rental
npm init -y
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb @aws-sdk/client-sns
npm install --save-dev vitest typescript @types/node@22 @types/aws-lambda aws-sdk-client-mock aws-sdk-client-mock-vitest
Enter fullscreen mode Exit fullscreen mode

A quick word on the roles: aws-sdk-client-mock replaces the send method of the SDK v3 clients. The add-on package aws-sdk-client-mock-vitest provides matching assertions such as toHaveReceivedCommandWith. Make sure its major version fits your Vitest version. Version 8 belongs to Vitest 5, and the README lists the counterparts for older Vitest versions.

Next you need three small files. In package.json you switch the project to ES modules and add the scripts:

{
  "type": "module",
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "typecheck": "tsc --noEmit"
  }
}
Enter fullscreen mode Exit fullscreen mode

tsconfig.json enables strict types and loads the extra matcher types so your editor knows toHaveReceivedCommandWith:

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "types": ["node", "aws-sdk-client-mock-vitest/extend"]
  },
  "include": ["src", "tests", "vitest.config.ts"]
}
Enter fullscreen mode Exit fullscreen mode

The Vitest configuration loads a setup file and cleans up spies and environment variables after every test:

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'node',
    setupFiles: ['tests/setup.ts'],
    // undo vi.fn/vi.spyOn and vi.stubEnv after every test
    restoreMocks: true,
    unstubEnvs: true,
  },
});
Enter fullscreen mode Exit fullscreen mode
// tests/setup.ts
import 'aws-sdk-client-mock-vitest/extend';
Enter fullscreen mode Exit fullscreen mode

Step 2: Shape the handler for testing

Before writing the first test, it is worth looking at the code itself. A handler that calls new Date() and randomUUID() somewhere in the middle returns something different on every run. That makes tests needlessly flaky. So I pass such things in from the outside as dependencies. The pattern is called dependency injection and is less complicated than it sounds: a factory function receives everything the handler needs and returns the actual handler.

Our reservation handler validates the request, rejects start times in the past and writes the reservation to DynamoDB with a condition. If the time slot is already taken, DynamoDB throws a ConditionalCheckFailedException and the handler answers with 409.

// src/reserve-bike.ts
import { ConditionalCheckFailedException, DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
import type { APIGatewayProxyEventV2, APIGatewayProxyStructuredResultV2 } from 'aws-lambda';
import { randomUUID } from 'node:crypto';

export interface ReserveDeps {
  db: DynamoDBDocumentClient;
  tableName: string;
  now: () => Date;
  newId: () => string;
}

interface ReservationRequest {
  bikeId: string;
  customerId: string;
  startsAt: string;
}

const json = (statusCode: number, body: unknown): APIGatewayProxyStructuredResultV2 => ({
  statusCode,
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify(body),
});

export function parseRequest(raw: string | undefined): ReservationRequest | null {
  if (!raw) return null;
  let data: unknown;
  try {
    data = JSON.parse(raw);
  } catch {
    return null;
  }
  const d = data as Partial<ReservationRequest>;
  const isText = (v: unknown): v is string => typeof v === 'string' && v.trim().length > 0;
  if (!isText(d.bikeId) || !isText(d.customerId) || !isText(d.startsAt)) return null;
  if (Number.isNaN(Date.parse(d.startsAt))) return null;
  return { bikeId: d.bikeId, customerId: d.customerId, startsAt: d.startsAt };
}

export function createHandler(deps: ReserveDeps) {
  return async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyStructuredResultV2> => {
    const req = parseRequest(event.body);
    if (!req) return json(400, { message: 'bikeId, customerId and startsAt (ISO date) are required' });

    if (Date.parse(req.startsAt) <= deps.now().getTime()) {
      return json(400, { message: 'startsAt must be in the future' });
    }

    const reservationId = deps.newId();
    try {
      await deps.db.send(
        new PutCommand({
          TableName: deps.tableName,
          Item: {
            pk: `BIKE#${req.bikeId}`,
            sk: `SLOT#${req.startsAt}`,
            reservationId,
            customerId: req.customerId,
            createdAt: deps.now().toISOString(),
          },
          // a slot can only be booked once
          ConditionExpression: 'attribute_not_exists(pk)',
        }),
      );
    } catch (err) {
      if (err instanceof ConditionalCheckFailedException) {
        return json(409, { message: 'This bike is already booked for that time' });
      }
      throw err; // unknown errors: let Lambda report them
    }

    return json(201, { reservationId });
  };
}

// production wiring: created once per execution environment
export const handler = createHandler({
  db: DynamoDBDocumentClient.from(new DynamoDBClient({})),
  tableName: process.env.TABLE_NAME ?? '',
  now: () => new Date(),
  newId: () => randomUUID(),
});
Enter fullscreen mode Exit fullscreen mode

Two details matter to me. First, the actual Lambda export handler stays a single line of wiring. Second, the handler only catches the error it really understands. Everything else is thrown on, so Lambda reports the invocation as failed and your alarms go off. AWS recommends exactly this split in its testing guide: the handler is a thin adapter, and the logic can be checked without a Lambda environment.

For input validation I deliberately use a small function without a library here, to keep the example easy to follow. In real projects I reach for Zod, as described in Zod in Lambda.

Step 3: Write Lambda unit tests with Vitest and mocks

Now it gets concrete. The test creates a DocumentClient, attaches a mock to it with mockClient and builds the handler with a fixed time and a fixed ID. Before each test, dbMock.reset() clears behavior and call history so tests do not influence each other.

// tests/reserve-bike.test.ts
import { ConditionalCheckFailedException, DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
import type { APIGatewayProxyEventV2 } from 'aws-lambda';
import { mockClient } from 'aws-sdk-client-mock';
import { beforeEach, describe, expect, it } from 'vitest';
import { createHandler, parseRequest } from '../src/reserve-bike.js';

const db = DynamoDBDocumentClient.from(new DynamoDBClient({ region: 'eu-central-1' }));
const dbMock = mockClient(db);

// fixed time and id: every run produces the same result
const handler = createHandler({
  db,
  tableName: 'bike-reservations-test',
  now: () => new Date('2030-04-30T12:00:00.000Z'),
  newId: () => 'res-123',
});

// only the fields the handler really reads
const apiEvent = (body: unknown): APIGatewayProxyEventV2 =>
  ({ body: typeof body === 'string' ? body : JSON.stringify(body) }) as APIGatewayProxyEventV2;

const validBody = { bikeId: 'b-42', customerId: 'cust-7', startsAt: '2030-05-01T09:00:00.000Z' };

beforeEach(() => {
  dbMock.reset();
});

describe('parseRequest', () => {
  it.each([
    ['no body', undefined],
    ['invalid JSON', '{bikeId:'],
    ['missing customerId', JSON.stringify({ bikeId: 'b-42', startsAt: '2030-05-01T09:00:00Z' })],
    ['date is not a date', JSON.stringify({ ...validBody, startsAt: 'tomorrow' })],
  ])('rejects %s', (_label, raw) => {
    expect(parseRequest(raw)).toBeNull();
  });
});

describe('reserve bike handler', () => {
  it('stores the reservation and answers 201', async () => {
    dbMock.on(PutCommand).resolves({});

    const res = await handler(apiEvent(validBody));

    expect(res.statusCode).toBe(201);
    expect(JSON.parse(res.body ?? '')).toEqual({ reservationId: 'res-123' });
    expect(dbMock).toHaveReceivedCommandOnce(PutCommand);
    expect(dbMock).toHaveReceivedCommandWith(PutCommand, {
      TableName: 'bike-reservations-test',
      Item: expect.objectContaining({
        pk: 'BIKE#b-42',
        sk: 'SLOT#2030-05-01T09:00:00.000Z',
        createdAt: '2030-04-30T12:00:00.000Z',
      }),
      ConditionExpression: 'attribute_not_exists(pk)',
    });
  });

  it('answers 409 when the slot is already taken', async () => {
    dbMock.on(PutCommand).rejects(
      new ConditionalCheckFailedException({ message: 'The conditional request failed', $metadata: {} }),
    );

    const res = await handler(apiEvent(validBody));

    expect(res.statusCode).toBe(409);
  });

  it('answers 400 for a start time in the past and never calls DynamoDB', async () => {
    const res = await handler(apiEvent({ ...validBody, startsAt: '2030-04-01T09:00:00.000Z' }));

    expect(res.statusCode).toBe(400);
    expect(dbMock).not.toHaveReceivedCommand(PutCommand);
  });

  it('passes unknown errors on to Lambda', async () => {
    dbMock.on(PutCommand).rejects(new Error('Throttled'));

    await expect(handler(apiEvent(validBody))).rejects.toThrow('Throttled');
  });
});
Enter fullscreen mode Exit fullscreen mode

What exactly the tests check

The first block uses it.each to check several broken inputs with a single test. That is handy for validation, because you add new cases as one line. The success case checks two things: the HTTP response and the command that would have reached DynamoDB. Especially the second part catches mistakes you would otherwise only notice in the cloud, such as a forgotten ConditionExpression.

In the 409 case the mock fails the call with exactly the exception DynamoDB throws when a condition is violated. The test for a start time in the past also makes sure DynamoDB is never called. And the last test shows that unknown errors are not swallowed.

Mock an instance or a class?

mockClient accepts an instance or a class. With an instance, the mock only affects that exact object, which fits our injected client well. If you pass the class, for example mockClient(SNSClient), every instance is mocked, even one created deep inside a module. We need that in the second example. According to the README, you should create class mocks last when you mix both variants.

Run the tests with:

npm test
Enter fullscreen mode Exit fullscreen mode

Expected result:

 Test Files  2 passed (2)
      Tests  10 passed (10)
Enter fullscreen mode Exit fullscreen mode

Step 4: Test an SQS handler with partial failures

The second handler reads reminders from an SQS queue and publishes them via SNS. Lambda receives up to ten messages per invocation from SQS. If one of them fails and the handler throws, by default the whole batch returns to the queue, including the successful messages. With partial batch responses (setting ReportBatchItemFailures) you report back only the IDs of the failed messages instead.

// src/send-reminders.ts
import { PublishCommand, SNSClient } from '@aws-sdk/client-sns';
import type { SQSBatchResponse, SQSEvent } from 'aws-lambda';

const sns = new SNSClient({});

interface Reminder {
  reservationId: string;
  customerId: string;
  startsAt: string;
}

export const handler = async (event: SQSEvent): Promise<SQSBatchResponse> => {
  const batchItemFailures: SQSBatchResponse['batchItemFailures'] = [];

  for (const record of event.Records) {
    try {
      const reminder = JSON.parse(record.body) as Reminder;
      await sns.send(
        new PublishCommand({
          TopicArn: process.env.TOPIC_ARN,
          Subject: 'Your bike is waiting',
          Message: `Reservation ${reminder.reservationId} starts at ${reminder.startsAt}.`,
          MessageAttributes: {
            customerId: { DataType: 'String', StringValue: reminder.customerId },
          },
        }),
      );
    } catch {
      // only this message goes back to the queue, the rest counts as done
      batchItemFailures.push({ itemIdentifier: record.messageId });
    }
  }

  return { batchItemFailures };
};
Enter fullscreen mode Exit fullscreen mode

Here the module creates its own SNS client. No problem: we simply mock the class. For the event I use a JSON file under tests/events/ instead of building it inside the test. You can reuse event files like this later for sam local invoke or console test events. One of the two messages deliberately contains broken JSON.

// tests/events/sqs-two-records.json (shortened, the full file is in the repository)
{
  "Records": [
    {
      "messageId": "msg-ok-1",
      "body": "{\"reservationId\":\"res-1\",\"customerId\":\"cust-7\",\"startsAt\":\"2030-05-01T09:00:00.000Z\"}",
      "eventSource": "aws:sqs"
    },
    {
      "messageId": "msg-broken-2",
      "body": "this is not json",
      "eventSource": "aws:sqs"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode
// tests/send-reminders.test.ts
import { PublishCommand, SNSClient } from '@aws-sdk/client-sns';
import type { SQSEvent } from 'aws-lambda';
import { mockClient } from 'aws-sdk-client-mock';
import { readFileSync } from 'node:fs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { handler } from '../src/send-reminders.js';

// mocks every SNSClient instance, including the one inside the module
const snsMock = mockClient(SNSClient);

const loadEvent = (name: string): SQSEvent =>
  JSON.parse(readFileSync(new URL(`./events/${name}`, import.meta.url), 'utf8')) as SQSEvent;

beforeEach(() => {
  snsMock.reset();
  vi.stubEnv('TOPIC_ARN', 'arn:aws:sns:eu-central-1:123456789012:bike-reminders');
});

describe('send reminders handler', () => {
  it('reports only the broken message as failed', async () => {
    snsMock.on(PublishCommand).resolves({ MessageId: 'sns-1' });

    const res = await handler(loadEvent('sqs-two-records.json'));

    expect(res.batchItemFailures).toEqual([{ itemIdentifier: 'msg-broken-2' }]);
    expect(snsMock).toHaveReceivedCommandTimes(PublishCommand, 1);
    expect(snsMock).toHaveReceivedCommandWith(PublishCommand, {
      TopicArn: 'arn:aws:sns:eu-central-1:123456789012:bike-reminders',
      Message: expect.stringContaining('res-1'),
    });
  });

  it('returns every message when SNS is down', async () => {
    snsMock.on(PublishCommand).rejects(new Error('Service unavailable'));

    const res = await handler(loadEvent('sqs-two-records.json'));

    expect(res.batchItemFailures.map((f) => f.itemIdentifier)).toEqual(['msg-ok-1', 'msg-broken-2']);
  });
});
Enter fullscreen mode Exit fullscreen mode

The first test expects exactly one failed message and exactly one SNS call. The second simulates an SNS outage, a scenario you can only reproduce in the cloud with some effort. Mocks are ideal for failure cases like this. If you would rather not write the partial failure logic yourself, the Batch Processor from Powertools for AWS Lambda does that work for you. You should still test the behavior.

🎬 Flow of a Lambda unit test with Vitest – this animated diagram only runs on my site: watch it on sascha-ms.de

Step 5: Deploy with Serverless Framework

For the functions to actually run, we need the infrastructure. I use Serverless Framework v4 here, which bundles TypeScript handlers with esbuild during deployment. The important line is functionResponseType: ReportBatchItemFailures. Without it, Lambda ignores the returned batchItemFailures, and your nice test checks something that never takes effect in production.

# serverless.yml
service: bike-rental-reservations
frameworkVersion: '4'

provider:
  name: aws
  runtime: nodejs22.x
  architecture: arm64
  region: eu-central-1
  environment:
    TABLE_NAME: !Ref ReservationsTable
    TOPIC_ARN: !Ref RemindersTopic
  iam:
    role:
      statements:
        - Effect: Allow
          Action: dynamodb:PutItem
          Resource: !GetAtt ReservationsTable.Arn
        - Effect: Allow
          Action: sns:Publish
          Resource: !Ref RemindersTopic

functions:
  reserveBike:
    handler: src/reserve-bike.handler
    events:
      - httpApi:
          method: POST
          path: /reservations
  sendReminders:
    handler: src/send-reminders.handler
    events:
      - sqs:
          arn: !GetAtt RemindersQueue.Arn
          batchSize: 10
          functionResponseType: ReportBatchItemFailures

resources:
  Resources:
    ReservationsTable:
      Type: AWS::DynamoDB::Table
      Properties:
        BillingMode: PAY_PER_REQUEST
        AttributeDefinitions:
          - AttributeName: pk
            AttributeType: S
          - AttributeName: sk
            AttributeType: S
        KeySchema:
          - AttributeName: pk
            KeyType: HASH
          - AttributeName: sk
            KeyType: RANGE
    RemindersDlq:
      Type: AWS::SQS::Queue
      Properties:
        MessageRetentionPeriod: 1209600
    RemindersQueue:
      Type: AWS::SQS::Queue
      Properties:
        VisibilityTimeout: 60
        RedrivePolicy:
          deadLetterTargetArn: !GetAtt RemindersDlq.Arn
          maxReceiveCount: 3
    RemindersTopic:
      Type: AWS::SNS::Topic
Enter fullscreen mode Exit fullscreen mode

The everyday order is simple: test first, then deploy. In a pipeline it looks like this:

npm ci
npm run typecheck
npm test
npx serverless deploy --stage dev
Enter fullscreen mode Exit fullscreen mode

I described how to deploy from GitHub Actions without stored access keys in Serverless Framework with GitHub Actions. About costs: the tests themselves cost nothing. DynamoDB in on-demand mode, SQS, SNS and Lambda bill per use, so a practice project typically stays in the cent range. Still, remove the stack with npx serverless remove when you are done.

Common mistakes in Lambda unit tests

Mocks that survive between tests

If you forget reset() in beforeEach, the third test still sees the calls of the second. Then toHaveReceivedCommandOnce suddenly checks something other than you think. Environment variables behave similarly: vi.stubEnv does not reset values on its own, which is why unstubEnvs: true is in the configuration.

Reading environment variables at import time

If your module reads process.env.TABLE_NAME into a constant while loading, a later vi.stubEnv comes too late. Either read the variable during the invocation, as in the SNS example, or pass it in as a dependency, as in the reservation handler.

Mocking too much

If you replace every helper of your own with vi.mock, in the end you only test whether mocks call mocks. Mock the boundary to the outside world, meaning the AWS clients, and let your own logic run for real. That keeps the tests stable even when you refactor internally.

Green does not mean deployable

A forgotten dynamodb:PutItem in the IAM role, a wrong queue ARN or a timeout that is too short will not show up in any unit test. That is not a flaw of Vitest, it is simply how mocks work. So add a few integration tests that send real requests to a test environment after deployment.

Treating FIFO queues like standard queues

With FIFO queues you must not simply carry on after the first failure. According to the AWS documentation, you then have to report the failed message and all unprocessed messages, otherwise the order gets mixed up. Write a separate test that checks exactly this order.

Measure coverage without fooling yourself

Vitest measures test coverage with the add-on package @vitest/coverage-v8:

npm install --save-dev @vitest/coverage-v8
npx vitest run --coverage
Enter fullscreen mode Exit fullscreen mode

In our example, the reservation handler ends up at just over 90 percent. Not covered are, of all things, the two lines of real wiring: new Date() and randomUUID(). I am perfectly fine with that. A percentage hints at where tests might be missing, but it is not a quality goal. I would rather check whether every decision in the code has at least one test that breaks when I flip it.

My take

I consider Lambda functions one of the most rewarding places to start with tests. They are small, have clear inputs and outputs, and events can be stored as JSON files. Skipping tests here means leaving a lot on the table.

At the same time, I disagree with the idea that you can fully secure a serverless application locally. According to AWS, emulators often lag behind the real services, and mocks know nothing about IAM. My split therefore looks like this: many fast unit tests for decisions and error paths, a few integration tests in a separate cloud environment for permissions and wiring. For Step Functions workflows, the TestState API joins the mix.

One more thought on AI assistants: Copilot or Claude write tests like these in seconds now. That helps if you decide upfront which cases should be checked. Do not just ask for “tests for this file”. Otherwise you get tests that take a snapshot of the current code instead of pinning down the desired behavior.

FAQ

Vitest or Jest for AWS Lambda?

Both work. Vitest understands TypeScript and ES modules without extra transformers, starts quickly and has a Jest-like API. For new TypeScript projects I therefore pick Vitest. You do not have to migrate existing Jest suites.

Do I need LocalStack for Lambda unit tests?

No. Unit tests replace the AWS clients with mocks and run without a network. Emulators like LocalStack are an intermediate step for integration tests, but AWS advises using them sparingly and testing in the cloud as well.

How do I test that my Lambda sends the right DynamoDB command?

With aws-sdk-client-mock and the toHaveReceivedCommandWith matcher. It lets you check table name, keys and conditions of the command without a real table.

Does aws-sdk-client-mock work with AWS SDK v2?

No, the library is built for the modular AWS SDK for JavaScript v3. SDK v2 is no longer developed, so switching pays off anyway.

You can protect the infrastructure around your handlers without an AWS account as well. I show how that works with template assertions in the article Test CDK stacks with Vitest.

Your next step

Pick an existing Lambda function and write exactly one test: the failure case you fear most. Usually that is a broken event or a third-party system that does not answer. Once that test is in place, the others almost follow on their own. Which Lambda function in your project has no test at all so far, and why is that?

Sources

This post was created with AI assistance.


👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser.

📬 Stay updated: New articles on serverless, AI and leading tech teams via RSS, browser push or the weekly newsletter (in German).

Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)