serverless-integration-test-builder

Use when a serverless project (SAM, serverless.yml, Wrangler, Vercel or Netlify functions) has no integration tests yet. Inventories functions and event sources, picks a local emulator per function, generates test events, asserts cold-start and timeout budgets, and emits the tests and CI config.

/plugin install qa-serverless@testland-qa

First time in Claude Code? Add the marketplace with /plugin marketplace add testland/qa

View source (opens in a new tab)

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

  • CI config.

When to use

  • New serverless project; need integration test coverage.
  • Inherited a serverless project with no tests; need to build coverage.
  • Migrating between platforms (e.g., Lambda → Workers); need to port test discipline.

Step 1 - Inventory functions from IaC

Per platform:

PlatformIaC sourceWhere functions live
AWS SAMtemplate.yaml AWS::Serverless::FunctionResources.<name>
Serverless Frameworkserverless.yml functions:each entry
Cloudflare Workerswrangler.toml + script bindings[[durable_objects.bindings]], scripts
Vercelpages/api/ + app/api/ + middlewarefilesystem-derived
Netlifynetlify/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.yml

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

  • AWS Lambdas).
PlatformTest pathSkill
AWS Lambda (Node/Python/etc.)sam local invoke or direct handleraws-sam-local-testing
AWS Lambda (.NET)Amazon.Lambda.TestUtilitiesaws-sam-local-testing references/dotnet.md
Cloudflare Workersvitest-pool-workers / Miniflarecloudflare-workers-miniflare
Azure FunctionsCore Tools func start + Azuriteazure-functions-tests
Vercel Edge / Netlify / Serverless FrameworkLambda-under-the-hood; vendor dev CLI + direct handler(no dedicated skill; same patterns as SAM)

Step 3 - Generate test events per event source

Event sourceTool
API Gateway requestsam local generate-event apigateway aws-proxy
S3 object createdsam local generate-event s3 put
SQS messagesam local generate-event sqs receive-message
DynamoDB Streamssam local generate-event dynamodb update
EventBridgesam local generate-event events
Cloudflare Workers fetchnew Request('https://...') directly
Vercel/Netlifyconstruct 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 + matrix

Per-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):

RuntimeTypical 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 Runtime5-30ms
Vercel Node.js Functions200-500ms (small) to 2-3s (large)
Netlify Functions300ms-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):

IntegrationTimeout ceiling
API Gateway (REST + HTTP API)29s hard - function timeout must be < 29s
Application Load Balancer4s default; configurable to 4000s
CloudFront Lambda@Edge5s viewer functions; 30s origin functions
SQS event sourcePer-queue visibility timeout (default 30s); function timeout must be below it
DynamoDB Streams6-hour batch window; per-batch function limit
EventBridge (async)Async with retry on timeout - requires idempotency
Step FunctionsPer-task timeout, default 60s
Cloudflare Workers10ms CPU (free) / 50ms CPU (paid); 30s wall-clock
Vercel Edge Functions30s 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 limit

These 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-patternWhy it failsFix
One emulator for all functionsMixed-platform tests are awkward; some emulators miss platform-specific behaviourPer-platform emulator
No staging deploy + smoke testsLocal-pass + prod-fail gapTwo-tier CI
Hand-rolled event payloadsSchema drift; missed fieldsUse sam local generate-event or commit fixtures
Skip cold-start budget assertionsp99 spikes go unnoticedPer-function budget in staging smoke
Test directory mixed with prod codetests/ dir not in deploy artifactKeep separate
No README documenting matrixFuture maintainers can't add functions consistentlyStep 7 README
Inflate jest test timeouts to mask slow handlersHides performance regressionMatch jest timeout to function timeout
Mock AWS SDK in all testsTests pass; IAM permission bugs hideUse LocalStack or real test account

Output

This skill produces:

  • A function inventory (Step 1).
  • Per-function test files using the right emulator (Steps 2-4).
  • A budget matrix (Step 5).
  • A two-tier CI config (Step 6).
  • A coverage matrix README (Step 7).

References

  • Companion catalog: references/cold-start-budget.md (cold-start + timeout budgets).
  • Per-platform tools: aws-sam-local-testing (incl. .NET in references/dotnet.md), cloudflare-workers-miniflare, azure-functions-tests.
  • Cross-plugin: testcontainers (in the qa-test-environment plugin)
    • LocalStack alternative for AWS-service emulation.
Reference

Cold-start and timeout budgets

View source (opens in a new tab) (opens in new window)

Overview

Per aws.amazon.com/blogs/compute on cold starts (opens in new window), Lambda's cold start has three phases:

  1. Init - download deployment package, unzip, bootstrap the runtime (Node/Python/Java/etc.).
  2. Init code - execute module-level imports + global setup.
  3. Invoke - the actual handler call.

Phases 1 + 2 are the "cold" part. Phase 3 is what runs every invocation (cold or warm).

When to read this

  • Designing a latency budget for a Lambda / Workers / Edge function.
  • Investigating "p95 is fine but p99 is 5s."
  • Choosing a runtime - Cloudflare's isolate model is qualitatively different from Lambda containers.
  • Auditing provisioned-concurrency / SnapStart configurations.

How to use

  1. Look up the runtime's typical range in the per-runtime budget table (e.g. Node.js 256MB is 200-700ms; Java 11 without SnapStart is 1.5-6s).
  2. Set the latency budget as two lines: p50/p95 tracks warm performance, and a separate p99 line absorbs the cold-start tail.
  3. Force a cold start (deploy, or wait past the idle-evict window) and record p95 first-invocation latency against the budget.
  4. If cold starts breach budget, apply the matching mitigation from below: SnapStart (JVM/.NET), provisioned concurrency, package-size trimming, lazy imports, or a pre-compiled runtime (Go/Rust).
  5. Add the build-step and monitoring assertions from Testable behaviours (zipped artifact < 50MB, init phase < 500ms, CloudWatch Init Duration).
  6. Re-measure after each change and confirm the p99 tail moved, not just p50/p95.

Per-runtime cold-start budgets

Per AWS, Cloudflare, Vercel docs (typical ranges; bigger packages and bigger memory-class skew higher):

RuntimeTypical cold startArchitecture
AWS Lambda Node.js (256MB)200-700msContainer (Firecracker microVM)
AWS Lambda Python (256MB)250-800msContainer
AWS Lambda Java 11 (512MB, no SnapStart)1.5-6sContainer + JVM warmup
AWS Lambda Java 11 (512MB, SnapStart)100-300msSnapshot-restore per docs.aws.amazon.com/lambda (opens in new window)
AWS Lambda .NET (1GB)1-3sContainer + .NET runtime
AWS Lambda Go (256MB)100-300msContainer; pre-compiled binary
AWS Lambda Rust (256MB)50-200msContainer; pre-compiled binary
Cloudflare Workers0-5ms (V8 isolate spawn)V8 isolate per developers.cloudflare.com/workers (opens in new window)
Vercel Edge Runtime5-30msV8 isolate (similar to Workers)
Vercel Node.js Functions200-500ms (small) to 2-3s (large)Lambda under the hood
Netlify Functions300ms-2sLambda 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:

  • State snapshot includes connections, random seeds; can't have per-instance unique values frozen.
  • Hooks beforeCheckpoint / afterRestore let you re-prime non-serializable state.

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

BehaviourTest
Cold start within budgetForce cold (deploy or wait > idle-evict time); measure p95 first-invocation latency
Warm performanceSubsequent invocations (50+) → p95 well within prod budget
SnapStart effectivePre/post SnapStart cold start delta
Provisioned concurrency keeps warmRun for an hour; no cold-start spikes observed
Package size in budgetBuild-step assertion: zipped artifact < 50MB
No heavy init-time importsProfile 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.

  1. The per-runtime table puts Java 11 without SnapStart at 1.5-6s cold start; p95 hides it because cold starts land in the p99 tail.
  2. Enable SnapStart, which snapshot-restores the initialised JVM and drops the cold start to the 100-300ms range.
  3. Re-prime non-serializable state in an afterRestore hook so a stale pooled DB connection is not frozen into the snapshot.
  4. Force a cold start (wait past idle-evict) and compare Init Duration before and after; confirm the p99 line, not just p95, falls.

Result: the cold-start tail drops from ~5s to the 100-300ms range with no provisioned-concurrency spend.

Anti-patterns

Anti-patternWhy it failsFix
p99 latency surpriseCold starts at the tail; not visible in p50/p95Watch p99; explicit cold-start monitoring (CloudWatch Init Duration metric)
Large dependency tree on init pathCold start inflated 2-5xAudit imports; lazy-import non-critical
Java on Lambda without SnapStart5s cold startsEnable SnapStart
Provisioned concurrency without size analysisPay for unused warm instancesTune to actual concurrency p99
Cold-start test only on the local dev environmentLocal doesn't simulate Lambda initDeploy + test against AWS / Workers / Edge
Treat cold starts as "rare"Bursty traffic → cold starts clusterAccount for both steady-state and burst patterns
Ignore module bundlingWebpack-bundled is smaller AND has fewer import resolution hopsBundle for production Lambdas

Limitations

  • Cold-start measurement is platform-side. CloudWatch Init Duration metric is canonical for Lambda; Workers / Edge expose their own.
  • SnapStart caveats are subtle. State that survives the snapshot may be wrong (random seeds, connection state).
  • Provisioned-concurrency is regional. Multi-region Lambdas need PC per region.
  • Workers / Edge are not free of all variance. First request per (script, region) still has 5-30ms init.
  • Doesn't address steady-state throughput. Cold start is one metric; concurrency-limit + duration are separate.

References

Reference

AWS Lambda timeout + billing budgets

View source (opens in a new tab) (opens in new window)

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 componentDetail
Request charge$0.20 per 1M requests (us-east-1)
Compute chargeMemory-class × GB-seconds (billed per ms)
Init durationFree; 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:

IntegrationTimeoutLambda config
API Gateway (REST + HTTP API)29 seconds (hard)Lambda timeout MUST be < 29s
Application Load Balancer4s default; configurable to 4000sConfigurable both sides
CloudFront (Lambda@Edge)5s viewer functions; 30s originTight viewer limit
SQS (event source)Per-queue visibility timeout (default 30s)Visibility > Lambda timeout × 6 (AWS recommendation)
DynamoDB Streams6h batch windowPer-batch limit
EventBridge (async)Retries on timeoutIdempotency required
Step FunctionsPer-task timeout; default 60sPer-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

BehaviourTest
Completes within timeout under prod loadk6 / load run against the deployed function
Graceful return via remaining-time checkInject slowness; assert "partial", not 504
API Gateway 29s budget honouredLong-running endpoint via API GW URL; assert ≤ 29s
SQS visibility > Lambda timeoutForce a timeout; observe SQS re-delivery
Memory sweet spot foundRun at 256/512/1024/2048 MB; chart duration
Cost at p99 within budgetLatency × memory × invocations × price/GB-s at p99, not average

Anti-patterns

Anti-patternWhy it failsFix
Timeout = 900s "for safety"Stuck Lambdas burn 15min; concurrency limits hitMatch timeout to p99 + buffer
Lambda timeout > API Gateway's 29sAPI GW kills first; Lambda runs unusedTimeout < 29s behind API GW
SQS visibility < Lambda timeoutIn-flight message re-delivered → duplicatesVisibility > timeout × 6
No remaining-time checkForce-kill; no progress savedCheck + early-return
Sizing memory by RAM need onlyCompute-bound work wastes wall-clockTune by p95 duration
Cost calculated from the averagep99 spikes blow the budgetCalculate against p99

Notes

  • Per-region pricing varies; the figures above are us-east-1.
  • Workers / Edge have different models: Cloudflare Workers 10ms CPU free / 50ms paid, 30s wall-clock; Vercel Edge Functions 30s wall-clock max.

References