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-jestThe 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.