# 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:

```typescript
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:

```typescript
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:

```typescript
// 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.

```typescript
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:

```typescript
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 `messageId`s, every context gets a unique `awsRequestId`. This prevents subtle test pollution where two tests accidentally share the same ID.

## Getting Started

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

Add the Jest setup (or import per file):

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

Write a test:

```typescript
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](https://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.*
