Proxy vs Inline Mocking Strategies

This page covers the two dominant architectural approaches for substituting real API dependencies during local development and automated testing — network-layer proxy interception and application-embedded inline mocking — and does not extend into contract testing or schema generation, which are separate concerns.

Prerequisites

  • Docker and Docker Compose v2+ installed (docker compose version)
  • Node.js 18+ with npm or pnpm for MSW-based inline setups
  • An OpenAPI 3.x specification file for your target API (used by Prism)
  • Basic familiarity with request interception patterns — specifically how middleware captures outbound HTTP traffic
  • Environment variable management in place (NODE_ENV, or a VITE_MOCK_ENABLED flag)
  • A CI runner that supports Docker service containers (GitHub Actions, GitLab CI, or CircleCI)

Proxy vs Inline Mocking Architecture Left side shows the proxy architecture: App makes HTTP request, Nginx proxy intercepts, routes to mock server (Prism/WireMock), which returns a shaped response. Right side shows inline architecture: App bootstrap registers MSW handlers; outbound fetch is intercepted inside the runtime by the Service Worker or in-process handler, which returns a response without any network hop. Proxy Architecture network-layer interception Application fetch('/api/...') Nginx / Dev Proxy routes /api → mock Mock Server Prism / WireMock shaped response + zero app-code changes + polyglot service support − extra infrastructure − network hop latency Inline Architecture application-embedded interception Application fetch('/api/...') MSW / In-Process handler intercepts fetch no network hop Service Worker / Node response + sub-ms test execution + co-located with business code − couples mock to app build − requires strict tree-shaking

Phase 1 — Core Setup

Proxy setup: Nginx routing to Prism

The proxy approach keeps mock configuration entirely outside the application codebase. A Stoplight Prism container serves responses derived from your OpenAPI spec; Nginx routes matching path prefixes to it.

# docker-compose.mock.yml
services:
  api-proxy:
    image: nginx:1.27-alpine
    volumes:
      - ./infra/mock/proxy.conf:/etc/nginx/conf.d/default.conf:ro
    ports:
      - "8080:80"
    depends_on:
      mock-server:
        condition: service_healthy
    networks:
      - mock-net

  mock-server:
    image: stoplight/prism:4
    command: mock -h 0.0.0.0 /specs/openapi.yaml
    volumes:
      - ./specs:/specs:ro
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:4010/__admin/health"]
      interval: 5s
      retries: 6
    networks:
      - mock-net

networks:
  mock-net:
    driver: bridge
# infra/mock/proxy.conf
server {
    listen 80;

    location /api/v1/ {
        proxy_pass         http://mock-server:4010/;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Mock-Scenario   $http_x_mock_scenario;
        proxy_connect_timeout 2s;
        proxy_read_timeout    10s;
    }

    location /health {
        return 200 "ok";
        add_header Content-Type text/plain;
    }
}

Start the stack and verify the proxy is reachable:

docker compose -f docker-compose.mock.yml up -d
curl -s http://localhost:8080/health       # → ok
curl -s http://localhost:8080/api/v1/users | jq .

Inline setup: MSW in a Vite + React project

Inline mocking using MSW handler registration requires three artefacts: a worker script installed at the root of your public directory, an http handler module, and a conditional bootstrap that activates only outside production.

npx msw init public/ --save
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/v1/users', () => {
    return HttpResponse.json(
      [
        { id: '1', name: 'Alice Okafor', role: 'admin' },
        { id: '2', name: 'Ben Strauss',  role: 'viewer' },
      ],
      { status: 200 }
    )
  }),

  http.post('/api/v1/sessions', async ({ request }) => {
    const body = await request.json() as { email: string }
    if (!body.email) {
      return HttpResponse.json({ error: 'email required' }, { status: 400 })
    }
    return HttpResponse.json({ token: 'mock-jwt-abc123' }, { status: 201 })
  }),
]
// src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'

export const worker = setupWorker(...handlers)
// src/main.tsx
async function bootstrap() {
  if (import.meta.env.VITE_MOCK_ENABLED === 'true') {
    const { worker } = await import('./mocks/browser')
    await worker.start({ onUnhandledRequest: 'warn' })
  }

  const root = document.getElementById('root')!
  ReactDOM.createRoot(root).render(<App />)
}

bootstrap()
# .env.development.local
VITE_MOCK_ENABLED=true

Phase 2 — Configuration and Wiring

Proxy: scenario-driven routing with request headers

Passing a custom header (X-Mock-Scenario) from the application through the Nginx proxy to Prism allows tests to select different response examples without changing OpenAPI fixtures. Prism picks the matching x-mock-response-name example from the spec automatically when you pass Prefer: example=<name>.

# Extended proxy.conf with scenario forwarding
location /api/v1/ {
    proxy_pass       http://mock-server:4010/;
    proxy_set_header Prefer "example=$http_x_mock_scenario";
    proxy_hide_header X-Mock-Scenario;
}
// Application fetch wrapper that activates scenario selection
export async function apiFetch(path: string, scenario?: string) {
  const headers: HeadersInit = { 'Content-Type': 'application/json' }
  if (scenario) {
    headers['X-Mock-Scenario'] = scenario
  }
  const res = await fetch(`/api/v1${path}`, { headers })
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  return res.json()
}

Inline: environment-variable guards and Node.js integration

For server-side rendering or Node.js test suites, replace the browser Service Worker with MSW’s setupServer from msw/node. This applies the same handler definitions without any Service Worker registration, keeping test setup minimal.

// src/mocks/server.ts  (Node / Vitest / Jest only)
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)
// vitest.setup.ts
import { beforeAll, afterAll, afterEach } from 'vitest'
import { server } from './src/mocks/server'

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    environment: 'jsdom',
    setupFiles: ['./vitest.setup.ts'],
    env: { VITE_MOCK_ENABLED: 'true' },
  },
})

Vite’s module resolution alias lets you swap the entire API client module at build time — useful when the proxy is the canonical integration target but you want inline mocks for unit tests:

// vite.config.ts
import { defineConfig, loadEnv } from 'vite'

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '')
  return {
    resolve: {
      alias:
        env.VITE_MOCK_ENABLED === 'true'
          ? { '@api/client': new URL('./src/mocks/api-client.mock.ts', import.meta.url).pathname }
          : {},
    },
  }
})

Phase 3 — CI/CD Integration

Combining both layers in a CI pipeline covers the full test pyramid: inline handlers for fast unit and component feedback, proxy containers for integration validation.

# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npm test -- --run
        env:
          VITE_MOCK_ENABLED: 'true'

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

      - name: Start mock proxy stack
        run: docker compose -f docker-compose.mock.yml up -d

      - name: Wait for proxy health
        run: |
          for i in $(seq 1 15); do
            curl -sf http://localhost:8080/health && break
            sleep 2
          done

      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npm run test:e2e
        env:
          API_BASE_URL: 'http://localhost:8080'

      - name: Tear down
        if: always()
        run: docker compose -f docker-compose.mock.yml down -v

The mock lifecycle management pattern governs the up -d → health-check → test → down -v sequence: always pass -v to Docker Compose teardown so volume state does not bleed between runs.

For pull-request gating, run only the unit job to keep feedback under 30 seconds. Reserve the integration job for merges to the main branch or scheduled nightly pipelines. Response shaping techniques like latency injection and fault simulation are best applied in the integration job where proxy infrastructure can inject them centrally.


Proxy against inline, property by property Six properties. Inline mocking wins on speed, isolation and per-test control because it lives in the test process. Proxy mocking wins on runtime reach, transport realism and the ability to serve non-JavaScript clients because it is a real server on a port. Neither wins outright, and a stack usually needs both. Property Inline Proxy startup cost none — in process a container to boot runtime reach the host language only anything that speaks HTTP transport realism below fetch is invisible real sockets, real TLS per-test override trivial, in the spec an admin call or a restart parallel isolation free — one per worker shared unless scoped what it cannot do see non-JS traffic be as fast as a function call Read the last row first: it is the one that eliminates a choice rather than merely scoring it.

Verification Steps

  • Proxy health check: curl -sf http://localhost:8080/health returns ok
  • Proxy routing: curl -s http://localhost:8080/api/v1/users | jq '. | length' returns a non-zero integer
  • Proxy scenario selection: curl -s -H 'X-Mock-Scenario: error-500' http://localhost:8080/api/v1/users returns HTTP 500
  • Inline handler registration (browser): open DevTools → Application → Service Workers, confirm mockServiceWorker.js shows status “activated and is running”
  • Inline handler (Vitest): npm test -- --run 2>&1 | grep -c 'PASS' equals the number of test files
  • No mock module in production bundle: npm run build && grep -r 'msw' dist/ returns no output (handler files must not appear in production artefacts)
  • Container cleanup: docker compose -f docker-compose.mock.yml ps shows no running services after teardown

Which layer does this test belong at? A three-question elimination. If a non-JavaScript process must see the mock, only a proxy will do. If the test needs transport-level faults such as a connection reset, only a proxy will do. Otherwise inline interception is faster, simpler and better isolated, and is the right default. Two eliminations and a default. Everything else is preference. Non-JS client? a Go or JVM process in the same job yes → proxy Transport-level fault? reset, truncated chunk, TLS error yes → proxy Otherwise speed, isolation, per-test control inline A stack that answers 'yes' to either question for some suites and 'no' for others should run both — not compromise on one.

Troubleshooting

Error: connect ECONNREFUSED 127.0.0.1:4010

Cause: The Prism container is not yet healthy when Nginx receives the first request.

Fix: Add a depends_on with condition: service_healthy in docker-compose.mock.yml (shown in Phase 1). In CI, add the explicit health-wait loop before running tests. Reduce the Prism healthcheck interval to 3 seconds if startup is consistently slow.

[MSW] Warning: captured an API request without a matching request handler

Cause: A fetch URL in the application does not match any registered http.* handler pattern. This often happens when a base URL prefix is prepended in the fetch call but omitted in the handler path.

Fix: Ensure handler paths either use absolute patterns (http.get('https://api.example.com/v1/users', ...)) or relative paths that match exactly what fetch() receives. Enable onUnhandledRequest: 'error' in tests so unmatched requests fail loudly rather than silently falling through.

nginx: [emerg] host not found in upstream "mock-server"

Cause: The mock-server service name is not resolvable because the containers are on different networks or the service definition has a typo.

Fix: Confirm both services declare the same named network in docker-compose.mock.yml. Run docker compose config to verify the merged network configuration before starting the stack.

MSW handlers import visible in production bundle

Cause: The dynamic import('./mocks/browser') is inside an if block, but the bundler cannot statically prove the condition is false and includes the module graph.

Fix: Use import.meta.env.VITE_MOCK_ENABLED (not process.env.NODE_ENV) as the guard. Vite replaces import.meta.env at build time, enabling dead-code elimination. Confirm with npm run build -- --debug and check the rollup output for mock-related chunks.

Proxy responses do not match the latest OpenAPI changes

Cause: The Prism container is still serving the previous version of openapi.yaml because Docker Compose cached the volume mount or the spec file path changed.

Fix: Run docker compose -f docker-compose.mock.yml pull && docker compose -f docker-compose.mock.yml up -d --force-recreate to restart with the fresh spec. In CI, always use up -d without --no-recreate to pick up spec changes on every run. Automated drift detection using Prism’s --validate-request flag will surface mismatches early — this aligns with the broader network layer abstraction goal of keeping simulation contracts in sync with production API definitions.


When to Advance

You are ready to build on top of this setup when:

  • Both the proxy and inline stacks start cleanly in CI without manual intervention
  • At least one test suite exercises each strategy (unit tests against inline handlers; one integration test routing through the proxy)
  • The production build contains no mock module references
  • Spec drift is caught automatically — either by Prism --validate-request or a schema-assertion step in the CI pipeline
  • The down -v teardown step runs as a CI always: condition so failures do not leave orphaned containers

At that point you can extend inline handlers with stateful behaviour (described in when to use proxy vs inline mocking) and introduce more sophisticated response shaping techniques across both layers.


Why most mature stacks end up with both

The framing of this guide is a choice, and in practice teams past a certain size stop choosing. It is worth understanding why, because the arrangement that results has its own requirements.

The pressure comes from two directions. Unit and component suites grow into the hundreds of specs, where in-process interception’s zero startup cost compounds into minutes saved per run — enough that nobody will accept a container per shard. Meanwhile integration and end-to-end coverage grows to include runtimes the interceptor cannot see: a backend service in the same job, a mobile build, a load generator. Neither pressure yields to the other, so both layers end up present.

That arrangement works well, provided three things are true.

One layer per endpoint. The failure mode of running both against the same URL is not an error; it is whichever intercepts first answering, silently, with the winner determined by load order. This is worth stating as a rule rather than a preference, because the symptom — occasionally the wrong response — is extremely hard to attribute.

One source for the definitions. Handlers and mappings generated from the same schema or fixture set cannot disagree. Maintained separately, they disagree within a sprint, and the disagreement surfaces as an integration test contradicting a unit test with no obvious cause.

A way to tell which answered. A response header naming the layer costs nothing and converts an afternoon of confusion into a glance at DevTools.

Teams that adopt both without those three tend to conclude that mocking is unreliable. Teams that adopt both with them get the fast inner loop and the broad outer coverage without the ambiguity, and the arrangement stays stable as the suite grows.

FAQ

Can proxy and inline mocking be used together in the same project?

Yes. The typical split is: inline handlers (MSW setupServer) for unit and component tests where speed and isolation matter most; proxy containers (Prism or WireMock behind Nginx) for integration and end-to-end tests where environment parity and cross-service routing validation are the priority. A single set of handler definitions can serve both layers if you keep the request/response shapes in a shared fixtures module.

Does adding an Nginx proxy in Docker Compose noticeably slow down local development?

On loopback networking the added round-trip latency is typically under 1 ms per request. The real overhead is container startup time (10–30 seconds on a cold Docker daemon) and the cognitive load of managing an extra process. For inner-loop development — iterating on a single component — inline mocking is faster. Reserve the proxy stack for integration runs and contract validation.

How do I prevent MSW handlers from appearing in production builds?

Guard the worker bootstrap with import.meta.env.VITE_MOCK_ENABLED === 'true' (for Vite) or process.env.NEXT_PUBLIC_MOCK_ENABLED (for Next.js). Both bundlers replace these expressions at build time with the literal false when the env var is unset, enabling tree-shaking. Verify the outcome with grep -r 'msw' dist/ after a production build — no output means the handlers were eliminated.

What is the right tool for detecting contract drift in both approaches?

Prism CLI covers both surfaces: run prism validate -s openapi.yaml -d recorded-responses/ against captured proxy traffic, or use prism mock --validate-request to reject inline test requests that deviate from the spec. Integrate the validation step before any test job that depends on mock responses so drift is caught on every CI run, not just during periodic audits.


← Back to API Mocking Fundamentals & Architecture