Mocking API Calls in TanStack Query Tests with MSW v2
Mock Service Worker (MSW) v2 intercepts HTTP requests at the network level, making it the best tool for testing TanStack Query hooks. Instead of mocking fetch directly (brittle, error-prone), MSW lets you define realistic API handlers that work across unit tests, integration tests, and the browser.
This guide covers MSW v2 with TanStack Query: setup, handler patterns, error scenarios, loading state testing, and simulated network delays.
Why MSW over Mocking fetch Directly
Direct fetch mocking has problems:
// ❌ Brittle — tightly coupled to fetch internals
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: '1', name: 'Alice' }),
} as Response)This breaks when TanStack Query evolves, when you switch from fetch to axios, or when tests run in different environments. MSW intercepts at the network level — your code calls fetch or axios normally, and MSW returns the mocked response.
Setup
npm install -D msw@latestMSW v2 requires a different setup for Node.js (Vitest) vs browser:
// src/test/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 'u1', name: 'Alice Chen', email: 'alice@example.com', plan: 'pro' },
{ id: 'u2', name: 'Bob Lee', email: 'bob@example.com', plan: 'free' },
])
}),
http.get('/api/users/:userId', ({ params }) => {
const { userId } = params
if (userId === 'u1') {
return HttpResponse.json({ id: 'u1', name: 'Alice Chen', email: 'alice@example.com', plan: 'pro' })
}
return HttpResponse.json({ error: 'User not found' }, { status: 404 })
}),
http.put('/api/users/:userId', async ({ request }) => {
const body = await request.json() as { name: string; email: string }
return HttpResponse.json({ id: 'u1', ...body, plan: 'pro' })
}),
]// src/test/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)// src/test/setup.ts
import '@testing-library/jest-dom'
import { cleanup } from '@testing-library/react'
import { afterAll, afterEach, beforeAll } from 'vitest'
import { server } from './server'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => {
server.resetHandlers()
cleanup()
})
afterAll(() => server.close())The onUnhandledRequest: 'error' flag catches requests your handlers don't cover — you'll know immediately if a test is making unexpected API calls.
Testing Query Hooks with MSW
// src/test/query-utils.ts
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'
import type { ReactNode } from 'react'
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: Infinity },
},
})
}
export function renderWithQueryClient(ui: ReactNode, queryClient = createTestQueryClient()) {
return render(ui, {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
})
}// hooks/useUsers.test.tsx
import { screen, waitFor } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { renderWithQueryClient } from '@/test/query-utils'
import { http, HttpResponse } from 'msw'
import { server } from '@/test/server'
// Component that uses useQuery
function UserList() {
const { data, isLoading, isError } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
})
if (isLoading) return <p>Loading users…</p>
if (isError) return <p role="alert">Failed to load users</p>
return (
<ul>
{data?.map((user: { id: string; name: string }) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
describe('UserList with MSW', () => {
it('renders user list from mocked API', async () => {
renderWithQueryClient(<UserList />)
// Loading state appears first
expect(screen.getByText('Loading users…')).toBeInTheDocument()
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('Alice Chen')).toBeInTheDocument()
})
expect(screen.getByText('Bob Lee')).toBeInTheDocument()
})
it('shows error state when API fails', async () => {
// Override the default handler for this test
server.use(
http.get('/api/users', () => {
return HttpResponse.json({ error: 'Server error' }, { status: 500 })
})
)
renderWithQueryClient(<UserList />)
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument()
})
expect(screen.getByText('Failed to load users')).toBeInTheDocument()
})
})Per-Test Handler Overrides
server.use() adds handlers that take priority over defaults for the current test. server.resetHandlers() (in afterEach) restores the base handlers:
describe('useUser detail view', () => {
it('shows user not found error', async () => {
server.use(
http.get('/api/users/:userId', () => {
return HttpResponse.json({ error: 'Not found' }, { status: 404 })
})
)
renderWithQueryClient(<UserDetail userId="u999" />)
await waitFor(() => {
expect(screen.getByText(/not found/i)).toBeInTheDocument()
})
})
it('shows user data for valid user', async () => {
// No override — uses default handler from handlers.ts
renderWithQueryClient(<UserDetail userId="u1" />)
await waitFor(() => {
expect(screen.getByText('Alice Chen')).toBeInTheDocument()
})
})
})Testing Mutation Flows
MSW handles mutations (POST, PUT, DELETE) too:
// handlers for mutations
server.use(
http.post('/api/users', async ({ request }) => {
const body = await request.json() as { name: string; email: string }
return HttpResponse.json(
{ id: 'new-user', ...body, plan: 'free' },
{ status: 201 }
)
}),
http.delete('/api/users/:userId', () => {
return new HttpResponse(null, { status: 204 })
})
)describe('Create user mutation', () => {
it('adds new user to cache after creation', async () => {
const queryClient = createTestQueryClient()
// Pre-load the user list
server.use(
http.get('/api/users', () =>
HttpResponse.json([{ id: 'u1', name: 'Alice Chen' }])
)
)
renderWithQueryClient(<CreateUserForm />, queryClient)
await userEvent.type(screen.getByLabelText('Name'), 'Carol White')
await userEvent.type(screen.getByLabelText('Email'), 'carol@example.com')
await userEvent.click(screen.getByRole('button', { name: 'Create user' }))
await waitFor(() => {
expect(screen.getByText('Carol White')).toBeInTheDocument()
})
})
})Simulating Network Delays
MSW v2 supports artificial delays to test loading states:
import { http, HttpResponse, delay } from 'msw'
describe('Loading state tests', () => {
it('shows skeleton while slow API responds', async () => {
server.use(
http.get('/api/users', async () => {
await delay(500) // Simulate 500ms network lag
return HttpResponse.json([{ id: 'u1', name: 'Alice Chen' }])
})
)
renderWithQueryClient(<UserList />)
// Skeleton/loading UI should be visible during the delay
expect(screen.getByTestId('loading-skeleton')).toBeInTheDocument()
// After delay, content loads
await waitFor(() => {
expect(screen.getByText('Alice Chen')).toBeInTheDocument()
}, { timeout: 2000 })
expect(screen.queryByTestId('loading-skeleton')).not.toBeInTheDocument()
})
it('shows timeout error after request hangs', async () => {
server.use(
http.get('/api/users', async () => {
await delay('infinite') // Never responds
return HttpResponse.json([])
})
)
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
// 200ms timeout for this test
queryFn: async () => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 200)
const res = await fetch('/api/users', { signal: controller.signal })
clearTimeout(timeout)
return res.json()
},
},
},
})
renderWithQueryClient(<UserList />, queryClient)
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument()
}, { timeout: 1000 })
})
})Testing Pagination with MSW
const paginatedHandlers = [
http.get('/api/products', ({ request }) => {
const url = new URL(request.url)
const page = parseInt(url.searchParams.get('page') ?? '1')
const limit = parseInt(url.searchParams.get('limit') ?? '20')
const allProducts = Array.from({ length: 55 }, (_, i) => ({
id: `p${i + 1}`,
name: `Product ${i + 1}`,
}))
const start = (page - 1) * limit
const items = allProducts.slice(start, start + limit)
return HttpResponse.json({
items,
total: allProducts.length,
page,
totalPages: Math.ceil(allProducts.length / limit),
})
}),
]
describe('Paginated product list', () => {
beforeEach(() => server.use(...paginatedHandlers))
it('loads the first page by default', async () => {
renderWithQueryClient(<ProductList />)
await waitFor(() => {
expect(screen.getByText('Product 1')).toBeInTheDocument()
})
expect(screen.getByText('Product 20')).toBeInTheDocument()
expect(screen.queryByText('Product 21')).not.toBeInTheDocument()
})
it('loads page 2 when next is clicked', async () => {
renderWithQueryClient(<ProductList />)
await waitFor(() => expect(screen.getByText('Product 1')).toBeInTheDocument())
await userEvent.click(screen.getByRole('button', { name: 'Next page' }))
await waitFor(() => expect(screen.getByText('Product 21')).toBeInTheDocument())
expect(screen.queryByText('Product 1')).not.toBeInTheDocument()
})
})What Automated Tests Miss
MSW tests run in Node.js with simulated network calls. They don't cover:
- Real API contract drift — the MSW handler is a copy of what you think the API does; if the API changes, MSW tests still pass
- Authentication header verification — MSW can check headers, but you need to actually verify them
- CORS and cross-origin issues — not relevant in Node.js test environments
- Rate limiting — real APIs may throttle; MSW always responds instantly
HelpMeTest tests against your live API endpoints with real credentials. When a backend change breaks your TanStack Query integration, live monitoring catches it before users do.
Summary
MSW v2 with TanStack Query gives you clean, maintainable API mocks:
- Setup —
setupServerinbeforeAll/afterAll,resetHandlersinafterEach - Default handlers — define in
handlers.ts, shared across all tests - Per-test overrides — use
server.use()inside individual tests - Error scenarios — return non-200 status codes with
HttpResponse.json({}, { status: 500 }) - Delays — use
delay()to test loading states and timeout handling onUnhandledRequest: 'error'— fails tests that make unexpected API calls
The test code mirrors your real code: fetch works normally, MSW handles the responses.