Response Shaping Techniques
This page covers how to control every dimension of an HTTP mock response — status codes, headers, body structure, latency, and multi-step state — and how to wire those controls into a local dev stack and CI pipeline. It does not cover how requests are intercepted or routed to the mock layer; that is handled by request interception patterns.
Prerequisites
- Node.js 18+ (for MSW examples) or Java 17+ (for WireMock examples)
- MSW 2.x installed (
npm install msw --save-dev) or WireMock standalone JAR / Docker image - An
MOCK_LATENCY_MSenvironment variable convention agreed on with your team (default0) - A local OpenAPI/JSON Schema document for the API you are mocking (used in Phase 3 validation)
- Familiarity with request interception patterns so you understand where in the request lifecycle shaping rules execute
Phase 1 — Core Setup: Static and Dynamic Response Handlers
The simplest shaping rule is a static fixture: a fixed status code, fixed headers, and a hardcoded JSON body. Start here to establish a contract baseline, then graduate to dynamic templates as your test scenarios grow.
MSW static handler (TypeScript)
// src/mocks/handlers/orders.ts
import { http, HttpResponse } from 'msw'
export const ordersHandlers = [
http.get('/api/orders/:id', ({ params }) => {
return HttpResponse.json(
{
id: params.id,
status: 'fulfilled',
total: 149.99,
currency: 'USD',
items: [
{ sku: 'WIDGET-001', qty: 2, unitPrice: 74.995 }
]
},
{ status: 200 }
)
})
]
MSW dynamic handler — rotating status and UUIDs
Once your static fixture covers the happy path, add a dynamic variant that rotates across states. Use a simple counter to cycle through active, pending, and failed without persisting server-side state:
// src/mocks/handlers/orders.ts
import { http, HttpResponse } from 'msw'
import { v4 as uuidv4 } from 'uuid'
const statusCycle: Array<'active' | 'pending' | 'failed'> = ['active', 'pending', 'failed']
let callCount = 0
export const ordersHandlers = [
http.get('/api/orders/:id', ({ params, request }) => {
const latencyMs = Number(process.env.MOCK_LATENCY_MS ?? 0)
const status = statusCycle[callCount++ % statusCycle.length]
const body = {
id: params.id,
correlationId: uuidv4(),
status,
resolvedAt: status === 'fulfilled' ? new Date().toISOString() : null
}
// Artificial latency is injected here; in unit tests set MOCK_LATENCY_MS=0
if (latencyMs > 0) {
return new Promise((resolve) =>
setTimeout(
() => resolve(HttpResponse.json(body, { status: 200 })),
latencyMs
)
)
}
return HttpResponse.json(body, { status: 200 })
})
]
WireMock dynamic template
WireMock’s Handlebars-based response templating covers the same ground without Node.js:
{
"request": {
"method": "GET",
"urlPathPattern": "/api/orders/[0-9]+"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json",
"X-RateLimit-Remaining": "{{randomValue length=3 type='NUMERIC'}}",
"X-Correlation-Id": "{{randomValue type='UUID'}}"
},
"jsonBody": {
"id": "{{request.pathSegments.[2]}}",
"correlationId": "{{randomValue type='UUID'}}",
"status": "{{pickRandom 'active' 'pending' 'failed'}}",
"createdAt": "{{now format='yyyy-MM-dd\\'T\\'HH:mm:ssZ'}}"
},
"fixedDelayMilliseconds": 0,
"transformers": ["response-template"]
}
}
Save this file to your wiremock/mappings/ directory; it is picked up automatically on server start or after a POST /__admin/mappings/reset.
Phase 2 — Configuration and Wiring: Fault Injection, Headers, and Latency
Injecting error codes and fault profiles
Testing retry logic and exponential backoff requires your mock to return 4xx and 5xx codes on demand. Gate fault injection behind an environment variable so it stays off in unit tests and activates only in integration or chaos-testing stages:
// src/mocks/handlers/faults.ts
import { http, HttpResponse } from 'msw'
type FaultProfile = 'rate-limit' | 'server-error' | 'timeout' | 'none'
const faultProfile = (process.env.MOCK_FAULT_PROFILE ?? 'none') as FaultProfile
export const faultHandlers = [
http.post('/api/payments', async ({ request }) => {
if (faultProfile === 'rate-limit') {
return new HttpResponse(null, {
status: 429,
headers: {
'Retry-After': '30',
'X-RateLimit-Limit': '100',
'X-RateLimit-Remaining': '0'
}
})
}
if (faultProfile === 'server-error') {
return HttpResponse.json(
{ error: 'upstream_timeout', retryable: true },
{ status: 503 }
)
}
if (faultProfile === 'timeout') {
// Never resolves — triggers client-side timeout logic
return new Promise(() => {})
}
// Default: happy path
const body = await request.json() as Record<string, unknown>
return HttpResponse.json(
{ transactionId: crypto.randomUUID(), status: 'accepted' },
{ status: 202 }
)
})
]
In your CI YAML, activate a fault stage after your normal integration tests:
# .github/workflows/integration.yml
jobs:
integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- name: Run happy-path integration tests
run: npm run test:integration
env:
MOCK_FAULT_PROFILE: none
MOCK_LATENCY_MS: 0
- name: Run fault-injection integration tests
run: npm run test:integration
env:
MOCK_FAULT_PROFILE: rate-limit
MOCK_LATENCY_MS: 0
- name: Run latency stress tests
run: npm run test:integration
env:
MOCK_FAULT_PROFILE: none
MOCK_LATENCY_MS: 600
Docker Compose service definition for WireMock
When your mock server must be accessible to multiple containers — a frontend dev server, a Node BFF, and a Playwright E2E runner simultaneously — run WireMock as a named Docker Compose service rather than a per-process in-memory mock. This decision is explored in detail under proxy vs inline mocking strategies, but the service definition itself looks like this:
# docker-compose.yml
services:
wiremock:
image: wiremock/wiremock:3.5.4
ports:
- "8080:8080"
volumes:
- ./wiremock/mappings:/home/wiremock/mappings
- ./wiremock/files:/home/wiremock/__files
environment:
- WIREMOCK_OPTIONS=--global-response-templating --verbose
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/__admin/health"]
interval: 5s
timeout: 3s
retries: 10
frontend:
build: .
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_API_BASE=http://wiremock:8080
depends_on:
wiremock:
condition: service_healthy
MSW worker registration for browser environments
For browser-based development, MSW handler registration happens at service-worker level. Wire in your shaping handlers before the dev server starts:
// src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { ordersHandlers } from './handlers/orders'
import { faultHandlers } from './handlers/faults'
export const worker = setupWorker(...ordersHandlers, ...faultHandlers)
// src/main.tsx (or app entry point)
async function enableMocking() {
if (process.env.NODE_ENV !== 'development') return
const { worker } = await import('./mocks/browser')
return worker.start({
onUnhandledRequest: 'warn'
})
}
enableMocking().then(() => {
// Mount your React/Vue/etc app here
})
Phase 3 — Integration: Stateful Sequences, Pagination, and Contract Validation
Simulating pagination
Pagination requires the mock to remember cursor position across sequential requests. An in-memory store keyed on a session identifier is sufficient for local development:
// src/mocks/handlers/pagination.ts
import { http, HttpResponse } from 'msw'
const PAGE_SIZE = 20
const TOTAL_ITEMS = 87
// Session-scoped page offset store; cleared between test runs
const pageStore = new Map<string, number>()
export const paginationHandlers = [
http.get('/api/products', ({ request }) => {
const url = new URL(request.url)
const sessionId = request.headers.get('x-session-id') ?? 'default'
const cursor = url.searchParams.get('cursor')
let offset = 0
if (cursor) {
offset = pageStore.get(`${sessionId}:${cursor}`) ?? 0
}
const nextOffset = offset + PAGE_SIZE
const hasMore = nextOffset < TOTAL_ITEMS
const nextCursor = hasMore ? Buffer.from(String(nextOffset)).toString('base64') : null
if (hasMore && nextCursor) {
pageStore.set(`${sessionId}:${nextCursor}`, nextOffset)
}
const items = Array.from({ length: Math.min(PAGE_SIZE, TOTAL_ITEMS - offset) }, (_, i) => ({
id: `prod-${offset + i + 1}`,
name: `Product ${offset + i + 1}`,
price: ((offset + i + 1) * 9.99).toFixed(2)
}))
return HttpResponse.json({
items,
meta: {
total: TOTAL_ITEMS,
pageSize: PAGE_SIZE,
nextCursor,
hasMore
}
})
})
]
WireMock scenario state machine for multi-step flows
WireMock’s built-in scenario API models multi-step sequences without external state. This is the correct tool for simulating a two-phase payment flow where the first POST returns 202 Accepted and the subsequent GET on the same resource returns 200 with a completed status. See mock lifecycle management for how to reset scenarios between test runs.
[
{
"scenarioName": "payment-flow",
"requiredScenarioState": "Started",
"newScenarioState": "payment-accepted",
"request": { "method": "POST", "url": "/api/payments" },
"response": {
"status": 202,
"jsonBody": { "transactionId": "txn-001", "status": "processing" }
}
},
{
"scenarioName": "payment-flow",
"requiredScenarioState": "payment-accepted",
"request": { "method": "GET", "url": "/api/payments/txn-001" },
"response": {
"status": 200,
"jsonBody": { "transactionId": "txn-001", "status": "completed", "settledAt": "2026-06-21T10:00:00Z" }
}
}
]
Contract validation in CI
Shaped responses must stay in sync with your OpenAPI specification. Add a validation step that captures mock output and runs it through your schema:
// scripts/validate-mock-responses.ts
import Ajv from 'ajv'
import addFormats from 'ajv-formats'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const ajv = new Ajv({ allErrors: true })
addFormats(ajv)
const spec = JSON.parse(
readFileSync(resolve('openapi.json'), 'utf8')
)
// Validate a response body against the spec's response schema
export function validateResponse(
path: string,
method: string,
statusCode: number,
body: unknown
): void {
const responseSchema =
spec.paths[path]?.[method.toLowerCase()]?.responses?.[statusCode]?.content?.[
'application/json'
]?.schema
if (!responseSchema) {
throw new Error(`No schema found in spec for ${method} ${path} → ${statusCode}`)
}
const validate = ajv.compile(responseSchema)
const valid = validate(body)
if (!valid) {
throw new Error(
`Mock response for ${method} ${path} → ${statusCode} failed schema validation:\n` +
JSON.stringify(validate.errors, null, 2)
)
}
}
For schema-driven data generation, generate your fixture seeds directly from the OpenAPI schema rather than hand-authoring them, so contract drift is structurally impossible.
Verification Steps
Run these checks after setting up shaping rules to confirm everything is wired correctly.
-
Happy path returns 200 with correct body shape:
curl -s http://localhost:8080/api/orders/42 | jq '.status' # Expected output: "active" | "pending" | "failed" (cycling) -
Rate-limit fault returns 429 with Retry-After header:
MOCK_FAULT_PROFILE=rate-limit curl -sv http://localhost:8080/api/payments \ -X POST -H 'Content-Type: application/json' -d '{}' # Expected: HTTP/1.1 429, Retry-After: 30 in response headers -
Latency injection delays response by configured amount:
time curl -s -o /dev/null http://localhost:8080/api/orders/42 # With MOCK_LATENCY_MS=500, real time should be ≥ 0.500s -
Pagination returns correct cursor and item count:
curl -s 'http://localhost:8080/api/products' | jq '{hasMore: .meta.hasMore, count: (.items | length)}' # Expected: {"hasMore": true, "count": 20} -
WireMock scenario resets cleanly between test runs:
curl -X POST http://localhost:8080/__admin/scenarios/reset # Expected: HTTP 200 with no body -
Contract validation passes for all shaped responses:
npx ts-node scripts/validate-mock-responses.ts # Expected: exits 0 with no validation errors logged
Troubleshooting
TypeError: Cannot read properties of undefined (reading 'status') in MSW handler
Cause: The request body was read as JSON but the Content-Type header was missing or incorrect in the test request, causing request.json() to throw before the handler returns.
Fix: Ensure the test client sets Content-Type: application/json. In MSW 2.x, add a guard:
const contentType = request.headers.get('content-type') ?? ''
if (!contentType.includes('application/json')) {
return new HttpResponse('Unsupported Media Type', { status: 415 })
}
const body = await request.json()
WireMock returns 404 Not Found for a mapped route
Cause: The mapping file has a JSON syntax error, or the urlPathPattern regex does not match the incoming path exactly. WireMock silently skips malformed mappings on startup.
Fix:
# Check the admin API for loaded mappings
curl -s http://localhost:8080/__admin/mappings | jq '.mappings | length'
# If count is lower than expected, inspect startup logs:
docker logs wiremock 2>&1 | grep -i "error\|warn\|exception"
Latency injection causes E2E test timeout exceeded in CI
Cause: MOCK_LATENCY_MS is set globally in the environment and applies to every request, including health-check polls and asset fetches, making cumulative wait time exceed the test runner timeout.
Fix: Apply latency only to the specific endpoints under test, not globally. In WireMock use per-mapping fixedDelayMilliseconds; in MSW use a handler-level setTimeout guard gated on the request path.
MSW onUnhandledRequest: 'error' breaks tests for third-party endpoints
Cause: Your app fetches analytics, font, or CDN assets that are not covered by any handler, and the strict mode rejects them.
Fix: Use onUnhandledRequest: 'bypass' for known external origins, or add explicit passthrough handlers:
import { http, passthrough } from 'msw'
export const passthroughHandlers = [
http.get('https://fonts.googleapis.com/*', () => passthrough()),
http.get('https://analytics.example.com/*', () => passthrough())
]
pickRandom or randomValue template helpers not resolving in WireMock responses
Cause: Response templating is not enabled. WireMock requires explicit activation via the --global-response-templating flag or per-mapping "transformers": ["response-template"].
Fix: Add --global-response-templating to the WireMock startup command, or add the transformer to each mapping that uses template helpers:
{
"response": {
"status": 200,
"jsonBody": { "id": "{{randomValue type='UUID'}}" },
"transformers": ["response-template"]
}
}
Contract validation reports schema drift after an API update
Cause: The OpenAPI spec was updated by the backend team but the mock fixtures were not regenerated, so the shaped response no longer matches the current schema.
Fix: Run validate-mock-responses.ts as a mandatory pre-merge CI step. Treat a validation failure as a required check: the fix is to regenerate fixtures from the updated spec using schema-driven data generation rather than patching the fixture by hand.
When to Advance
You have response shaping correctly implemented when:
- Every endpoint your frontend calls has at least a happy-path handler in your mock layer
- Fault injection (4xx/5xx) is gated behind an environment variable and documented in your team’s CI runbook
- Shaped responses are validated against your OpenAPI schema in at least one CI stage
- Latency injection is configurable per environment without code changes
- WireMock scenario states (or MSW stateful handlers) cover every multi-step flow your UI needs to exercise
- Mock definitions live in version control and are deployed alongside application code
When all of the above are in place, move on to best practices for dynamic response shaping for advanced deterministic state machines, idempotency key simulation, and correlated request-response chain patterns.
FAQ
When should I use dynamic response templates instead of static JSON fixtures?
Use dynamic templates when you need to simulate pagination cursors, rotating UUIDs, time-sensitive timestamp fields, or conditional error paths that vary by request parameter. Static fixtures are sufficient for pure contract validation where payload variance is irrelevant and test reproducibility is the priority.
How do I inject latency without slowing my entire test suite?
Gate latency behind MOCK_LATENCY_MS and set it to 0 in your unit and fast-integration test runs. Activate a non-zero value only in dedicated performance or E2E stages. Apply the delay at handler level rather than globally so health-check requests and non-API fetches remain fast.
Can response shaping simulate rate-limit and retry-after flows?
Yes. Return 429 with a Retry-After header on the first request, then 200 on the second. In MSW use a stateful counter; in WireMock use a two-state scenario. Both approaches are covered in Phase 2 above.
How do I keep shaped mock responses in sync with my OpenAPI spec?
Run validate-mock-responses.ts (or an equivalent Ajv-based check) as a required CI gate on every pull request. For schema-driven data generation, generate fixture seeds from the OpenAPI spec directly so drift is structurally impossible rather than caught after the fact.
Related
- Best Practices for Dynamic Response Shaping — deterministic state machines, idempotency keys, and correlated request-response chain patterns
- Request Interception Patterns — where in the network stack shaping rules are applied
- Proxy vs Inline Mocking Strategies — choosing between shared WireMock containers and per-process MSW handlers
- Mock Lifecycle Management — starting, resetting, and tearing down mock servers between test runs
- Schema-Driven Data Generation — generating fixture data from OpenAPI specs to prevent contract drift
← Back to API Mocking Fundamentals & Architecture