Testing React with MSW and Testing Library: End-to-End Example

Testing React with MSW and Testing Library: End-to-End Example

Testing React components that fetch data requires handling asynchronous behavior, loading states, error conditions, and successful renders. The combination of Mock Service Worker (MSW) and React Testing Library gives you a powerful toolkit for writing these tests without mocking at the module level or coupling your tests to implementation details.

This guide walks through a complete, realistic example — building and testing a user management component with create, read, and delete operations.

Project Setup

Start with a React project using Vite or Create React App with the necessary testing dependencies:

npm install --save-dev @testing-library/react @testing-library/user-event @testing-library/jest-dom
npm install --save-dev msw
npm install --save-dev vitest jsdom
# or, if using Jest:
npm install --save-dev jest jest-environment-jsdom babel-jest

The Component Under Test

Here's the component we'll test — a user list that fetches from an API, handles loading and error states, and supports creating and deleting users:

// src/components/UserList.jsx
import { useState, useEffect } from 'react'

export function UserList() {
  const [users, setUsers] = useState([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    fetch('/api/users')
      .then(res => {
        if (!res.ok) throw new Error('Failed to load users')
        return res.json()
      })
      .then(data => {
        setUsers(data)
        setLoading(false)
      })
      .catch(err => {
        setError(err.message)
        setLoading(false)
      })
  }, [])

  const deleteUser = async (id) => {
    const res = await fetch(`/api/users/${id}`, { method: 'DELETE' })
    if (res.ok) {
      setUsers(users.filter(u => u.id !== id))
    }
  }

  if (loading) return <div data-testid="loading">Loading users...</div>
  if (error) return <div data-testid="error">{error}</div>

  return (
    <div>
      <h1>Users ({users.length})</h1>
      <ul>
        {users.map(user => (
          <li key={user.id} data-testid={`user-${user.id}`}>
            <span>{user.name}</span>
            <span>{user.email}</span>
            <button onClick={() => deleteUser(user.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  )
}
// src/components/CreateUserForm.jsx
import { useState } from 'react'

export function CreateUserForm({ onUserCreated }) {
  const [name, setName] = useState('')
  const [email, setEmail] = useState('')
  const [submitting, setSubmitting] = useState(false)
  const [formError, setFormError] = useState(null)

  const handleSubmit = async (e) => {
    e.preventDefault()
    setSubmitting(true)
    setFormError(null)

    try {
      const res = await fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name, email }),
      })

      if (!res.ok) {
        const data = await res.json()
        throw new Error(data.message || 'Failed to create user')
      }

      const newUser = await res.json()
      onUserCreated(newUser)
      setName('')
      setEmail('')
    } catch (err) {
      setFormError(err.message)
    } finally {
      setSubmitting(false)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="Name"
        value={name}
        onChange={e => setName(e.target.value)}
        data-testid="name-input"
      />
      <input
        type="email"
        placeholder="Email"
        value={email}
        onChange={e => setEmail(e.target.value)}
        data-testid="email-input"
      />
      <button type="submit" disabled={submitting} data-testid="submit-button">
        {submitting ? 'Creating...' : 'Create User'}
      </button>
      {formError && <div data-testid="form-error">{formError}</div>}
    </form>
  )
}

Setting Up MSW Handlers

Define handlers that cover all the scenarios you want to test:

// src/mocks/handlers.js
import { http, HttpResponse } from 'msw'

const initialUsers = [
  { id: 1, name: 'Alice Johnson', email: 'alice@example.com' },
  { id: 2, name: 'Bob Smith', email: 'bob@example.com' },
]

export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json(initialUsers)
  }),

  http.post('/api/users', async ({ request }) => {
    const body = await request.json()
    
    if (!body.name || !body.email) {
      return HttpResponse.json(
        { message: 'Name and email are required' },
        { status: 400 }
      )
    }

    const newUser = { id: Date.now(), ...body }
    return HttpResponse.json(newUser, { status: 201 })
  }),

  http.delete('/api/users/:id', ({ params }) => {
    return new HttpResponse(null, { status: 204 })
  }),
]

MSW Server Setup

// src/mocks/server.js
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

Test Setup File

// src/setupTests.js
import '@testing-library/jest-dom'
import { server } from './mocks/server'

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

Configure Vitest to use this setup:

// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/setupTests.js',
  },
})

Writing the Tests

Testing the user list — happy path

// src/components/UserList.test.jsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { server } from '../mocks/server'
import { http, HttpResponse } from 'msw'
import { UserList } from './UserList'

describe('UserList', () => {
  test('shows loading state initially', () => {
    render(<UserList />)
    expect(screen.getByTestId('loading')).toBeInTheDocument()
  })

  test('displays users after loading', async () => {
    render(<UserList />)

    await waitFor(() => {
      expect(screen.queryByTestId('loading')).not.toBeInTheDocument()
    })

    expect(screen.getByText('Alice Johnson')).toBeInTheDocument()
    expect(screen.getByText('alice@example.com')).toBeInTheDocument()
    expect(screen.getByText('Bob Smith')).toBeInTheDocument()
    expect(screen.getByText('Users (2)')).toBeInTheDocument()
  })

  test('shows user count correctly', async () => {
    render(<UserList />)

    await waitFor(() => {
      expect(screen.getByText('Users (2)')).toBeInTheDocument()
    })
  })
})

Testing error states

  test('shows error message when API fails', async () => {
    server.use(
      http.get('/api/users', () => {
        return HttpResponse.json(
          { message: 'Server error' },
          { status: 500 }
        )
      })
    )

    render(<UserList />)

    await waitFor(() => {
      expect(screen.getByTestId('error')).toBeInTheDocument()
    })

    expect(screen.getByText('Failed to load users')).toBeInTheDocument()
  })

  test('shows error when network fails completely', async () => {
    server.use(
      http.get('/api/users', () => {
        return HttpResponse.error()
      })
    )

    render(<UserList />)

    await waitFor(() => {
      expect(screen.getByTestId('error')).toBeInTheDocument()
    })
  })

  test('shows empty state when no users exist', async () => {
    server.use(
      http.get('/api/users', () => {
        return HttpResponse.json([])
      })
    )

    render(<UserList />)

    await waitFor(() => {
      expect(screen.getByText('Users (0)')).toBeInTheDocument()
    })
  })

Testing async state — delete user

  test('removes user from list after delete', async () => {
    const user = userEvent.setup()
    render(<UserList />)

    // Wait for users to load
    await waitFor(() => {
      expect(screen.getByText('Alice Johnson')).toBeInTheDocument()
    })

    // Find and click the delete button for Alice
    const aliceRow = screen.getByTestId('user-1')
    const deleteButton = aliceRow.querySelector('button')
    await user.click(deleteButton)

    // Alice should be gone, Bob should remain
    await waitFor(() => {
      expect(screen.queryByText('Alice Johnson')).not.toBeInTheDocument()
    })
    expect(screen.getByText('Bob Smith')).toBeInTheDocument()
    expect(screen.getByText('Users (1)')).toBeInTheDocument()
  })

Testing the create form

// src/components/CreateUserForm.test.jsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { server } from '../mocks/server'
import { http, HttpResponse } from 'msw'
import { CreateUserForm } from './CreateUserForm'

describe('CreateUserForm', () => {
  const mockOnUserCreated = jest.fn()

  beforeEach(() => {
    mockOnUserCreated.mockClear()
  })

  test('submits form and calls onUserCreated with new user', async () => {
    const user = userEvent.setup()
    render(<CreateUserForm onUserCreated={mockOnUserCreated} />)

    await user.type(screen.getByTestId('name-input'), 'Charlie Davis')
    await user.type(screen.getByTestId('email-input'), 'charlie@example.com')
    await user.click(screen.getByTestId('submit-button'))

    await waitFor(() => {
      expect(mockOnUserCreated).toHaveBeenCalledWith(
        expect.objectContaining({
          name: 'Charlie Davis',
          email: 'charlie@example.com',
        })
      )
    })
  })

  test('shows submitting state during form submission', async () => {
    const user = userEvent.setup()
    
    // Delay the response so we can observe the submitting state
    server.use(
      http.post('/api/users', async () => {
        await new Promise(resolve => setTimeout(resolve, 100))
        return HttpResponse.json({ id: 3, name: 'Test', email: 'test@example.com' })
      })
    )

    render(<CreateUserForm onUserCreated={mockOnUserCreated} />)

    await user.type(screen.getByTestId('name-input'), 'Test User')
    await user.type(screen.getByTestId('email-input'), 'test@example.com')
    await user.click(screen.getByTestId('submit-button'))

    expect(screen.getByText('Creating...')).toBeInTheDocument()
    expect(screen.getByTestId('submit-button')).toBeDisabled()

    await waitFor(() => {
      expect(screen.getByText('Create User')).toBeInTheDocument()
    })
  })

  test('shows validation error from API', async () => {
    const user = userEvent.setup()

    server.use(
      http.post('/api/users', () => {
        return HttpResponse.json(
          { message: 'Email already exists' },
          { status: 422 }
        )
      })
    )

    render(<CreateUserForm onUserCreated={mockOnUserCreated} />)

    await user.type(screen.getByTestId('name-input'), 'Alice Johnson')
    await user.type(screen.getByTestId('email-input'), 'alice@example.com')
    await user.click(screen.getByTestId('submit-button'))

    await waitFor(() => {
      expect(screen.getByTestId('form-error')).toBeInTheDocument()
    })

    expect(screen.getByText('Email already exists')).toBeInTheDocument()
    expect(mockOnUserCreated).not.toHaveBeenCalled()
  })

  test('clears form after successful submission', async () => {
    const user = userEvent.setup()
    render(<CreateUserForm onUserCreated={mockOnUserCreated} />)

    const nameInput = screen.getByTestId('name-input')
    const emailInput = screen.getByTestId('email-input')

    await user.type(nameInput, 'Charlie Davis')
    await user.type(emailInput, 'charlie@example.com')
    await user.click(screen.getByTestId('submit-button'))

    await waitFor(() => {
      expect(mockOnUserCreated).toHaveBeenCalled()
    })

    expect(nameInput).toHaveValue('')
    expect(emailInput).toHaveValue('')
  })
})

Testing with msw/node in Non-jsdom Environments

For server-side code (Next.js API routes, backend handlers), use the MSW server without a DOM environment:

// __tests__/serverSide.test.js (Jest with Node environment)
/**
 * @jest-environment node
 */
import { server } from '../src/mocks/server'
import { http, HttpResponse } from 'msw'
import { fetchUserById } from '../src/lib/api'

describe('fetchUserById (server-side)', () => {
  test('returns user data', async () => {
    server.use(
      http.get('https://api.external.com/users/:id', ({ params }) => {
        return HttpResponse.json({ id: params.id, name: 'Alice' })
      })
    )

    const user = await fetchUserById('1')
    expect(user.name).toBe('Alice')
  })
})

Handling Complex Async Patterns

Testing optimistic updates

test('shows optimistic update before server confirms deletion', async () => {
  const user = userEvent.setup()
  
  let deleteResolved = false
  server.use(
    http.delete('/api/users/:id', async () => {
      await new Promise(resolve => {
        setTimeout(() => {
          deleteResolved = true
          resolve()
        }, 200)
      })
      return new HttpResponse(null, { status: 204 })
    })
  )

  render(<UserList />)

  await waitFor(() => screen.getByText('Alice Johnson'))

  const aliceRow = screen.getByTestId('user-1')
  await user.click(aliceRow.querySelector('button'))

  // If component implements optimistic update, Alice disappears immediately
  // This test documents and verifies that behavior
  await waitFor(() => {
    expect(screen.queryByText('Alice Johnson')).not.toBeInTheDocument()
  })
})

Testing polling behavior

test('refreshes user list every 30 seconds', async () => {
  jest.useFakeTimers()

  let callCount = 0
  server.use(
    http.get('/api/users', () => {
      callCount++
      return HttpResponse.json([])
    })
  )

  render(<UserList refreshInterval={30000} />)
  await waitFor(() => expect(callCount).toBe(1))

  jest.advanceTimersByTime(30000)
  await waitFor(() => expect(callCount).toBe(2))

  jest.useRealTimers()
})

Debugging MSW in Tests

When tests fail unexpectedly, MSW's logging helps:

// In server setup, log unhandled requests
server.listen({
  onUnhandledRequest: ({ method, url }) => {
    console.warn(`Unhandled ${method} request to ${url}`)
  },
})

You can also inspect what requests were made during a test:

const requests = []

server.events.on('request:start', ({ request }) => {
  requests.push({ method: request.method, url: request.url })
})

test('makes the right API call', async () => {
  render(<UserList />)
  await waitFor(() => screen.getByText('Alice'))
  
  expect(requests).toContainEqual(
    expect.objectContaining({ method: 'GET', url: expect.stringContaining('/api/users') })
  )
})

Best Practices

Keep handlers realistic. Your handlers should return data shaped like the real API. If the real API returns { data: { users: [] } }, your handlers should too — not just [].

Use server.resetHandlers() in afterEach. Override handlers per-test, not per-suite. This prevents test pollution.

Prefer waitFor over findBy. Both work, but waitFor gives you more control over timing assertions.

Don't test MSW itself. Your tests should verify component behavior, not that MSW intercepted the right request.

Handle the full lifecycle. Test loading, success, error, and empty states. These are the scenarios users actually encounter.

Conclusion

MSW and React Testing Library together give you a testing stack that validates real user interactions against realistic API behavior. The key insight is that MSW operates at the network level — your components make actual fetch calls, and MSW intercepts them. This means your tests are exercising the same code paths as your production application.

Start by defining comprehensive handlers in a shared file, configure the server once in your test setup, and then override per-test only when you need specific scenarios. The result is a test suite that's maintainable, realistic, and fast to write.

Read more