6.1 KiB
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