Files
2025-11-30 08:25:35 +08:00

6.1 KiB

Testing TanStack Query

Testing queries, mutations, and components


Setup

npm install -D @testing-library/react @testing-library/jest-dom vitest msw

Test Utils

// src/test-utils.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'

export function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: false, // Disable retries in tests
        gcTime: Infinity,
      },
    },
    logger: {
      log: console.log,
      warn: console.warn,
      error: () => {}, // Silence errors in tests
    },
  })
}

export function renderWithClient(ui: React.ReactElement) {
  const testQueryClient = createTestQueryClient()
  return render(
    <QueryClientProvider client={testQueryClient}>
      {ui}
    </QueryClientProvider>
  )
}

Testing Queries

import { renderHook, waitFor } from '@testing-library/react'
import { useTodos } from './useTodos'

describe('useTodos', () => {
  it('fetches todos successfully', async () => {
    const { result } = renderHook(() => useTodos(), {
      wrapper: ({ children }) => (
        <QueryClientProvider client={createTestQueryClient()}>
          {children}
        </QueryClientProvider>
      ),
    })

    // Initially pending
    expect(result.current.isPending).toBe(true)

    // Wait for success
    await waitFor(() => expect(result.current.isSuccess).toBe(true))

    // Check data
    expect(result.current.data).toHaveLength(3)
  })

  it('handles errors', async () => {
    // Mock fetch to fail
    global.fetch = vi.fn(() =>
      Promise.reject(new Error('API error'))
    )

    const { result } = renderHook(() => useTodos())

    await waitFor(() => expect(result.current.isError).toBe(true))
    expect(result.current.error?.message).toBe('API error')
  })
})

Testing with MSW

import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'

const server = setupServer(
  http.get('/api/todos', () => {
    return HttpResponse.json([
      { id: 1, title: 'Test todo', completed: false },
    ])
  })
)

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

test('fetches todos', async () => {
  const { result } = renderHook(() => useTodos())

  await waitFor(() => expect(result.current.isSuccess).toBe(true))

  expect(result.current.data).toEqual([
    { id: 1, title: 'Test todo', completed: false },
  ])
})

test('handles server error', async () => {
  server.use(
    http.get('/api/todos', () => {
      return new HttpResponse(null, { status: 500 })
    })
  )

  const { result } = renderHook(() => useTodos())

  await waitFor(() => expect(result.current.isError).toBe(true))
})

Testing Mutations

test('adds todo successfully', async () => {
  const { result } = renderHook(() => useAddTodo())

  act(() => {
    result.current.mutate({ title: 'New todo' })
  })

  await waitFor(() => expect(result.current.isSuccess).toBe(true))
  expect(result.current.data).toEqual(
    expect.objectContaining({ title: 'New todo' })
  )
})

test('handles mutation error', async () => {
  server.use(
    http.post('/api/todos', () => {
      return new HttpResponse(null, { status: 400 })
    })
  )

  const { result } = renderHook(() => useAddTodo())

  act(() => {
    result.current.mutate({ title: 'New todo' })
  })

  await waitFor(() => expect(result.current.isError).toBe(true))
})

Testing Components

import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TodoList } from './TodoList'

test('displays todos', async () => {
  renderWithClient(<TodoList />)

  expect(screen.getByText(/loading/i)).toBeInTheDocument()

  await waitFor(() => {
    expect(screen.getByText('Test todo')).toBeInTheDocument()
  })
})

test('adds new todo', async () => {
  renderWithClient(<TodoList />)

  await waitFor(() => {
    expect(screen.getByText('Test todo')).toBeInTheDocument()
  })

  const input = screen.getByPlaceholderText(/new todo/i)
  const button = screen.getByRole('button', { name: /add/i })

  await userEvent.type(input, 'Another todo')
  await userEvent.click(button)

  await waitFor(() => {
    expect(screen.getByText('Another todo')).toBeInTheDocument()
  })
})

Testing with Prefilled Cache

test('uses prefilled cache', () => {
  const queryClient = createTestQueryClient()

  // Prefill cache
  queryClient.setQueryData(['todos'], [
    { id: 1, title: 'Cached todo', completed: false },
  ])

  render(
    <QueryClientProvider client={queryClient}>
      <TodoList />
    </QueryClientProvider>
  )

  // Should immediately show cached data
  expect(screen.getByText('Cached todo')).toBeInTheDocument()
})

Testing Optimistic Updates

test('optimistic update rollback on error', async () => {
  const queryClient = createTestQueryClient()
  queryClient.setQueryData(['todos'], [
    { id: 1, title: 'Original', completed: false },
  ])

  server.use(
    http.patch('/api/todos/1', () => {
      return new HttpResponse(null, { status: 500 })
    })
  )

  const { result } = renderHook(() => useUpdateTodo(), {
    wrapper: ({ children }) => (
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    ),
  })

  act(() => {
    result.current.mutate({ id: 1, completed: true })
  })

  // Check optimistic update
  expect(queryClient.getQueryData(['todos'])).toEqual([
    { id: 1, title: 'Original', completed: true },
  ])

  // Wait for rollback
  await waitFor(() => expect(result.current.isError).toBe(true))

  // Should rollback
  expect(queryClient.getQueryData(['todos'])).toEqual([
    { id: 1, title: 'Original', completed: false },
  ])
})

Best Practices

Disable retries in tests Use MSW for consistent mocking Test loading, success, and error states Test optimistic updates and rollbacks Use waitFor for async updates Prefill cache when testing with existing data Silence console errors in tests Don't test implementation details Don't mock TanStack Query internals