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.


Response shaping pipeline A flow diagram showing an incoming HTTP request passing through a route matcher into four parallel shaping rules — status code, response headers, body template, and latency injection — which merge into a single shaped HTTP response that is returned to the caller. HTTP Request GET /orders/42 Route Matcher path / method header predicates Status Code 200 / 4xx / 5xx Response Headers Content-Type, CORS, rate-limit Body Template static / dynamic / schema Latency Injection fixed / jitter / env-var Shaped Response 200 OK + JSON body + headers + delay

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_MS environment variable convention agreed on with your team (default 0)
  • 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.


← Back to API Mocking Fundamentals & Architecture