jsmanifest logojsmanifest

Mock Service Worker: Test APIs Without a Backend

Mock Service Worker: Test APIs Without a Backend

Learn how Mock Service Worker (MSW) lets you mock APIs at the network level for testing and development—no backend required. Includes practical examples and real-world patterns.

While I was looking over some testing strategies for a new React project the other day, I realized I was once guilty of a common anti-pattern: writing tests that depended on actual backend servers or complex mocking setups that broke whenever the API changed. Little did I know there was a better way to handle API mocking that would save me hours of debugging and make my tests actually reliable.

That's when I came across Mock Service Worker (MSW), and it completely changed how I approach API mocking in both development and testing environments.

Why API Mocking Matters in Modern Development

Let's be honest—developing frontend applications without a reliable backend can be frustrating. You're either waiting for the backend team to finish their endpoints, dealing with flaky staging environments, or trying to test edge cases that are nearly impossible to reproduce with a real server.

I cannot stress this enough! Traditional approaches like monkey-patching fetch or using libraries that intercept HTTP clients directly create brittle tests. When I finally decided to look for alternatives, I kept running into the same problems:

  • Mocks that only worked in specific test runners
  • Different mocking strategies for browser development versus Node.js tests
  • Fake data that didn't match real API responses
  • Tests that passed but failed in production because the mocking layer was too divorced from reality

These issues aren't just annoying—they cost real development time and erode confidence in your test suite.

What Makes Mock Service Worker Different

Mock Service Worker takes a fundamentally different approach. Instead of intercepting requests at the application level, it intercepts them at the network level using Service Workers in the browser and a similar approach in Node.js.

In other words, your application makes real HTTP requests. Those requests never leave your computer, but from your application's perspective, it's communicating with an actual server. This means you can use the same mocks across different frameworks, testing libraries, and even during local development.

When I first understood this concept, it was fascinating! Your code doesn't need to know it's being mocked. You're testing the actual data flow through your application, not some abstraction of it.

MSW Architecture Diagram

Setting Up MSW in Your Project

Let's start with the basics. First, install MSW:

npm install msw --save-dev

For browser development, you'll need to generate the Service Worker file. This is a one-time setup:

npx msw init public/ --save

This creates a mockServiceWorker.js file in your public directory. The --save flag updates your package.json so you can regenerate it later if needed.

Now let's create a basic handler file. I typically create a src/mocks directory to keep everything organized:

// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
 
interface User {
  id: string
  name: string
  email: string
}
 
export const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    const { id } = params
    
    const user: User = {
      id: id as string,
      name: 'Christopher T.',
      email: 'chris@jsmanifest.com'
    }
    
    return HttpResponse.json(user)
  }),
 
  http.post('/api/users', async ({ request }) => {
    const newUser = await request.json() as User
    
    return HttpResponse.json(
      { ...newUser, id: crypto.randomUUID() },
      { status: 201 }
    )
  }),
 
  http.delete('/api/users/:id', ({ params }) => {
    return new HttpResponse(null, { status: 204 })
  })
]

Notice how we're defining handlers that look almost like Express route handlers. This feels natural if you've written backend code before. The http object provides methods for GET, POST, PUT, DELETE, and other HTTP methods.

Using MSW in Browser Development vs Node.js Testing

Here's where MSW really shines—you write your handlers once and use them everywhere. Luckily we can set up different entry points for browser and Node.js environments.

For browser development, create a browser setup file:

// src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
 
export const worker = setupWorker(...handlers)

Then in your app's entry point (like main.tsx or index.tsx), conditionally start the worker in development:

// src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
 
async function enableMocking() {
  if (process.env.NODE_ENV !== 'development') {
    return
  }
 
  const { worker } = await import('./mocks/browser')
  
  return worker.start({
    onUnhandledRequest: 'warn'
  })
}
 
enableMocking().then(() => {
  ReactDOM.createRoot(document.getElementById('root')!).render(
    <React.StrictMode>
      <App />
    </React.StrictMode>
  )
})

For Node.js testing, the setup is slightly different:

// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
 
export const server = setupServer(...handlers)

And in your test setup file (like vitest.setup.ts or jest.setup.ts):

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

MSW in Action

Real-World Scenarios: Error States, Loading States, and Dynamic Responses

One thing I realized early on was that testing happy paths is easy. The real value comes from easily simulating error states, slow networks, and edge cases.

Let's look at some practical examples:

// Testing error states
http.get('/api/users/:id', ({ params }) => {
  const { id } = params
  
  // Simulate a not found error
  if (id === 'not-found') {
    return HttpResponse.json(
      { error: 'User not found' },
      { status: 404 }
    )
  }
  
  // Simulate a server error
  if (id === 'error') {
    return HttpResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  }
  
  // Simulate network timeout
  if (id === 'timeout') {
    return new Promise(() => {}) // Never resolves
  }
  
  // Happy path
  return HttpResponse.json({
    id,
    name: 'Christopher T.',
    email: 'chris@jsmanifest.com'
  })
})

For testing loading states, you can add artificial delays:

http.get('/api/slow-endpoint', async () => {
  await new Promise(resolve => setTimeout(resolve, 3000))
  return HttpResponse.json({ data: 'This took 3 seconds' })
})

In your tests, you can override handlers for specific test cases:

import { server } from './mocks/server'
import { http, HttpResponse } from 'msw'
 
test('handles 404 errors gracefully', async () => {
  server.use(
    http.get('/api/users/:id', () => {
      return HttpResponse.json(
        { error: 'Not found' },
        { status: 404 }
      )
    })
  )
  
  // Your test code here
  // The component should show an error message
})

Advanced Patterns: Stateful Handlers and Realistic Data

While I was working on a more complex application, I needed handlers that maintained state between requests. This is where MSW really becomes powerful.

Here's a stateful handler example:

// Create an in-memory database
const db = {
  users: new Map([
    ['1', { id: '1', name: 'Christopher T.', email: 'chris@jsmanifest.com' }],
    ['2', { id: '2', name: 'Jane Doe', email: 'jane@example.com' }]
  ])
}
 
export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json(Array.from(db.users.values()))
  }),
 
  http.post('/api/users', async ({ request }) => {
    const newUser = await request.json() as User
    const id = crypto.randomUUID()
    const user = { ...newUser, id }
    
    db.users.set(id, user)
    
    return HttpResponse.json(user, { status: 201 })
  }),
 
  http.put('/api/users/:id', async ({ params, request }) => {
    const { id } = params
    const updates = await request.json() as Partial<User>
    
    const existingUser = db.users.get(id as string)
    if (!existingUser) {
      return HttpResponse.json(
        { error: 'User not found' },
        { status: 404 }
      )
    }
    
    const updatedUser = { ...existingUser, ...updates }
    db.users.set(id as string, updatedUser)
    
    return HttpResponse.json(updatedUser)
  }),
 
  http.delete('/api/users/:id', ({ params }) => {
    const { id } = params
    db.users.delete(id as string)
    return new HttpResponse(null, { status: 204 })
  })
]

For realistic test data, I often combine MSW with libraries like Faker.js:

import { faker } from '@faker-js/faker'
 
http.get('/api/users', () => {
  const users = Array.from({ length: 10 }, () => ({
    id: faker.string.uuid(),
    name: faker.person.fullName(),
    email: faker.internet.email(),
    avatar: faker.image.avatar(),
    createdAt: faker.date.past().toISOString()
  }))
  
  return HttpResponse.json(users)
})

Best Practices and Common Pitfalls to Avoid

After using MSW in several projects, I've learned some valuable lessons. Here are patterns that work well and mistakes I was once guilty of:

Do:

  • Keep handlers in a separate directory and organize them by feature or domain
  • Use TypeScript for type-safe request/response handling
  • Reset handlers between tests to prevent test pollution
  • Use onUnhandledRequest: 'warn' in development and 'error' in tests
  • Create helper functions for common response patterns (pagination, error responses, etc.)

Don't:

  • Make handlers too complex—they should mock the API, not replicate business logic
  • Forget to clean up state between tests
  • Mock every single endpoint—only mock what you need for the current test or feature
  • Use production API URLs in handlers—use relative paths like /api/users
  • Ignore the console warnings about unhandled requests—they often reveal missing mocks

One mistake I see developers make is trying to test too many scenarios in a single handler. In other words, keep your handlers focused and override them in specific tests when you need different behavior.

Wonderful! You now have a powerful tool for mocking APIs that works consistently across your entire development workflow. MSW has saved me countless hours of debugging flaky tests and made it possible to develop features before the backend is ready.

The beauty of MSW is that it mirrors how real HTTP works. Your application doesn't know the difference between MSW's mocked responses and actual server responses, which means your tests are more realistic and your development workflow is smoother.

And that concludes the end of this post! I hope you found this valuable and look out for more in the future!