Tool-Specific Implementation & Setup

Without a reliable local mock stack, frontend engineers block on unavailable backends and integration tests burn flaky retries against staging endpoints that change without notice. Platform and QA teams bear the cost: slow feedback loops, environment drift, and incidents traced back to assumptions that held in staging but not in production.

Where Mock Tooling Sits in the Local-Dev Stack

Every local-dev simulation stack has three distinct interception surfaces. Understanding which layer a tool targets determines which problems it can solve — and which it cannot.

Mock Infrastructure Layers Diagram showing three interception layers: browser service worker (MSW), process-level HTTP (WireMock standalone), and network routing (local API gateway), with arrows indicating where each tool intercepts traffic. LAYER 1 — BROWSER Service Worker intercepts fetch / XHR before packets leave the tab MSW browser + Node server LAYER 2 — PROCESS / TCP PORT Standalone HTTP server accepts real TCP connections on a local port WireMock standalone / Spring Boot LAYER 3 — NETWORK ROUTING Reverse proxy or gateway routes paths to mock or real upstreams API Gateway nginx / Kong / Traefik Traffic flows top → bottom; each layer can intercept before reaching the next

The request interception patterns that underlie all three layers share the same goal — substitute a controlled response for a real upstream call — but the tradeoffs in latency, fidelity, and setup complexity differ significantly across layers.


Core Concept 1: Browser-Level Interception with MSW

MSW handler registration installs a Service Worker that sits between the browser’s network stack and the real network. Every fetch or XMLHttpRequest call passes through the worker; matched routes return synthetic responses and unmatched calls pass through to the real server unchanged. Crucially, the application code never needs to know mocks are active — there are no import stubs or monkey-patches to undo before shipping.

Environment-aware MSW initialisation (src/mocks/browser.ts):

import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';

// Activate only in development or when REACT_APP_ENABLE_MOCKS is set
const shouldEnableMocks =
  process.env.NODE_ENV === 'development' ||
  process.env.REACT_APP_ENABLE_MOCKS === 'true';

export async function startMocks(): Promise<void> {
  if (!shouldEnableMocks) return;

  const worker = setupWorker(...handlers);
  await worker.start({
    onUnhandledRequest: 'bypass', // real API calls reach the network
    quiet: process.env.CI === 'true'
  });
}

Call startMocks() at the application entry point (main.tsx) before rendering:

import { startMocks } from './mocks/browser';

startMocks().then(() => {
  ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
});

The await ensures handlers are registered before the first component renders, eliminating the race condition where a useEffect fires before the worker is ready.

For production CI runs that execute tests in Node, MSW exposes an identical server-side API. The same handler array powers both environments:

// src/mocks/server.ts (Node / test runner)
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

Advanced MSW handler patterns cover stateful response sequences, dynamic payload generation from request bodies, and conditional error injection — all essential once the basic intercept layer is stable.


Core Concept 2: Backend Simulation — Trade-offs Across Approaches

Choosing between MSW, WireMock, and a custom Express mock server comes down to five concrete dimensions. The table below anchors the decision in real engineering constraints rather than marketing claims.

Dimension MSW WireMock Standalone Express mock server
Interception layer Service Worker / Node fetch TCP port (real HTTP server) TCP port (Node process)
Language requirement JavaScript / TypeScript None (JVM runtime only) Node.js
Request schema validation Manual in handler Built-in (OpenAPI plugin) Manual
Traffic recording No Yes (--record-mappings) No
Docker image available No (needs app container) wiremock/wiremock Custom Dockerfile
Latency (local) ~0 ms (no TCP) 1–5 ms (loopback) 1–5 ms (loopback)
State machine / scenarios Custom resolver code Built-in scenario state Custom middleware
Best fit Frontend SPA / React / Next.js Backend contract testing, QA suites Lightweight custom logic

WireMock standalone configuration is the right choice when QA engineers need to record real API traffic against a staging environment, then replay it deterministically in offline CI runs. The mapping format is JSON, version-controllable, and platform-independent.

WireMock mapping with response templating (mappings/orders-create.json):

{
  "request": {
    "method": "POST",
    "urlPath": "/api/v1/orders",
    "headers": {
      "Content-Type": { "equalTo": "application/json" }
    },
    "bodyPatterns": [
      { "matchesJsonPath": "$.items[?(@.sku)]" }
    ]
  },
  "response": {
    "status": 201,
    "jsonBody": {
      "orderId": "{{randomValue length=12 type='ALPHANUMERIC'}}",
      "status": "PENDING",
      "itemCount": "{{jsonPath request.body '$.items.length()'}}",
      "createdAt": "{{now format='yyyy-MM-dd\\'T\\'HH:mm:ss\\'Z\\''}}"
    },
    "headers": {
      "Content-Type": "application/json",
      "X-Mock-Server": "wiremock"
    }
  }
}

The --global-response-templating flag enables Handlebars expressions across all mappings without per-mapping opt-in. This matters when you have dozens of fixtures and want consistent timestamp and correlation-ID generation site-wide.

The proxy vs inline mocking strategies analysis explores the deeper tradeoffs between letting traffic reach a real upstream (proxy) versus returning synthetic responses before any network egress occurs (inline), which is the fundamental choice behind selecting any of these tools.


Core Concept 3: Integration Surface — CI/CD and Environment Parity

Mock infrastructure that works on a developer’s laptop but fails in CI is worse than no mocks at all: it creates a false sense of coverage and then breaks at merge time. Dockerized mock environments solve this by packaging the mock server, its mapping files, and any seed data into a Compose service that starts identically everywhere.

Docker Compose orchestration (docker-compose.mock.yml):

services:
  mock-api:
    image: wiremock/wiremock:3.13.2
    ports:
      - "${MOCK_API_PORT:-8080}:8080"
    volumes:
      - ./mappings:/home/wiremock/mappings:ro
      - ./__files:/home/wiremock/__files:ro
    command:
      - "--global-response-templating"
      - "--verbose"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/__admin/health"]
      interval: 5s
      timeout: 3s
      retries: 6
      start_period: 10s
    networks:
      - dev-network

  mock-db:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: ${DB_USER:-dev}
      POSTGRES_PASSWORD: ${DB_PASS:-devpass}
      POSTGRES_DB: mock_data
    ports:
      - "${DB_PORT:-5432}:5432"
    volumes:
      - ./seeds:/docker-entrypoint-initdb.d:ro
    networks:
      - dev-network

networks:
  dev-network:
    driver: bridge

The healthcheck block is the critical addition most teams omit. Without it, docker compose up -d --wait exits as soon as the container starts — not when WireMock is ready to accept connections. The --wait flag respects healthcheck and blocks until all services report healthy.

GitHub Actions integration (.github/workflows/integration.yml):

name: Integration Tests

on: [push, pull_request]

env:
  MOCK_PORT: 8080
  CI: "true"

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - name: Start mock infrastructure
        run: |
          docker compose -f docker-compose.mock.yml up -d --wait
          echo "WireMock admin UI available at http://localhost:${MOCK_PORT}/__admin"

      - name: Run integration suite
        run: npm run test:integration
        env:
          API_BASE_URL: "http://localhost:${{ env.MOCK_PORT }}"

      - name: Collect logs and teardown
        if: always()
        run: |
          docker compose -f docker-compose.mock.yml logs > mock-logs.txt
          docker compose -f docker-compose.mock.yml down --volumes
      
      - name: Upload mock logs on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: mock-logs
          path: mock-logs.txt
          retention-days: 7

The if: always() teardown step ensures Docker containers and ephemeral volumes are cleaned up even when the test suite fails, preventing port conflicts on self-hosted runners.

This integration surface connects directly to mock lifecycle management principles: start, validate readiness, run workloads, and always clean up — in that order, with no shortcuts.


Core Concept 4: Operational Concerns — Health Checks, Hot Reload, and State Teardown

A mock infrastructure that requires a full container restart to pick up mapping changes slows development iteration to a crawl. The operational surface covers three concerns that most setup guides skip.

Health checks before traffic

Never assume a mock server is ready because its container started. The /__admin/health endpoint on WireMock returns 200 OK only once the embedded Jetty server is fully initialised. Gate all traffic on this:

# Wait up to 30 seconds for WireMock to become ready
until curl -sf http://localhost:8080/__admin/health; do
  echo "Waiting for WireMock..." && sleep 2
done
echo "WireMock ready"

For MSW in Playwright or Cypress tests, register the server and wait for server.listen() to resolve before navigation:

import { server } from './mocks/server';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

server.resetHandlers() between tests is mandatory — without it, handlers added inside individual test cases leak into subsequent tests and cause ordering-dependent failures.

Hot reload of WireMock mappings

WireMock 3.x supports live mapping reload via its admin API. No container restart required:

# Add or replace a mapping at runtime
curl -X POST http://localhost:8080/__admin/mappings \
  -H "Content-Type: application/json" \
  -d @mappings/new-endpoint.json

# Force a reload of all files from disk
curl -X POST http://localhost:8080/__admin/mappings/reset

Mount the mappings directory as a read-write volume (./mappings:/home/wiremock/mappings) when developing new stubs, then switch to :ro (read-only) in CI to prevent accidental mutation.

State teardown between test runs

Stateful WireMock scenarios accumulate state across requests. Reset to the initial scenario state before each test suite:

# Reset all scenario state machines to their initial state
curl -X POST http://localhost:8080/__admin/scenarios/reset

For MSW, server.resetHandlers() resets overrides added with server.use() but preserves the baseline handlers passed to setupServer(). This distinction matters: baseline handlers stay active; per-test overrides are removed.

Schema-driven data generation techniques apply directly here — seeding deterministic, schema-valid data before each test run and resetting it on teardown is the same lifecycle pattern applied to the data layer.


Decision Guide: Choosing the Right Tool

The right tool depends on four variables: where the application code runs, who owns the test suite, whether you need traffic recording, and how much JVM infrastructure your team is comfortable operating.

Mock Tool Decision Flowchart Flowchart guiding users from the question 'Where does your mock traffic originate?' through branches for browser, backend services, and multi-service routing to the recommended tool: MSW, WireMock, or API Gateway. Where does mock traffic originate? Start here Browser / SPA Backend / QA Multi-service MSW Zero-latency SW intercept WireMock Real TCP port, recording API Gateway Path-based routing Also need Node test support? ➜ msw/node + setupServer Need OpenAPI contract validation? ➜ WireMock + openapi-stub-gen Mix real + mock upstreams? ➜ nginx / Kong routing rules Wrap in Docker Compose for CI parity regardless of tool choice

Quick-reference checklist — use this before committing to a tool:

  • Does your application code run in a browser? → MSW is sufficient for all browser-originating traffic
  • Do you need a real TCP port that curl or Postman can hit? → WireMock or an Express mock server
  • Do you need to record real API traffic and replay it offline? → WireMock --record-mappings mode
  • Do you need to mix real and mock upstreams by path? → local API gateway routing with nginx or Kong
  • Does the mock need to run identically in CI and locally? → Docker Compose with pinned image tags
  • Do you need schema-valid, realistic test data? → Pair any tool with schema-driven data generation

Common Failure Modes and Mitigations

MSW worker not intercepting after deployment build

The Service Worker script (mockServiceWorker.js) must be copied to the public directory. MSW’s npx msw init public/ does this once, but it must be repeated after upgrades. Verify by opening DevTools → Application → Service Workers and checking the worker is registered and active. If it shows “redundant”, clear site data and reload.

WireMock returns 404 for a mapped path

WireMock URL matching is exact by default. /api/v1/orders and /api/v1/orders/ are different routes. Use urlPathPattern with a regex (/api/v1/orders(/.*)?) when trailing-slash ambiguity exists. Enable --verbose and watch the request log to see exactly what WireMock received.

Docker Compose port conflicts in CI

Self-hosted runners reuse workspace volumes across runs. Use docker compose down --volumes in teardown (not just down) to remove anonymous volumes. For port conflicts, always specify ports via env vars (${MOCK_PORT:-8080}) and set them explicitly in the CI environment, never rely on defaults matching.

MSW onUnhandledRequest: 'error' breaking tests for third-party scripts

Browser environments load analytics, error-tracking, and font scripts that trigger unhandled requests. Add a filter function instead of a string mode:

worker.start({
  onUnhandledRequest(request, print) {
    // Ignore third-party domains
    if (!request.url.includes('localhost')) return;
    print.error();
  }
});

WireMock scenario state leaking between test files

Parallel test runners may reset scenario state mid-test in another worker. If tests are parallelised, either run one WireMock instance per parallel worker (different ports) or disable scenario-dependent tests from running in parallel with --runInBand (Jest) or equivalent.

nginx gateway not proxying correctly to a Docker service

When nginx runs on the host and WireMock runs in Docker, 127.0.0.1 in nginx.conf does not reach the Docker network. Use host.docker.internal (Docker Desktop) or place nginx inside the same Compose network and reference the service name (mock-api:8080) directly. The running WireMock in Docker Compose guide covers cross-container networking in detail.


Local API Gateway — Routing Mock and Real Traffic Together

The third interception layer becomes necessary when a single application needs some paths resolved by a local mock and others forwarded to a real upstream. A developer building a checkout flow might want /api/v1/products served by WireMock while /auth/token hits a real identity provider, because mocking OAuth flows introduces more complexity than it removes.

nginx reverse proxy configuration (nginx.conf):

http {
  upstream mock_backend {
    server 127.0.0.1:8080;  # WireMock
  }

  upstream real_auth {
    server auth.internal.example.com:443;
  }

  server {
    listen 3000;

    # Route versioned API calls to local mock
    location ~ ^/api/v[12]/ {
      proxy_pass http://mock_backend;
      proxy_set_header Host $host;
      proxy_set_header X-Mock-Environment "local";
      proxy_connect_timeout 2s;
    }

    # Forward authentication to real IdP
    location /auth/ {
      proxy_pass https://real_auth;
      proxy_ssl_server_name on;
      proxy_set_header Host auth.internal.example.com;
    }

    # Return structured error for any unconfigured route
    location / {
      default_type application/json;
      return 404 '{"error":"route_not_configured","hint":"add a location block in nginx.conf"}';
    }
  }
}

The local API gateway routing cluster covers Kong and Traefik alternatives, environment-variable-driven route switching, and how to hot-swap upstreams without restarting the gateway process.


FAQ

Should I use MSW or WireMock for a frontend project?

Use MSW when all mock traffic originates in the browser — it intercepts at the Service Worker layer with zero network round-trips and no process to manage. Reach for WireMock when backend services or CI pipelines need a standalone HTTP server that records, validates, and replays interactions against a real TCP port. Many teams run both simultaneously: MSW for browser tests, WireMock for server-side integration tests.

How do I prevent mock code from shipping to production?

Guard all mock initialisation behind an env-var check (NODE_ENV === 'development' or an explicit ENABLE_MOCKS flag). Modern bundlers (Vite, webpack, esbuild) tree-shake the entire import path when the condition is statically false at build time, so no mock code reaches the production bundle.

Can MSW and WireMock run simultaneously in the same project?

Yes. MSW intercepts browser-originating fetch and XMLHttpRequest calls inside the Service Worker; WireMock listens on a TCP port for server-side or native HTTP calls. They occupy different network layers and do not conflict. The pattern is common in full-stack apps where the server-side renderer calls WireMock and the client-side fetch calls are intercepted by MSW.

How do I make mock environments match CI exactly?

Use Docker Compose to co-locate mock servers, databases, and application services. Pin image tags (wiremock/wiremock:3.13.2, not latest), mount mapping volumes read-only, and expose port numbers via env vars that CI sets explicitly. The --wait flag on docker compose up combined with a healthcheck block guarantees services are ready before tests start.

What causes unhandled-request warnings in MSW?

Any fetch call that does not match a registered handler triggers the warning. Set onUnhandledRequest: 'bypass' to let unmatched calls reach the real network, or 'error' to fail the test immediately. In browser environments with third-party scripts, use the function form to filter by hostname so analytics and font requests do not trigger false failures.


← Back to Home