MSW Complete Guide | Mock Service Worker for API Testing

MSW Complete Guide | Mock Service Worker for API Testing

이 글의 핵심

MSW (Mock Service Worker) intercepts requests at the network level using Service Workers. It provides seamless API mocking for development and testing.

Introduction

MSW (Mock Service Worker) is an API mocking library that intercepts requests at the network level. Unlike traditional mocking, MSW uses Service Workers to intercept actual HTTP requests.

Traditional Mocking

// Tightly coupled to implementation
jest.mock('axios');
axios.get.mockResolvedValue({ data: { name: 'Alice' } });

Problems:

  • ❌ Coupled to HTTP library
  • ❌ Different mocks for fetch vs axios
  • ❌ Doesn’t work in browser

MSW

// Intercepts at network level
rest.get('/api/user', (req, res, ctx) => {
  return res(ctx.json({ name: 'Alice' }));
});

Benefits:

  • ✅ Library-agnostic (fetch, axios, etc.)
  • ✅ Works in browser AND Node.js
  • ✅ Same mocks for dev and tests

1. Installation

npm install --save-dev msw

Browser Setup

npx msw init public/ --save

This creates public/mockServiceWorker.js.

2. Defining Handlers

// mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  // GET request
  http.get('/api/user', () => {
    return HttpResponse.json({
      id: 1,
      name: 'Alice',
      email: '[email protected]',
    });
  }),

  // POST request
  http.post('/api/login', async ({ request }) => {
    const { email, password } = await request.json();
    
    if (email === '[email protected]' && password === 'password') {
      return HttpResponse.json({ token: 'fake-token' });
    }
    
    return HttpResponse.json(
      { error: 'Invalid credentials' },
      { status: 401 }
    );
  }),

  // Dynamic path
  http.get('/api/users/:id', ({ params }) => {
    const { id } = params;
    return HttpResponse.json({ id, name: `User ${id}` });
  }),
];

3. Browser Integration

// mocks/browser.ts
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';

export const worker = setupWorker(...handlers);
// main.tsx
import { worker } from './mocks/browser';

if (import.meta.env.DEV) {
  worker.start({
    onUnhandledRequest: 'warn',
  });
}

ReactDOM.createRoot(document.getElementById('root')!).render(<App />);

Now all API requests are mocked in development!

4. Node.js Integration (Tests)

// mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

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

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

5. Testing with MSW

// UserProfile.tsx
export function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser);
  }, [userId]);
  
  if (!user) return <div>Loading...</div>;
  return <div>{user.name}</div>;
}
// UserProfile.test.tsx
import { render, screen } from '@testing-library/react';
import { UserProfile } from './UserProfile';

test('displays user name', async () => {
  render(<UserProfile userId={1} />);
  
  expect(await screen.findByText('User 1')).toBeInTheDocument();
});

6. Response Modifiers

import { http, HttpResponse, delay } from 'msw';

export const handlers = [
  // Delayed response
  http.get('/api/slow', async () => {
    await delay(2000);
    return HttpResponse.json({ message: 'Slow response' });
  }),

  // Error response
  http.get('/api/error', () => {
    return HttpResponse.json(
      { error: 'Internal Server Error' },
      { status: 500 }
    );
  }),

  // Network error
  http.get('/api/network-error', () => {
    return HttpResponse.error();
  }),

  // Custom headers
  http.get('/api/with-headers', () => {
    return HttpResponse.json(
      { data: 'value' },
      {
        headers: {
          'X-Custom-Header': 'custom-value',
          'Cache-Control': 'no-cache',
        },
      }
    );
  }),
];

7. Runtime Request Handlers

// Override handler for specific test
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';

test('handles server error', async () => {
  server.use(
    http.get('/api/user', () => {
      return HttpResponse.json(
        { error: 'Server error' },
        { status: 500 }
      );
    })
  );
  
  // Test error handling...
});

8. GraphQL Support

import { graphql, HttpResponse } from 'msw';

export const handlers = [
  graphql.query('GetUser', ({ variables }) => {
    return HttpResponse.json({
      data: {
        user: {
          id: variables.id,
          name: 'Alice',
        },
      },
    });
  }),

  graphql.mutation('CreatePost', ({ variables }) => {
    return HttpResponse.json({
      data: {
        createPost: {
          id: Date.now(),
          title: variables.title,
        },
      },
    });
  }),
];

9. Real-World Example: E-commerce API

// mocks/handlers.ts
import { http, HttpResponse, delay } from 'msw';

interface Product {
  id: number;
  name: string;
  price: number;
}

const products: Product[] = [
  { id: 1, name: 'Laptop', price: 999 },
  { id: 2, name: 'Mouse', price: 29 },
  { id: 3, name: 'Keyboard', price: 79 },
];

let cart: { productId: number; quantity: number }[] = [];

export const handlers = [
  // Get products
  http.get('/api/products', async () => {
    await delay(500);
    return HttpResponse.json({ products });
  }),

  // Get product by ID
  http.get('/api/products/:id', ({ params }) => {
    const product = products.find(p => p.id === Number(params.id));
    
    if (!product) {
      return HttpResponse.json(
        { error: 'Product not found' },
        { status: 404 }
      );
    }
    
    return HttpResponse.json({ product });
  }),

  // Add to cart
  http.post('/api/cart', async ({ request }) => {
    const { productId, quantity } = await request.json();
    
    const existing = cart.find(item => item.productId === productId);
    
    if (existing) {
      existing.quantity += quantity;
    } else {
      cart.push({ productId, quantity });
    }
    
    return HttpResponse.json({ cart }, { status: 201 });
  }),

  // Get cart
  http.get('/api/cart', () => {
    return HttpResponse.json({ cart });
  }),

  // Clear cart
  http.delete('/api/cart', () => {
    cart = [];
    return HttpResponse.json({ success: true });
  }),
];

10. Best Practices

1. Share Handlers Between Dev and Tests

mocks/
├── handlers.ts      # Shared handlers
├── browser.ts       # Browser setup
└── server.ts        # Node.js setup

2. Use Realistic Data

// Good: realistic
http.get('/api/user', () => {
  return HttpResponse.json({
    id: 1,
    name: 'Alice Johnson',
    email: '[email protected]',
    createdAt: '2024-01-15T10:30:00Z',
  });
});

// Bad: minimal
http.get('/api/user', () => {
  return HttpResponse.json({ name: 'test' });
});

3. Test Error States

test('handles network error', async () => {
  server.use(
    http.get('/api/user', () => {
      return HttpResponse.error();
    })
  );
  
  render(<UserProfile />);
  expect(await screen.findByText('Network error')).toBeInTheDocument();
});

11. Debugging

// Enable detailed logging
worker.start({
  onUnhandledRequest: 'warn',
  onUnhandledResponse: 'warn',
});

// Log all requests
http.get('/api/*', ({ request }) => {
  console.log('Request:', request.method, request.url);
  return HttpResponse.json({});
});

Summary

MSW revolutionizes API mocking:

  • Network-level interception
  • Works everywhere - browser, Node.js
  • Library-agnostic - fetch, axios, etc.
  • Realistic - uses actual HTTP
  • GraphQL support built-in

Key Takeaways:

  1. Intercepts at network level
  2. Same mocks for dev and tests
  3. Use HttpResponse for responses
  4. Override handlers per test
  5. Support REST and GraphQL

Next Steps:

Resources: