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.tsbetween browser and Node to avoid drift. - Use
server.usefor scenario-specific overrides (errors, streaming, model routing). - Assert on
usageshapes 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.