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:

Dynamic response shaping pipeline Five-stage pipeline: client request enters seed resolver, passes through state machine, latency injector, schema validator, and finally the proxy fallback chain before a response is returned. Client Request 1. Seed Derive from X-Request-ID or ENV var 2. State Transition machine → template 3. Latency Base + jitter timeout simulation 4. Schema Validate vs OpenAPI / JSON Schema 5. Proxy fallback chain local mock → staging → live API

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 Map is 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-middleware processes proxyReq callbacks synchronously, but onProxyReq fires 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 inside on.proxyReq, to guarantee removal.


← Back to Response Shaping Techniques