Mocking API Calls in TanStack Query Tests with MSW v2

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@latest

MSW 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:

  • SetupsetupServer in beforeAll/afterAll, resetHandlers in afterEach
  • 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.

Read more