n4nAI

Mocking LLM APIs with MSW for frontend testing

Learn to mock LLM API MSW frontend calls with Mock Service Worker. Step-by-step tutorial for React testing of chat UIs without live model calls.

n4n Team3 min read577 words

Audio narration

Coming soon — every post will get a voice note here.

When you need to mock llm api msw frontend interactions, you are usually trying to validate chat UI behavior without sending requests to a live model server or burning tokens in CI. Mock Service Worker (MSW) intercepts fetch calls at the network level, so your React components and test suites hit a fake but contract-accurate OpenAI-compatible endpoint. This tutorial builds a working mock from scratch and runs it against a Vitest test.

Prerequisites

  • Node.js 18+ and npm
  • A React + Vite project (the patterns adapt cleanly to Next.js or Vue)
  • MSW v2 installed as a dev dependency
  • Vitest, @testing-library/react, @testing-library/jest-dom, and jsdom for the test layer

Install the toolchain:

npm i -D msw vitest @testing-library/react @testing-library/jest-dom jsdom @vitejs/plugin-react

You should already have a src/ directory and a component entry point. We will add mocks alongside the app code.

Initialize MSW

Generate the service worker script into your static assets folder:

npx msw init public/ --save

This writes public/mockServiceWorker.js. The browser worker is served from that path; Node tests use a different harness. Confirm the file exists before moving on.

Define handlers for the LLM endpoint

Create src/mocks/handlers.ts. We mock the standard chat completions route using the OpenAI response shape so any existing client works unchanged.

import { http, HttpResponse } from 'msw'

export const handlers = [
  http.post('https://api.openai.com/v1/chat/completions', async ({ request }) => {
    const body = (await request.json()) as {
      model?: string
      stream?: boolean
      messages?: unknown[]
    }

    // Echo model so tests can assert client routing
    const model = body.model ?? 'gpt-3.5-turbo'

    if (body.stream) {
      // Streaming handled separately; see below
      return HttpResponse.json({ error: 'use stream handler' }, { status: 400 })
    }

    return HttpResponse.json({
      id: 'chatcmpl-test',
      object: 'chat.completion',
      created: Math.floor(Date.now() / 1000),
      model,
      choices: [
        {
          index: 0,
          message: { role: 'assistant', content: 'Mocked response from MSW' },
          finish_reason: 'stop',
        },
      ],
      usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
    })
  }),
]

This single handler covers the non-streaming case and records which model the client requested.

Start the worker in development

Create src/mocks/browser.ts:

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

export const worker = setupWorker(...handlers)

In src/main.tsx, boot the app after the worker starts:

import React from 'react'
import ReactDOM from 'react-dom/client'
import { worker } from './mocks/browser'
import { App } from './App'

async function enableMocking() {
  if (import.meta.env.DEV) {
    await worker.start({ onUnhandledRequest: 'bypass' })
  }
}

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

With the dev server running, open the browser network tab and post to the completions URL—MSW answers locally.

Build a minimal chat component

src/Chat.tsx exercises the mocked path:

import { useState } from 'react'

export function Chat() {
  const [reply, setReply] = useState('')
  const [error, setError] = useState('')
  const [loading, setLoading] = useState(false)

  async function send() {
    setLoading(true)
    setError('')
    try {
      const res = await fetch('https://api.openai.com/v1/chat/completions', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          model: 'gpt-3.5-turbo',
          messages: [{ role: 'user', content: 'Hello' }],
        }),
      })
      if (!res.ok) throw new Error(`status ${res.status}`)
      const data = await res.json()
      setReply(data.choices[0].message.content)
    } catch (e) {
      setError((e as Error).message)
    } finally {
      setLoading(false)
    }
  }

  return (
    <div>
      <button onClick={send} disabled={loading}>
        {loading ? 'Sending…' : 'Send'}
      </button>
      <p data-testid="reply">{reply}</p>
      {error && <p data-testid="error">{error}</p>}
    </div>
  )
}

Write a frontend test with MSW Node

For CI you run MSW in Node. Create src/mocks/node.ts:

import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

Configure Vitest in vitest.config.ts:

import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
  },
})

src/test/setup.ts manages the server lifecycle:

import { afterAll, afterEach, beforeAll } from 'vitest'
import { server } from '../mocks/node'

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

The test file:

import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Chat } from '../Chat'

test('renders mocked LLM reply without network', async () => {
  render(<Chat />)
  fireEvent.click(screen.getByText('Send'))
  await waitFor(() =>
    expect(screen.getByTestId('reply')).toHaveTextContent('Mocked response from MSW')
  )
})

Run npx vitest run. Expected output:

 ✓ src/test/Chat.test.tsx (1)
   ✓ renders mocked LLM reply without network

 Test Files  1 passed (1)
      Tests  1 passed (1)

You have now used mock llm api msw frontend technique to replace a live model call with a deterministic fixture.

Mock streaming responses

Real chat UIs often consume Server-Sent Events. Extend the handler to return a ReadableStream:

http.post('https://api.openai.com/v1/chat/completions', async ({ request }) => {
  const body = (await request.json()) as { stream?: boolean }
  if (!body.stream) return HttpResponse.json(/* previous JSON */)

  const encoder = new TextEncoder()
  const stream = new ReadableStream({
    start(controller) {
      const chunks = ['Hello', ' world', ' from', ' MSW']
      chunks.forEach((c, i) => {
        controller.enqueue(
          encoder.encode(
            `data: ${JSON.stringify({
              choices: [{ delta: { content: c }, finish_reason: i === chunks.length - 1 ? 'stop' : null }],
            })}\n\n`
          )
        )
      })
      controller.enqueue(encoder.encode('data: [DONE]\n\n'))
      controller.close()
    },
  })
  return new HttpResponse(stream, { headers: { 'Content-Type': 'text/event-stream' } })
})

Client-side consumption in Chat.tsx:

const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify({ ...payload, stream: true }) })
const reader = res.body!.getReader()
const decoder = new TextDecoder()
while (true) {
  const { done, value } = await reader.read()
  if (done) break
  const lines = decoder.decode(value).split('\n\n')
  for (const line of lines) {
    if (line.startsWith('data: ') && !line.includes('[DONE]')) {
      const json = JSON.parse(line.replace('data: ', ''))
      setReply((r) => r + json.choices[0].delta.content)
    }
  }
}

The same MSW node server streams bytes to your test, letting you assert incremental UI updates.

Simulate rate limits and fallback

Override handlers per test with server.use. First, add error UI to the component (already present via data-testid="error"). Then test:

import { server } from '../mocks/node'
import { http, HttpResponse } from 'msw'

test('shows error on 429', async () => {
  server.use(
    http.post('https://api.openai.com/v1/chat/completions', () =>
      HttpResponse.json({ error: 'rate limited' }, { status: 429 })
    )
  )
  render(<Chat />)
  fireEvent.click(screen.getByText('Send'))
  await waitFor(() => expect(screen.getByTestId('error')).toHaveTextContent('status 429'))
})

This pattern lets you mock llm api msw frontend failure modes—timeouts, malformed JSON, provider degradation—without touching real infrastructure.

Parameterize responses by model

To verify client routing, return different content per model:

const replies: Record<string, string> = {
  'gpt-4': 'GPT-4 mock',
  'gpt-3.5-turbo': 'GPT-3.5 mock',
}
return HttpResponse.json({
  // ...
  choices: [{ message: { content: replies[model] ?? 'default' }, finish_reason: 'stop' }],
})

A test can post with model: 'gpt-4' and assert the corresponding string. This catches hardcoded-model bugs early.

Mocking a gateway instead of a single vendor

If your frontend calls an OpenAI-compatible gateway such as n4n.ai—one endpoint covering 240+ models with automatic fallback—point MSW at that base URL instead. The handler logic stays identical because the request and response contracts are OpenAI-compatible. You can then test client-side routing directives or cache-control hints without invoking the real gateway, keeping CI hermetic.

CI integration checklist

  • Start the MSW node server in a global setup file; fail on unhandled requests.
  • Share handlers.ts between browser and Node to avoid drift.
  • Use server.use for scenario-specific overrides (errors, streaming, model routing).
  • Assert on usage shapes if your UI displays token counts.
  • Keep mock responses read-only; generate dynamic data inside the handler if needed.

MSW turns unpredictable LLM endpoints into deterministic fixtures. Your frontend tests run in milliseconds, cost zero, and cover streaming, errors, and model routing with a few lines of code. When you mock llm api msw frontend paths this way, you ship chat features with confidence instead of guesswork.

Tagsmswmockingfrontendtesting

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All testing & mocking llm apis in ci posts →