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.


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

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.


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