When to Use Proxy vs Inline Mocking

Choosing the wrong interception layer produces one of two failure modes: a unit suite that passes locally but fails in CI because it never validates real network behaviour, or an integration pipeline that takes twelve minutes to run because every test round-trips through a socket. This page resolves that decision with concrete thresholds — not rules of thumb.

Context: where the two strategies diverge

Both approaches serve the same goal — replacing a real upstream API with controlled responses — but they intercept requests at fundamentally different points in the stack.

Proxy vs inline mocking strategies diverge at the transport boundary. A proxy sits between the network adapter and the upstream server: the application makes a genuine TCP connection, which the proxy intercepts. An inline mock replaces the network call itself inside the JavaScript runtime — fetch, XMLHttpRequest, or an HTTP client instance — so no socket is ever opened.

The environment and test-suite characteristics that make one choice correct make the other wrong.

Proxy vs Inline Mocking — Interception Points On the left, the proxy intercepts at the network transport layer between the app and the upstream API. On the right, the inline mock replaces the fetch/XHR call inside the JS runtime before any socket is opened. Proxy Mocking Inline Mocking App / Browser real TCP Proxy / Mock Server blocked Upstream API HERE JS Runtime (fetch / XHR / axios) Inline Mock Handler HERE — no socket opened — Upstream API Left: proxy intercepts at the transport layer. Right: inline mock intercepts inside the runtime — no TCP connection.

Solution: five decision thresholds

Work through these in order. The first threshold that resolves conclusively determines your choice for that layer.

1. CORS and pre-flight validation

If the environment enforces strict origin headers during local development — for example, a browser integration test that must exercise the OPTIONS pre-flight and verify Access-Control-Allow-Origin — proxy mocking is the only viable option. The request interception pattern that inline mocks use bypasses the browser’s network stack entirely, so pre-flight requests are never sent and CORS cannot be validated.

2. Test-suite size and CI budget

Inline mocks execute in under 2 ms per request because no socket is involved. Proxy mocks add 15–50 ms due to localhost socket routing. At 500 or more HTTP-dependent tests, inline mocking reduces CI runtime by 40–60%. If your suite is smaller or if realistic latency is a test concern, proxy mocking’s overhead is negligible.

3. State mutation across sequential requests

If mocks must carry state across a sequence — a POST /orders creates a record and a subsequent GET /orders/:id must return it — use a stateful proxy server such as WireMock with scenario state. Inline mocks can replicate this but require explicit manual state management between handler invocations, which becomes brittle as sequences lengthen. The mock lifecycle management principles that govern startup, state reset, and teardown apply most naturally to proxy-level servers.

4. Framework coupling and tool reuse

Inline mocks bind to a specific test runner (Jest, Vitest) and HTTP client. A proxy mock is framework-agnostic: Playwright, Cypress, Postman, and a shell script can all issue requests against the same running WireMock or Prism instance without any runner-specific configuration. Platform teams standardising mock routing across polyglot services should favour the proxy layer.

5. Silent failure diagnosis

Proxy mocks fail silently in specific patterns: changeOrigin: true missing causes host-header rejection; the bypass function accidentally intercepting WebSocket upgrade requests drops long-polling connections; and misconfigured X-Forwarded-For headers cause authentication middleware to reject requests. Inline mock failures are immediate and visible — a missing handler throws synchronously into the test output.

Step-by-step implementation

Proxy mocking with Vite dev server

This configuration proxies /api/v2 to a local Prism or WireMock instance and uses a bypass function to exclude health-check endpoints:

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  server: {
    proxy: {
      '/api/v2': {
        target: 'http://localhost:4010',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api\/v2/, ''),
        bypass(req) {
          // Exclude health checks from mock routing
          if (req.url === '/api/v2/health') return req.url;
          // Pass X-Mock-Scenario header through to WireMock
          req.headers['x-mock-scenario'] = process.env.MOCK_SCENARIO ?? 'default';
        }
      }
    }
  }
});

Start the mock server before the dev server:

# Start WireMock on port 4010
docker run --rm -p 4010:8080 \
  -v "$(pwd)/wiremock:/home/wiremock" \
  wiremock/wiremock:3.3.1 \
  --global-response-templating \
  --stateful-scenarios

Inline mocking with Vitest and axios-mock-adapter

This setup guarantees zero state leakage between parallel test workers by constructing and restoring the adapter inside each test lifecycle:

// __tests__/setup/axios-mock.ts
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { beforeEach, afterEach } from 'vitest';

let mockAdapter: MockAdapter;

beforeEach(() => {
  mockAdapter = new MockAdapter(axios, { onNoMatch: 'throwException' });
});

afterEach(() => {
  mockAdapter.restore();
});

export function getMockAdapter(): MockAdapter {
  return mockAdapter;
}

Register it in vitest.config.ts:

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    setupFiles: ['__tests__/setup/axios-mock.ts'],
    pool: 'forks', // isolate module registries between workers
  }
});

MSW as a hybrid layer

MSW handler registration bridges both strategies: setupWorker operates at the Service Worker layer (network-level, browser context) while setupServer intercepts at the Node runtime layer (inline, no socket). Use setupWorker for Playwright integration tests that need realistic network behaviour, and setupServer for Vitest unit suites.

// src/mocks/server.ts — Node/Vitest inline variant
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
// src/mocks/browser.ts — Service Worker proxy-like variant
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';

export const worker = setupWorker(...handlers);

The handlers array is shared — this is the critical advantage: a single source of truth for mock definitions used by both layers.

Verification

After configuring either layer, confirm correct interception with a targeted assertion:

# Proxy: verify the mock server is receiving routed requests
curl -s -o /dev/null -w "%{http_code}" \
  -H "x-mock-scenario: default" \
  http://localhost:5173/api/v2/users/1
# Expected: 200 (from WireMock stub, not upstream)

# Inline: verify no real network calls escape the test suite
VITEST_POOL=forks npx vitest run --reporter=verbose 2>&1 | grep "Network request"
# Expected: zero lines (no unhandled network requests)

For proxy mocking, inspect the WireMock journal to confirm requests are being matched and not falling through to unmatched-request errors:

curl -s http://localhost:4010/__admin/requests | jq '.requests | length'
# Should increment with each test run

Gotchas and edge cases

  • WebSocket upgrade interception. Vite’s proxy bypass function receives WebSocket upgrade requests. If the function returns undefined for those requests, Vite attempts to proxy them to the mock server, which typically does not speak the WebSocket protocol. Add an explicit check: if (req.headers.upgrade === 'websocket') return req.url; to pass WebSocket traffic through directly.

  • Inline mock order sensitivity. When using MSW’s server.use() to override handlers inside a test, the override is prepended to the handler list — the first matching handler wins. If a previous server.use() call from a test that ran earlier was not cleaned up (missing server.resetHandlers()), it silently takes precedence over your new handler. Always call server.resetHandlers() in afterEach, not just afterAll.

  • Parallel worker module isolation. With Vitest’s --pool=threads, worker threads share a module registry by default. A singleton axios instance imported at module scope is shared between tests in different files. Switch to --pool=forks or restructure the client as a factory function to guarantee per-test isolation when using inline mocks.


← Back to Proxy vs Inline Mocking Strategies