Overview
Serverless integration tests are awkward because the test surface is multi-tool: each platform has its own local emulator, event-source binding model, and cold-start characteristic. This skill produces a coherent test directory from the IaC definition.
The output: a test directory layout + per-function test files
When to use
Step 1 - Inventory functions from IaC
Per platform:
| Platform | IaC source | Where functions live |
|---|---|---|
| AWS SAM | template.yaml AWS::Serverless::Function | Resources.<name> |
| Serverless Framework | serverless.yml functions: | each entry |
| Cloudflare Workers | wrangler.toml + script bindings | [[durable_objects.bindings]], scripts |
| Vercel | pages/api/ + app/api/ + middleware | filesystem-derived |
| Netlify | netlify/functions/, netlify/edge-functions/ | filesystem-derived |
# Example: SAM
yq '.Resources | to_entries | map(select(.value.Type == "AWS::Serverless::Function")) | .[].key' template.yaml
# Example: Serverless Framework
yq '.functions | keys' serverless.ymlOutput: a function inventory:
functions:
- name: get-user
platform: aws-sam
runtime: nodejs20.x
handler: src/get-user.handler
timeout: 10
memory: 512
events:
- type: api
path: /users/{id}
method: GET
- name: process-upload
platform: aws-sam
runtime: python3.12
timeout: 300
events:
- type: s3
bucket: uploads
events: ['s3:ObjectCreated:*']Step 2 - Pick the emulator per function
Default: pick one emulator per platform detected in Step 1's inventory - the per-platform skill below is the canonical driver for that runtime. Use multiple emulators only when the project mixes platforms (e.g., Vercel app
| Platform | Test path | Skill |
|---|---|---|
| AWS Lambda (Node/Python/etc.) | sam local invoke or direct handler | aws-sam-local-testing |
| AWS Lambda (.NET) | Amazon.Lambda.TestUtilities | aws-sam-local-testing references/dotnet.md |
| Cloudflare Workers | vitest-pool-workers / Miniflare | cloudflare-workers-miniflare |
| Azure Functions | Core Tools func start + Azurite | azure-functions-tests |
| Vercel Edge / Netlify / Serverless Framework | Lambda-under-the-hood; vendor dev CLI + direct handler | (no dedicated skill; same patterns as SAM) |
Step 3 - Generate test events per event source
| Event source | Tool |
|---|---|
| API Gateway request | sam local generate-event apigateway aws-proxy |
| S3 object created | sam local generate-event s3 put |
| SQS message | sam local generate-event sqs receive-message |
| DynamoDB Streams | sam local generate-event dynamodb update |
| EventBridge | sam local generate-event events |
Cloudflare Workers fetch | new Request('https://...') directly |
| Vercel/Netlify | construct Request object |
Commit events to tests/fixtures/events/. Reference in tests.
Step 4 - Emit test scaffold per function
tests/
integration/
get-user.test.ts
process-upload.test.ts
fixtures/
events/
api-get-users-id.json
s3-object-created.json
payloads/
user-1.json
conftest.py # Or jest setup
README.md # How to run + matrixPer-function test:
// tests/integration/get-user.test.ts
import { handler } from '../../src/get-user';
import event from '../fixtures/events/api-get-users-id.json';
describe('get-user', () => {
test('returns 200 for valid id', async () => {
const response = await handler({ ...event, pathParameters: { id: '1' } } as any, {} as any);
expect(response.statusCode).toBe(200);
});
test('returns 404 for missing id', async () => {
const response = await handler({ ...event, pathParameters: { id: 'missing' } } as any, {} as any);
expect(response.statusCode).toBe(404);
});
test('completes within timeout budget', async () => {
// Per references/timeout-budgets.md: timeout=10s; want headroom
const start = Date.now();
await handler({ ...event, pathParameters: { id: '1' } } as any, {} as any);
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(5000); // 50% of timeout
});
});Step 5 - Cold-start + timeout budget assertions
For each function, derive a budget from references/cold-start-budget.md (cold-start tables + timeout budgets in its references/timeout-budgets.md).
Typical cold starts per runtime (per AWS / Cloudflare / Vercel docs; bigger packages and memory classes skew higher):
| Runtime | Typical cold start |
|---|---|
| AWS Lambda Node.js (256MB) | 200-700ms |
| AWS Lambda Python (256MB) | 250-800ms |
| AWS Lambda Java 11 (512MB, no SnapStart) | 1.5-6s |
| AWS Lambda Java 11 (512MB, SnapStart (opens in new window)) | 100-300ms |
| AWS Lambda .NET (1GB) | 1-3s |
| AWS Lambda Go (256MB) | 100-300ms |
| AWS Lambda Rust (256MB) | 50-200ms |
| Cloudflare Workers (opens in new window) | 0-5ms (V8 isolate spawn) |
| Vercel Edge Runtime | 5-30ms |
| Vercel Node.js Functions | 200-500ms (small) to 2-3s (large) |
| Netlify Functions | 300ms-2s |
The upstream integration - not the function's own timeout - is usually the operational ceiling (per docs.aws.amazon.com/lambda (opens in new window); Lambda's hard maximum is 900s):
| Integration | Timeout ceiling |
|---|---|
| API Gateway (REST + HTTP API) | 29s hard - function timeout must be < 29s |
| Application Load Balancer | 4s default; configurable to 4000s |
| CloudFront Lambda@Edge | 5s viewer functions; 30s origin functions |
| SQS event source | Per-queue visibility timeout (default 30s); function timeout must be below it |
| DynamoDB Streams | 6-hour batch window; per-batch function limit |
| EventBridge (async) | Async with retry on timeout - requires idempotency |
| Step Functions | Per-task timeout, default 60s |
| Cloudflare Workers | 10ms CPU (free) / 50ms CPU (paid); 30s wall-clock |
| Vercel Edge Functions | 30s wall-clock max |
Set the assertion threshold at roughly 50% of the binding ceiling so a regression trips the test before it trips production:
budgets:
get-user:
cold_start_p99_ms: 800 # AWS Lambda Node@512MB typical
warm_p99_ms: 200
timeout_s: 10
integration_timeout: api-gateway-29s # API GW < 29s
process-upload:
cold_start_p99_ms: 1500
warm_p99_ms: 2000
timeout_s: 300
integration_timeout: s3-event-async # No upstream limitThese become assertion thresholds in staging-smoke tests (run against deployed functions, not local).
Step 6 - CI integration
# .github/workflows/serverless-tests.yml
jobs:
local-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
- uses: aws-actions/setup-sam@v2
- run: sam build --use-container
- run: npm ci && npx jest tests/integration/
staging-deploy-and-smoke:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
needs: local-tests
steps:
- uses: actions/checkout@v5
- run: sam deploy --stack-name staging --parameter-overrides Stage=staging
- run: npx jest tests/staging-smoke/Two-tier: local-fast tests on every PR; deploy + smoke on main.
Step 7 - Document coverage matrix
# Serverless Integration Test Matrix
## Functions covered
| Function | Local test | Cold-start test | Timeout test | Event sources |
|---|---|---|---|---|
| get-user | ✅ tests/integration/get-user.test.ts | staging only | ✅ <5s | API GW GET /users/{id} |
| process-upload | ✅ | staging only | ✅ <150s of 300s | S3 ObjectCreated |
| ... | | | | |
## Gaps
- `legacy-export` - no integration tests; on backlog.
- Cold-start tests not feasible locally for any function (per [references/cold-start-budget.md](references/cold-start-budget.md)).
## How to add a new function
1. Add to IaC.
2. Generate event fixture.
3. Add test file following the template.
4. Update this matrix.Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| One emulator for all functions | Mixed-platform tests are awkward; some emulators miss platform-specific behaviour | Per-platform emulator |
| No staging deploy + smoke tests | Local-pass + prod-fail gap | Two-tier CI |
| Hand-rolled event payloads | Schema drift; missed fields | Use sam local generate-event or commit fixtures |
| Skip cold-start budget assertions | p99 spikes go unnoticed | Per-function budget in staging smoke |
| Test directory mixed with prod code | tests/ dir not in deploy artifact | Keep separate |
| No README documenting matrix | Future maintainers can't add functions consistently | Step 7 README |
| Inflate jest test timeouts to mask slow handlers | Hides performance regression | Match jest timeout to function timeout |
| Mock AWS SDK in all tests | Tests pass; IAM permission bugs hide | Use LocalStack or real test account |
Output
This skill produces:
References
Overview
Per aws.amazon.com/blogs/compute on cold starts (opens in new window), Lambda's cold start has three phases:
Phases 1 + 2 are the "cold" part. Phase 3 is what runs every invocation (cold or warm).
When to read this
How to use
Per-runtime cold-start budgets
Per AWS, Cloudflare, Vercel docs (typical ranges; bigger packages and bigger memory-class skew higher):
| Runtime | Typical cold start | Architecture |
|---|---|---|
| AWS Lambda Node.js (256MB) | 200-700ms | Container (Firecracker microVM) |
| AWS Lambda Python (256MB) | 250-800ms | Container |
| AWS Lambda Java 11 (512MB, no SnapStart) | 1.5-6s | Container + JVM warmup |
| AWS Lambda Java 11 (512MB, SnapStart) | 100-300ms | Snapshot-restore per docs.aws.amazon.com/lambda (opens in new window) |
| AWS Lambda .NET (1GB) | 1-3s | Container + .NET runtime |
| AWS Lambda Go (256MB) | 100-300ms | Container; pre-compiled binary |
| AWS Lambda Rust (256MB) | 50-200ms | Container; pre-compiled binary |
| Cloudflare Workers | 0-5ms (V8 isolate spawn) | V8 isolate per developers.cloudflare.com/workers (opens in new window) |
| Vercel Edge Runtime | 5-30ms | V8 isolate (similar to Workers) |
| Vercel Node.js Functions | 200-500ms (small) to 2-3s (large) | Lambda under the hood |
| Netlify Functions | 300ms-2s | Lambda under the hood |
The "Workers / Edge" qualitative leap is the isolate model: each function is a V8 isolate, spun up in microseconds per developers.cloudflare.com (opens in new window) - no container, no OS startup.
Mitigations
Provisioned concurrency (AWS Lambda)
Per docs.aws.amazon.com/lambda (opens in new window): keeps N execution environments pre-initialised. Eliminates cold starts up to N concurrent requests; you pay for the keep-warm time.
Trade-off: cost. A constant N=10 provisioned concurrency for 30 days ≈ $30-300 depending on memory class.
Lambda SnapStart (Java / .NET)
Per docs.aws.amazon.com/lambda/latest/dg/snapstart.html (opens in new window): takes a snapshot of the initialised JVM and restores from it on each cold start. Reduces Java cold starts from 1.5-6s → 100-300ms.
Caveats:
Package-size discipline
Lambda cold-start scales with deployment-package size. Per AWS: keep under 50MB (zipped) → cold start in the 200-800ms range. Larger packages → seconds.
Avoid heavy module-level imports
Init code runs once but on every cold start. Heavy imports (database connection pool init, large dependency trees) inflate init time.
# Bad: top-level
import heavy_lib # 2s import time
def handler(event, ctx):
return heavy_lib.do_thing(event)
# Better: lazy-import
def handler(event, ctx):
import heavy_lib
return heavy_lib.do_thing(event)Lazy imports add per-warm-call latency but reduce cold-start spike.
Runtime choice
Pre-compiled runtimes (Go, Rust) have far lower cold starts than managed runtimes (Java, Python). For latency-critical paths, runtime choice is a primary lever.
Timeout budgets
Cold start is the front of the latency budget; the back is the timeout. Lambda's 15-minute hard limit, the getRemainingTimeInMillis early-return pattern, per-ms billing and the memory-CPU relationship, and the integration timeout cascade (API Gateway's hard 29s, SQS visibility timeouts, Lambda@Edge limits) are cataloged in timeout-budgets.md (opens in new window).
Testable behaviours
| Behaviour | Test |
|---|---|
| Cold start within budget | Force cold (deploy or wait > idle-evict time); measure p95 first-invocation latency |
| Warm performance | Subsequent invocations (50+) → p95 well within prod budget |
| SnapStart effective | Pre/post SnapStart cold start delta |
| Provisioned concurrency keeps warm | Run for an hour; no cold-start spikes observed |
| Package size in budget | Build-step assertion: zipped artifact < 50MB |
| No heavy init-time imports | Profile init phase; assert < 500ms |
Worked example
A Java 11 Lambda (512MB) serves a low-traffic checkout callback. Users report occasional multi-second waits, though the dashboard p95 looks healthy.
Result: the cold-start tail drops from ~5s to the 100-300ms range with no provisioned-concurrency spend.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| p99 latency surprise | Cold starts at the tail; not visible in p50/p95 | Watch p99; explicit cold-start monitoring (CloudWatch Init Duration metric) |
| Large dependency tree on init path | Cold start inflated 2-5x | Audit imports; lazy-import non-critical |
| Java on Lambda without SnapStart | 5s cold starts | Enable SnapStart |
| Provisioned concurrency without size analysis | Pay for unused warm instances | Tune to actual concurrency p99 |
| Cold-start test only on the local dev environment | Local doesn't simulate Lambda init | Deploy + test against AWS / Workers / Edge |
| Treat cold starts as "rare" | Bursty traffic → cold starts cluster | Account for both steady-state and burst patterns |
| Ignore module bundling | Webpack-bundled is smaller AND has fewer import resolution hops | Bundle for production Lambdas |
Limitations
References
AWS Lambda's wall-clock limit is 15 minutes (900 seconds) per invocation. Per docs.aws.amazon.com/lambda configuration-timeout (opens in new window): "The default value for this setting is 3 seconds, but you can adjust this in increments of 1 second up to a maximum value of 900 seconds (15 minutes)." For longer work use Step Functions, AWS Batch, or ECS Fargate - don't architect around the limit.
Timeout vs deadline at runtime
Per docs.aws.amazon.com/lambda python-context (opens in new window), the Context object exposes get_remaining_time_in_millis() (Python) / getRemainingTimeInMillis() (Node/JVM). Break out proactively:
def handler(event, context):
while not_done:
if context.get_remaining_time_in_millis() < 5000:
save_checkpoint()
return {"status": "partial", "checkpoint": ...}
do_work_chunk()
return {"status": "complete"}The 5-second cushion lets the handler return cleanly; without it the Lambda is force-killed at timeout (no SIGTERM grace) and the caller gets a 504-equivalent.
Billing semantics
Per docs.aws.amazon.com/lambda lambda-pricing (opens in new window):
| Cost component | Detail |
|---|---|
| Request charge | $0.20 per 1M requests (us-east-1) |
| Compute charge | Memory-class × GB-seconds (billed per ms) |
| Init duration | Free; not billed (per AWS; historically has changed - verify current docs) |
GB-second formula: memory_GB * duration_seconds. A 512MB Lambda running 1000ms costs 0.5 * 1.0 * $0.0000166667 = $0.00000833; per million 1s invocations at 512MB: ~$8.54 including the request charge.
Memory ↔ CPU relationship
Per docs.aws.amazon.com/lambda configuration-memory (opens in new window): "The amount of CPU available to a function is proportional to the memory you allocate to it. At 1,769 MB, a function has the equivalent of one vCPU." Compute-bound workloads should size memory by CPU need; the sweet spot is usually the memory class where wall-clock time stops dropping (often 1024-2048MB).
Integration timeout cascade
The integration's timeout is often the operational ceiling:
| Integration | Timeout | Lambda config |
|---|---|---|
| API Gateway (REST + HTTP API) | 29 seconds (hard) | Lambda timeout MUST be < 29s |
| Application Load Balancer | 4s default; configurable to 4000s | Configurable both sides |
| CloudFront (Lambda@Edge) | 5s viewer functions; 30s origin | Tight viewer limit |
| SQS (event source) | Per-queue visibility timeout (default 30s) | Visibility > Lambda timeout × 6 (AWS recommendation) |
| DynamoDB Streams | 6h batch window | Per-batch limit |
| EventBridge (async) | Retries on timeout | Idempotency required |
| Step Functions | Per-task timeout; default 60s | Per-task tuning |
The API Gateway 29-second hard limit is the most-encountered surprise: a Lambda configured for 60s still times out at 29s because API Gateway gives up first.
Testable behaviours
| Behaviour | Test |
|---|---|
| Completes within timeout under prod load | k6 / load run against the deployed function |
| Graceful return via remaining-time check | Inject slowness; assert "partial", not 504 |
| API Gateway 29s budget honoured | Long-running endpoint via API GW URL; assert ≤ 29s |
| SQS visibility > Lambda timeout | Force a timeout; observe SQS re-delivery |
| Memory sweet spot found | Run at 256/512/1024/2048 MB; chart duration |
| Cost at p99 within budget | Latency × memory × invocations × price/GB-s at p99, not average |
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Timeout = 900s "for safety" | Stuck Lambdas burn 15min; concurrency limits hit | Match timeout to p99 + buffer |
| Lambda timeout > API Gateway's 29s | API GW kills first; Lambda runs unused | Timeout < 29s behind API GW |
| SQS visibility < Lambda timeout | In-flight message re-delivered → duplicates | Visibility > timeout × 6 |
| No remaining-time check | Force-kill; no progress saved | Check + early-return |
| Sizing memory by RAM need only | Compute-bound work wastes wall-clock | Tune by p95 duration |
| Cost calculated from the average | p99 spikes blow the budget | Calculate against p99 |