Best Practices for Dynamic Response Shaping
Static JSON fixtures answer the same way every time — which means they never expose the race conditions, auth-expiry flows, or empty-state renders that surface in production. This page solves the transition from static stubs to fully dynamic mock responses: payloads that are realistic, reproducible, and schema-valid on every invocation.
Context: why static mocks break at scale
Three environment gaps drive teams toward dynamic shaping:
Flaky CI assertions. When Faker or Math.random() is unseeded, the same test against the same mock returns different data on every run. One assertion passes, the next fails — not because the code changed, but because the payload did.
Workflow simulation gaps. A POST /checkout that always returns { status: "pending" } can’t drive the downstream GET /checkout/status through a realistic processing → complete sequence. Frontend checkout flows then only get tested against one state.
Latency blindspots. Zero-delay mocks hide entire categories of bugs: race conditions between concurrent fetch calls, missing loading spinners, AbortController timeouts that never fire. Applying jitter-based latency at the request interception layer surfaces these before production does.
The diagram below shows how the five practices on this page layer into a single request path:
Solution
Step 1 — Bind payload generation to a deterministic seed
Non-deterministic data generation breaks CI/CD reproducibility and makes QA debugging significantly harder. Tie every payload to a seed value derived from a stable request identifier or environment variable.
// mock-handlers/users.ts
import { http, HttpResponse } from 'msw'
import { faker } from '@faker-js/faker'
function seededUser(seed: string) {
faker.seed(parseInt(seed.replace(/\D/g, '').slice(0, 8), 10) || 42)
return {
id: faker.string.uuid(),
name: faker.person.fullName(),
email: faker.internet.email(),
createdAt: faker.date.past({ years: 2 }).toISOString(),
}
}
export const userHandlers = [
http.get('/api/users/:id', ({ request, params }) => {
const seed = request.headers.get('x-request-id') ?? params.id as string
return HttpResponse.json(seededUser(seed))
}),
]
Pair this with deterministic seed management when you need the seed propagated across multiple services in a Docker Compose stack.
Verification:
curl -s -H 'X-Request-ID: test-001' http://localhost:3000/api/users/42 | jq .
# Run a second time — the name, email, and id must be byte-for-byte identical.
curl -s -H 'X-Request-ID: test-002' http://localhost:3000/api/users/42 | jq .
# Changing the seed must produce a predictably different but still stable payload.
Step 2 — Add a stateful sequential routing layer
Frontend workflows span multiple endpoints in sequence: POST /orders creates a record, GET /orders/{id} fetches it, POST /orders/{id}/confirm advances it. A flat static mock can’t model these transitions. Implement a lightweight state machine in your mock server to track sequences and mutate subsequent responses.
// state/checkout-state.ts
type CheckoutState = 'idle' | 'pending' | 'processing' | 'complete'
const sessionState = new Map<string, CheckoutState>()
export function getState(sessionId: string): CheckoutState {
return sessionState.get(sessionId) ?? 'idle'
}
export function transition(sessionId: string, next: CheckoutState): void {
sessionState.set(sessionId, next)
}
export function reset(sessionId: string): void {
sessionState.delete(sessionId)
}
// mock-handlers/checkout.ts
import { http, HttpResponse } from 'msw'
import { getState, transition } from '../state/checkout-state'
export const checkoutHandlers = [
http.post('/api/checkout', ({ request }) => {
const sessionId = request.headers.get('x-session-id') ?? 'default'
transition(sessionId, 'pending')
return HttpResponse.json({ status: 'pending' }, { status: 201 })
}),
http.get('/api/checkout/status', ({ request }) => {
const sessionId = request.headers.get('x-session-id') ?? 'default'
const current = getState(sessionId)
if (current === 'pending') {
transition(sessionId, 'processing')
return HttpResponse.json({ status: 'processing' })
}
if (current === 'processing') {
transition(sessionId, 'complete')
return HttpResponse.json({ status: 'complete', orderId: 'ord_789xyz' })
}
return HttpResponse.json({ status: current })
}),
http.delete('/api/checkout/state', ({ request }) => {
const sessionId = request.headers.get('x-session-id') ?? 'default'
reset(sessionId)
return new HttpResponse(null, { status: 204 })
}),
]
The DELETE /api/checkout/state endpoint is your teardown hook — call it in afterEach so state never bleeds between test runs. This integrates cleanly with mock lifecycle management teardown patterns.
Step 3 — Inject controlled latency with jitter
Zero-delay mocks conceal an entire class of bugs. Apply a configurable base delay plus random jitter, and honour an X-Simulate-Timeout header so tests can exercise timeout handling and retry logic.
// middleware/latency.ts
import type { RequestHandler, Request, Response, NextFunction } from 'express'
const BASE_DELAY_MS = parseInt(process.env.MOCK_BASE_DELAY ?? '200', 10)
const MAX_JITTER_MS = parseInt(process.env.MOCK_JITTER ?? '150', 10)
const TIMEOUT_AFTER_MS = parseInt(process.env.MOCK_TIMEOUT_MS ?? '5000', 10)
export const latencyMiddleware: RequestHandler = (
req: Request,
res: Response,
next: NextFunction,
): void => {
if (req.headers['x-simulate-timeout'] === 'true') {
setTimeout(() => res.status(504).json({ error: 'Gateway Timeout' }), TIMEOUT_AFTER_MS)
return
}
const jitter = Math.floor(Math.random() * MAX_JITTER_MS)
setTimeout(next, BASE_DELAY_MS + jitter)
}
Expose the three env vars (MOCK_BASE_DELAY, MOCK_JITTER, MOCK_TIMEOUT_MS) so CI can set a lower floor while local development keeps a realistic one. This mirrors the network layer abstraction principle of controlling observable behaviour from environment config, not from code changes.
Step 4 — Gate every response through schema validation
Dynamic shaping must not produce payloads that violate the published API contract. A payload missing a required field is a silent contract breach that breaks consumers without any error at the mock layer.
Configure validate_response in your mock config:
# mock.config.yaml
mock:
port: 3000
validate_response: true
schema_ref: "./schemas/openapi.yaml"
strict_mode: true # reject extra properties not in schema
Add a CI lint step with spectral-cli:
# Install once
npm install --save-dev @stoplight/spectral-cli
# Add to package.json scripts
# "lint:api": "spectral lint ./schemas/openapi.yaml --ruleset .spectral.yaml --fail-severity warn"
npm run lint:api
.spectral.yaml ruleset (enforce response shape):
extends: ["spectral:oas"]
rules:
oas3-valid-media-example: error
oas3-schema: error
operation-operationId: warn
operation-description: warn
When drift is detected:
ERROR: Mock payload missing required field 'metadata.version' on GET /api/users
Expected: { "metadata": { "version": "string" } }
Got: { "metadata": {} }
Fix the response template, then confirm validation passes:
curl -s http://localhost:3000/api/users | jq '.metadata.version'
# Must return a non-null string — not null, not undefined.
This ties directly into schema-driven data generation — using the OpenAPI spec as the single source of truth for both mock generation and validation.
Step 5 — Configure proxy chaining and header hygiene
Unmatched routes should not 404 silently — they should fall through to a real upstream. Implement a strict fallback chain (local dynamic mock → staging proxy → live API) and strip mock-specific headers before forwarding.
// proxy/fallback.ts
import { createProxyMiddleware } from 'http-proxy-middleware'
import type { Application } from 'express'
const UPSTREAM = process.env.MOCK_UPSTREAM_URL ?? 'https://api.staging.internal'
const MOCK_HEADERS_TO_STRIP = [
'x-mock-delay',
'x-mock-scenario',
'x-simulate-timeout',
'x-request-seed',
]
export function attachProxyFallback(app: Application): void {
app.use(
'/api',
createProxyMiddleware({
target: UPSTREAM,
changeOrigin: true,
on: {
proxyReq: (proxyReq) => {
MOCK_HEADERS_TO_STRIP.forEach((h) => proxyReq.removeHeader(h))
},
error: (err, _req, res: any) => {
res.status(502).json({ error: 'Upstream unavailable', detail: err.message })
},
},
}),
)
}
For a full configuration, including routing tiers in Docker Compose, see proxy vs inline mocking strategies.
Verification
Run these three checks in sequence to confirm all five practices are active:
# 1. Deterministic seed — two identical calls must return identical bodies
HASH1=$(curl -s -H 'X-Request-ID: seed-test' http://localhost:3000/api/users/1 | sha256sum)
HASH2=$(curl -s -H 'X-Request-ID: seed-test' http://localhost:3000/api/users/1 | sha256sum)
[ "$HASH1" = "$HASH2" ] && echo "PASS: seed deterministic" || echo "FAIL: seed non-deterministic"
# 2. Stateful transition — first call returns pending, second returns processing
curl -s -H 'X-Session-ID: verify-01' http://localhost:3000/api/checkout/status | jq .status
# Expect: "pending"
curl -s -H 'X-Session-ID: verify-01' http://localhost:3000/api/checkout/status | jq .status
# Expect: "processing"
# 3. Latency floor — response time must be ≥200ms
curl -w 'Total: %{time_total}s\n' -o /dev/null -s http://localhost:3000/api/users/1
# Expect: Total: 0.2xx or higher
Gotchas and edge cases
-
Seed collisions across user IDs. If the seed is derived purely from
params.id(a short integer), low numeric IDs hash to the same low integer seed and Faker produces near-identical names. Prefix the seed with the resource type (user-${id},order-${id}) to guarantee namespace isolation. -
State machine survives hot-reload. In-memory state is held in module scope. When the mock server reloads in watch mode, the
Mapis discarded. If tests depend on state populated before the reload, they will silently pass for the wrong reason. Use a persistent store (SQLite, Redis) for state that must survive reloads, or make each test responsible for seeding its own state. -
Proxy stripping order matters.
http-proxy-middlewareprocessesproxyReqcallbacks synchronously, butonProxyReqfires after the proxy has already copied request headers. If you add a header in middleware after the proxy middleware is mounted, that header reaches the upstream. Mount header-stripping middleware before the proxy middleware in Express, not insideon.proxyReq, to guarantee removal.
Related
- Response Shaping Techniques — the parent topic covering the full range of shaping approaches
- Schema-Driven Data Generation — generating fixtures directly from OpenAPI schemas
- Deterministic Seed Management — propagating seeds across distributed mock services
- Proxy vs Inline Mocking Strategies — deciding when to route through a proxy vs intercept inline
← Back to Response Shaping Techniques