Most serverless teams we are brought into can deploy in ninety seconds and have no idea whether the deploy works until someone clicks something in staging. Testing is the part of the serverless workflow that got left behind: the handler code is easy to test, but the interesting behaviour lives in IAM policies, event mappings, DynamoDB conditions and API Gateway request shapes — none of which exist on your laptop.
This post lays out the four-layer testing strategy we install on Serverless Framework V4 engagements, what each layer is genuinely good at, and where each one lies to you.
The four layers
| Layer | Runs where | Feedback | Catches |
|---|---|---|---|
| Unit | Node process | < 1s | Business logic, branching, error mapping |
| Integration (local emulation) | LocalStack / DynamoDB Local | seconds | Query shapes, condition expressions, serialization |
HTTP smoke (serverless-offline) | Local HTTP server | seconds | Routing, request/response mapping, auth middleware |
| Ephemeral stage (real AWS) | Deployed per branch | minutes | IAM, event sources, quotas, cold-path behaviour |
The rule of thumb: push everything you can down to the fastest layer that can still fail honestly. Do not simulate IAM. Do not unit-test API Gateway.
Step 1: Make handlers thin so unit tests are worth writing
A handler that parses an event, does business logic and writes to DynamoDB in one function can only be tested by mocking half of AWS. Split it:
// src/orders/create.logic.js -- pure, no AWS
export function buildOrder(input, now) {
if (!input.sku) throw new ValidationError('sku is required');
return {
pk: `ORDER#${input.orderId}`,
sku: input.sku,
quantity: input.quantity ?? 1,
createdAt: now.toISOString(),
status: 'PENDING'
};
}
// src/orders/create.handler.js -- thin adapter
import { buildOrder } from './create.logic.js';
import { putOrder } from '../lib/orders.repo.js';
export const handler = async (event) => {
const input = JSON.parse(event.body ?? '{}');
const order = buildOrder(input, new Date());
await putOrder(order);
return { statusCode: 201, body: JSON.stringify(order) };
};
Now buildOrder is testable with plain Vitest or Jest, no mocks, in milliseconds:
import { describe, it, expect } from 'vitest';
import { buildOrder } from '../src/orders/create.logic.js';
describe('buildOrder', () => {
it('defaults quantity to 1', () => {
const o = buildOrder({ orderId: 'a1', sku: 'SKU-9' }, new Date('2026-01-01'));
expect(o.quantity).toBe(1);
expect(o.status).toBe('PENDING');
});
it('rejects a missing sku', () => {
expect(() => buildOrder({ orderId: 'a1' }, new Date())).toThrow(/sku/);
});
});
Aim for the majority of your assertions to live here. They are the only tests that stay fast forever.
Step 2: Integration tests against DynamoDB Local or LocalStack
The repository layer — condition expressions, GSI queries, pagination, transaction rollbacks — is where mocks are actively harmful. aws-sdk-client-mock will happily let you assert that you called PutItem with a malformed ConditionExpression. A real engine will not.
Run the real engine in Docker. For DynamoDB-only services, DynamoDB Local is smaller and faster:
# docker-compose.test.yml
services:
dynamodb:
image: amazon/dynamodb-local:latest
command: -jar DynamoDBLocal.jar -inMemory -sharedDb
ports: ['8000:8000']
localstack:
image: localstack/localstack:latest
environment:
SERVICES: sqs,sns,s3,eventbridge
ports: ['4566:4566']
Point the SDK at it with an endpoint override that is only active in tests:
// src/lib/ddb.js
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
const client = new DynamoDBClient(
process.env.AWS_ENDPOINT_URL
? { endpoint: process.env.AWS_ENDPOINT_URL, region: 'us-east-1',
credentials: { accessKeyId: 'test', secretAccessKey: 'test' } }
: {}
);
export const ddb = DynamoDBDocumentClient.from(client);
Note that AWS_ENDPOINT_URL is now honoured natively by recent AWS SDK v3 releases, so in many services you can skip the custom wiring entirely and just export the variable in your test script.
A repository test then looks like a normal database test:
import { beforeAll, it, expect } from 'vitest';
import { createTable, putOrder, getOrder } from '../src/lib/orders.repo.js';
beforeAll(async () => { await createTable(); });
it('refuses to overwrite an existing order', async () => {
await putOrder({ pk: 'ORDER#1', sku: 'A', status: 'PENDING' });
await expect(putOrder({ pk: 'ORDER#1', sku: 'B', status: 'PENDING' }))
.rejects.toThrow('ConditionalCheckFailedException');
expect((await getOrder('ORDER#1')).sku).toBe('A');
});
What this layer cannot tell you: whether your Lambda's execution role is allowed to call PutItem on that table. LocalStack does not enforce IAM the way AWS does. Never treat a green LocalStack suite as a permissions check.
Step 3: serverless-offline for HTTP-shaped tests
When the service is an HTTP API, serverless-offline gives you the routing and request-mapping layer without a deploy. It reads the same serverless.yml your deploy uses, so a typo in a path or a missing authorizer shows up locally.
plugins:
- serverless-offline
custom:
serverless-offline:
httpPort: 3000
noPrependStageInUrl: true
npx serverless offline start --stage local &
npx vitest run test/http
Keep this suite small — a handful of happy-path and auth-failure requests per route. It exists to catch wiring mistakes, not to re-test business logic you already covered in step 1. Be aware of the gaps: custom authorizer caching, WAF, throttling, usage plans and payload size limits all behave differently offline than in API Gateway.
Step 4: Ephemeral stages for the tests only AWS can run
Everything above still cannot answer: does the IAM role work, does the EventBridge rule actually match, does the SQS event source mapping deliver in batches, do the alarms fire? For those, deploy a throwaway stage.
Because Serverless Framework namespaces every resource by stage, a per-pull-request stack is close to free — you pay for what the tests invoke.
provider:
name: aws
stage: ${opt:stage, 'dev'}
# tag everything so a sweeper can find orphans
stackTags:
Ephemeral: ${self:custom.isEphemeral}
Service: ${self:service}
custom:
isEphemeral: ${strToBool(${param:ephemeral, 'false'})}
In GitHub Actions, using OIDC rather than stored keys:
jobs:
e2e:
permissions: { id-token: write, contents: read }
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/gha-serverless-deploy
aws-region: eu-west-1
- run: npm ci
- run: npx serverless deploy --stage pr${{ github.event.number }} --param="ephemeral=true"
- run: npm run test:e2e
env:
API_BASE_URL: ${{ steps.deploy.outputs.api_url }}
- if: always()
run: npx serverless remove --stage pr${{ github.event.number }}
Three things make this survivable in practice:
- Always remove, even on failure.
if: always()on the teardown step, plus a nightly sweeper that deletes stacks taggedEphemeral=trueolder than 24 hours. Orphaned stacks are the number one reason teams abandon ephemeral environments. - Isolate the account. Run ephemeral stages in a sandbox AWS account with a budget alarm, not in staging.
- Keep the suite short. Five to ten end-to-end assertions covering the paths that only exist in AWS: an authenticated request through API Gateway, an event published to EventBridge landing in the right consumer, a poison message reaching the DLQ.
Testing asynchronous flows without sleep
End-to-end tests on event-driven systems fail intermittently when they rely on fixed sleeps. Poll with a deadline instead:
async function eventually(fn, { timeout = 30000, interval = 1000 } = {}) {
const deadline = Date.now() + timeout;
let lastError;
while (Date.now() < deadline) {
try { return await fn(); } catch (e) { lastError = e; }
await new Promise(r => setTimeout(r, interval));
}
throw lastError;
}
it('routes a failed order to the DLQ', async () => {
await publishOrder({ orderId: 'bad', sku: null });
await eventually(async () => {
const msgs = await receiveFromDlq();
expect(msgs.length).toBeGreaterThan(0);
});
});
Also make your handlers idempotent and assert it: invoke the same event twice and check the side effect happened once. Retries are not an edge case in serverless, they are the default.
A pipeline that fits the layers
{
"scripts": {
"test": "vitest run test/unit",
"test:int": "docker compose -f docker-compose.test.yml up -d && AWS_ENDPOINT_URL=http://localhost:8000 vitest run test/integration",
"test:http": "vitest run test/http",
"test:e2e": "vitest run test/e2e"
}
}
Run test on every commit, test:int and test:http on every push, and test:e2e against an ephemeral stage on pull requests to main. If the first three are green a developer should be confident enough to open a PR; if the fourth is green a reviewer should be confident enough to merge.
What to fix first if you have nothing
If your service currently has zero tests, do them in this order — each step is worth doing on its own:
- Extract the logic out of two or three of your riskiest handlers and unit-test it.
- Add DynamoDB Local (or LocalStack) tests for your data access layer.
- Add the teardown-safe ephemeral stage job, with a single smoke test.
- Backfill
serverless-offlineroute tests as bugs justify them.
That sequence gets you most of the confidence for a fraction of the effort of a full pyramid, and it stops the pattern where the only integration test is a deploy to staging on a Friday.
Need help getting there? SleekDeploy builds test suites and CI pipelines for Serverless Framework teams — from a one-week audit of an existing service to a full DevOps for Serverless Apps engagement, including CI/CD pipeline setup. Get in touch and tell us what your current deploy-and-pray workflow looks like.