Skip to main content

Command Palette

Search for a command to run...

How I Built a Serverless Testing Library That Cuts Test Setup by 90%

Published
8 min read
How I Built a Serverless Testing Library That Cuts Test Setup by 90%

Every Lambda test starts the same way: you need an event object — and crafting one is annoying. API Gateway v2 events have 30+ fields, SQS needs message IDs, receipt handles, and ARNs, and DynamoDB Streams expect marshaled AttributeValue maps. The usual options are copy‑pasting a 60‑line JSON fixture or spending 20 minutes hand‑crafting one from memory.

I built @sls-testing to stop that. It provides typed, composable one‑line builders that give sensible defaults, automatic marshaling, and easy overrides so your tests only express what matters.

The payoff: what used to be a 30–60 line fixture becomes a single builder call — cutting test setup by roughly 90%. Below, I’ll show before/after examples, the API surface, and how it handles common event types (API Gateway, SQS, S3, DynamoDB Streams).

Here's what the before/after looks like.

The Problem: 60 Lines to Say "POST /users"

Testing a Lambda handler behind API Gateway v2 requires an APIGatewayProxyEventV2 object. Here's the minimum viable event most teams copy around:

const event = {
  version: '2.0',
  routeKey: '$default',
  rawPath: '/users',
  rawQueryString: '',
  headers: {
    'content-type': 'application/json',
    'accept': 'application/json',
  },
  isBase64Encoded: false,
  body: JSON.stringify({ name: 'Lucas' }),
  requestContext: {
    accountId: '123456789012',
    apiId: 'test-api-id',
    domainName: 'test-api-id.execute-api.us-east-1.amazonaws.com',
    domainPrefix: 'test-api-id',
    http: {
      method: 'POST',
      path: '/users',
      protocol: 'HTTP/1.1',
      sourceIp: '127.0.0.1',
      userAgent: 'jest',
    },
    requestId: 'some-uuid-here',
    routeKey: '$default',
    stage: '$default',
    time: '01/Jan/2024:00:00:00 +0000',
    timeEpoch: 1704067200000,
  },
}

That's 30+ lines for an event where the only things you actually care about are the method, path, and body. The rest is structural noise — correct enough to not crash, meaningless to your test.

Now multiply that by every event type in your service. SQS needs messageId, receiptHandle, attributes, eventSourceARN. S3 needs bucket, key, responseElements, userIdentity. DynamoDB Streams need marshalled AttributeValue maps where "hello" becomes { S: "hello" } and 42 becomes { N: "42" }.

Most teams solve this one of three ways:

  1. Copy-paste JSON fixtures — Brittle, verbose, drift from reality over time.

  2. Hand-roll factory functions — Every team writes their own, slightly differently, and they're never complete.

  3. Skip testing — The honest answer when the setup cost exceeds the perceived value.

None of these are good.

The Solution: Express Intent, Not Structure

With @sls-testing/core, the same test becomes:

import { buildApiGatewayEvent } from '@sls-testing/core'

const event = buildApiGatewayEvent({
  method: 'POST',
  path: '/users',
  body: JSON.stringify({ name: 'Lucas' }),
})

Three lines. Same fully-typed event. Every field you didn't specify gets a sensible default — a real-looking request ID, a timestamp, valid ARNs. The TypeScript types come from @types/aws-lambda, so your IDE autocompletes every field if you need to override something specific.

The pattern is the same across all six event types:

// SQS — bodies auto-serialized, each record gets a unique messageId
const sqsEvent = buildSQSEvent({
  records: [
    { body: { orderId: 'abc-123', amount: 99.9 } },
    { body: { orderId: 'def-456', amount: 49.9 } },
  ],
})

// S3 — just bucket and key, everything else filled in
const s3Event = buildS3Event({
  bucket: 'uploads',
  key: 'images/photo.png',
})

// DynamoDB Streams — plain objects auto-marshalled to AttributeValue
const streamEvent = buildDynamoDBStreamEvent({
  records: [{
    eventName: 'INSERT',
    keys: { id: 'abc' },
    newImage: { id: 'abc', name: 'Lucas', count: 42 },
  }],
})

// EventBridge
const ebEvent = buildEventBridgeEvent({
  source: 'app.orders',
  'detail-type': 'OrderPlaced',
  detail: { orderId: 'abc-123' },
})

// SNS
const snsEvent = buildSNSEvent({
  records: [{ message: { action: 'notify' } }],
})

The DynamoDB builder is where the savings are most dramatic. Manually constructing a DynamoDBStreamEvent with marshalled values is easily 40-50 lines. The builder does the marshalling for you — pass { count: 42 } and it becomes { N: "42" } automatically.

Beyond Events: Lambda Context

Events are half the story. Your handler also receives a Context object, and AWS's type definition has 12 fields. Most tests either ignore it (handler(event, {} as any) — hello, runtime crash) or build an incomplete mock.

import { buildLambdaContext } from '@sls-testing/core'

const context = buildLambdaContext({
  functionName: 'order-service-dev-processOrder',
  memoryLimitInMB: '512',
  remainingTimeOverride: 5000,
})

context.getRemainingTimeInMillis() // 5000 — actually works

Every field has a default. getRemainingTimeInMillis() returns the value you configure. The awsRequestId is a real UUID. The logGroupName derives from the function name. It's a real Context object, not a type-cast empty object.

Assertions That Speak Serverless

The companion package @sls-testing/jest adds custom Jest matchers that understand Lambda response shapes:

import '@sls-testing/jest'

const result = await handler(event, context)

// Status code assertions
expect(result).toHaveStatusCode(200)
expect(result).toBeSuccessfulApiResponse()  // any 2xx
expect(result).toBeClientError()             // any 4xx
expect(result).toBeServerError()             // any 5xx

// Deep response matching with asymmetric matchers
expect(result).toMatchLambdaResponse({
  statusCode: 201,
  body: { userId: expect.any(String) },
  headers: { 'content-type': 'application/json' },
})

// SQS batch response assertions
expect(result).toHaveNoFailedMessages()
expect(result).toHaveFailedMessage('msg-id-2')

toMatchLambdaResponse automatically parses the JSON body for comparison — you don't need to JSON.parse(result.body) in every test. Asymmetric matchers like expect.any(String) work inside the body, so you can assert structure without pinning every generated value.

The error messages are designed for Lambda. When toHaveStatusCode fails, it shows you both the expected and actual status codes plus the response body — because when a Lambda returns 500 instead of 200, the first thing you need is the error message, not a generic "expected 200 but received 500".

What the Numbers Actually Look Like

Let me do the math on a real scenario — a service with three Lambda functions (API Gateway handler, SQS consumer, DynamoDB Stream processor), each with 3-4 test cases.

Without @sls-testing

Component Lines
API Gateway event fixture ~35
SQS event fixture (2 records) ~45
DynamoDB Stream event fixture ~50
Lambda context mock ~20
Helper: JSON body parser for assertions ~10
Helper: status code checker ~8
Copy-paste overhead across test files ~40
Total test infrastructure ~208

With @sls-testing

Component Lines
API Gateway event (per test) 3-4
SQS event (per test) 3-5
DynamoDB Stream event (per test) 4-6
Lambda context (per test) 1-3
Import + matcher setup 2
Total test infrastructure ~20

That's roughly a 90% reduction in test setup code. But the real win isn't the line count — it's the cognitive load. When a test file is 80% fixture and 20% assertion, you can't see what's being tested. When it's 20% setup and 80% assertion, the intent is obvious.

Design Decisions

A few choices I made that shaped the library:

Sensible defaults, full override. Every builder returns a complete, valid event with zero arguments. Pass a DeepPartial override to change any field. This means the simple case is one line, but you can still construct precise edge cases when you need to test specific header combinations or malformed payloads.

Auto-serialization. SQS bodies and SNS messages are automatically JSON.stringify'd. DynamoDB images are automatically marshalled. You pass plain objects; the builder handles the format Lambda actually receives.

Framework-agnostic core. @sls-testing/core works with Jest, Vitest, Mocha, or any test runner. The Jest-specific matchers are a separate package. Vitest adapters are planned for v2.

Types from the source. All event types come from @types/aws-lambda — the community-maintained definitions that match the actual AWS runtime. No custom type definitions that could drift.

Unique identifiers per call. Every buildSQSEvent() call generates unique messageIds, every context gets a unique awsRequestId. This prevents subtle test pollution where two tests accidentally share the same ID.

Getting Started

npm install @sls-testing/core @sls-testing/jest --save-dev

Add the Jest setup (or import per file):

{
  "setupFilesAfterEnv": ["@sls-testing/jest"]
}

Write a test:

import { buildApiGatewayEvent, buildLambdaContext } from '@sls-testing/core'
import '@sls-testing/jest'
import { handler } from './handler'

it('creates a user', async () => {
  const event = buildApiGatewayEvent({
    method: 'POST',
    path: '/users',
    body: JSON.stringify({ name: 'Lucas' }),
  })

  const result = await handler(event, buildLambdaContext())

  expect(result).toHaveStatusCode(201)
  expect(result).toMatchLambdaResponse({
    body: { name: 'Lucas', id: expect.any(String) },
  })
})

That's it. No fixture files. No factory functions. No as any casts.

What's Next

The library is at v1 and covers the six most common Lambda event sources. The roadmap includes:

  • Vitest adapter — Same matchers, native Vitest integration

  • Serverless Framework plugin — Bridge serverless.yml config into tests so function names, timeouts, and env vars stay in sync automatically

  • More event types — Cognito triggers, CloudWatch Events, Kinesis

  • Snapshot testing — Assert that response shapes haven't changed across deploys

  • Error simulation — Builders for timeout, OOM, and cold start scenarios

The repo is at github.com/brognilucas/sls-testing. Contributions welcome — especially if you have event types you'd like to see supported.


Testing serverless applications shouldn't require more boilerplate than the business logic itself. If your test files are 80% fixture setup, something is wrong with the tooling, not with your tests.