QueryClientProvider and handling of async behavior.
Basic Setup
Wrap your components in aQueryClientProvider for testing:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false, // Disable retries in tests
},
},
})
}
function renderWithClient(ui: React.ReactElement) {
const testQueryClient = createTestQueryClient()
const { rerender, ...result } = render(
<QueryClientProvider client={testQueryClient}>
{ui}
</QueryClientProvider>
)
return {
...result,
rerender: (rerenderUi: React.ReactElement) =>
rerender(
<QueryClientProvider client={testQueryClient}>
{rerenderUi}
</QueryClientProvider>
),
}
}
test('my query test', async () => {
renderWithClient(<MyComponent />)
// ... your test
})
Testing Queries
Successful Query
import { renderWithClient } from './test-utils'
import { screen, waitFor } from '@testing-library/react'
function Posts() {
const { data, isLoading } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
if (isLoading) return <div>Loading...</div>
return (
<ul>
{data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
test('should display posts', async () => {
// Mock the API
global.fetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve([{ id: 1, title: 'Test Post' }]),
})
)
renderWithClient(<Posts />)
// Wait for loading to finish
expect(screen.getByText('Loading...')).toBeInTheDocument()
// Wait for data to appear
await waitFor(() => {
expect(screen.getByText('Test Post')).toBeInTheDocument()
})
})
Query Error
test('should handle error', async () => {
// Mock API error
global.fetch = vi.fn(() => Promise.reject(new Error('Failed to fetch')))
renderWithClient(<Posts />)
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument()
})
})
Testing Mutations
Successful Mutation
import { renderWithClient } from './test-utils'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
function CreatePost() {
const [title, setTitle] = useState('')
const mutation = useMutation({
mutationFn: (newPost: { title: string }) => {
return fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(newPost),
})
},
})
return (
<div>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Post title"
/>
<button onClick={() => mutation.mutate({ title })}>Create</button>
{mutation.isPending && <div>Creating...</div>}
{mutation.isSuccess && <div>Post created!</div>}
</div>
)
}
test('should create a post', async () => {
const user = userEvent.setup()
global.fetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ id: 1, title: 'New Post' }),
})
)
renderWithClient(<CreatePost />)
// Type in input
await user.type(screen.getByPlaceholderText('Post title'), 'New Post')
// Click create button
await user.click(screen.getByText('Create'))
// Check pending state
expect(screen.getByText('Creating...')).toBeInTheDocument()
// Wait for success
await waitFor(() => {
expect(screen.getByText('Post created!')).toBeInTheDocument()
})
// Verify API was called
expect(global.fetch).toHaveBeenCalledWith(
'/api/posts',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ title: 'New Post' }),
})
)
})
Mutation Error
test('should handle mutation error', async () => {
const user = userEvent.setup()
global.fetch = vi.fn(() => Promise.reject(new Error('Failed to create')))
renderWithClient(<CreatePost />)
await user.type(screen.getByPlaceholderText('Post title'), 'New Post')
await user.click(screen.getByText('Create'))
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument()
})
})
Using MSW (Mock Service Worker)
MSW is recommended for API mocking:import { rest } from 'msw'
import { setupServer } from 'msw/node'
const server = setupServer(
rest.get('/api/posts', (req, res, ctx) => {
return res(
ctx.json([
{ id: 1, title: 'First Post' },
{ id: 2, title: 'Second Post' },
])
)
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
test('should display posts', async () => {
renderWithClient(<Posts />)
await waitFor(() => {
expect(screen.getByText('First Post')).toBeInTheDocument()
expect(screen.getByText('Second Post')).toBeInTheDocument()
})
})
test('should handle server error', async () => {
// Override handler for this test
server.use(
rest.get('/api/posts', (req, res, ctx) => {
return res(ctx.status(500))
})
)
renderWithClient(<Posts />)
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument()
})
})
Testing Hooks
UserenderHook from React Testing Library:
import { renderHook, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
function usePosts() {
return useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
}
test('usePosts hook', async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
global.fetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve([{ id: 1, title: 'Test' }]),
})
)
const { result } = renderHook(() => usePosts(), { wrapper })
// Initially loading
expect(result.current.isLoading).toBe(true)
// Wait for success
await waitFor(() => expect(result.current.isSuccess).toBe(true))
// Check data
expect(result.current.data).toEqual([{ id: 1, title: 'Test' }])
})
Testing with Prefetched Data
Set initial data in tests:test('should use prefetched data', async () => {
const queryClient = new QueryClient()
// Prefetch data
queryClient.setQueryData(['posts'], [
{ id: 1, title: 'Prefetched Post' },
])
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
render(<Posts />, { wrapper })
// Data should appear immediately
expect(screen.getByText('Prefetched Post')).toBeInTheDocument()
})
Testing Optimistic Updates
test('should show optimistic update', async () => {
const user = userEvent.setup()
const queryClient = new QueryClient()
// Set initial data
queryClient.setQueryData(['todos'], [
{ id: 1, text: 'Existing Todo' },
])
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
render(<TodoList />, { wrapper })
// Add new todo
await user.type(screen.getByPlaceholderText('New todo'), 'New Todo')
await user.click(screen.getByText('Add'))
// Should see optimistic update immediately
expect(screen.getByText('New Todo')).toBeInTheDocument()
// Wait for server confirmation
await waitFor(() => {
expect(global.fetch).toHaveBeenCalled()
})
})
Testing Query Invalidation
test('should invalidate queries', async () => {
const queryClient = new QueryClient()
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
const { result } = renderHook(() => usePosts(), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
// Invalidate
queryClient.invalidateQueries({ queryKey: ['posts'] })
// Should refetch
await waitFor(() => expect(result.current.isFetching).toBe(true))
})
Testing Suspense Queries
import { Suspense } from 'react'
function PostsWithSuspense() {
const { data } = useSuspenseQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
return (
<ul>
{data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
test('should render with suspense', async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve([{ id: 1, title: 'Test' }]),
})
)
renderWithClient(
<Suspense fallback={<div>Loading...</div>}>
<PostsWithSuspense />
</Suspense>
)
expect(screen.getByText('Loading...')).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByText('Test')).toBeInTheDocument()
})
})
Custom Test Utilities
Create reusable test utilities:// test-utils.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, renderHook } from '@testing-library/react'
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: Infinity,
},
mutations: {
retry: false,
},
},
logger: {
log: console.log,
warn: console.warn,
error: () => {}, // Silence errors in tests
},
})
}
export function createWrapper() {
const testQueryClient = createTestQueryClient()
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={testQueryClient}>
{children}
</QueryClientProvider>
)
}
export function renderWithClient(ui: React.ReactElement) {
const testQueryClient = createTestQueryClient()
return render(
<QueryClientProvider client={testQueryClient}>
{ui}
</QueryClientProvider>
)
}
export function renderHookWithClient<TResult, TProps>(
hook: (props: TProps) => TResult
) {
return renderHook(hook, { wrapper: createWrapper() })
}
import { renderWithClient, renderHookWithClient } from './test-utils'
test('my test', async () => {
renderWithClient(<MyComponent />)
// ...
})
test('my hook test', async () => {
const { result } = renderHookWithClient(() => useMyQuery())
// ...
})
Testing Error Boundaries
import { ErrorBoundary } from 'react-error-boundary'
test('should catch query error in error boundary', async () => {
global.fetch = vi.fn(() => Promise.reject(new Error('API Error')))
renderWithClient(
<ErrorBoundary fallback={<div>Error occurred</div>}>
<Suspense fallback={<div>Loading...</div>}>
<PostsWithSuspense />
</Suspense>
</ErrorBoundary>
)
await waitFor(() => {
expect(screen.getByText('Error occurred')).toBeInTheDocument()
})
})
Best Practices
- Always disable retry in tests for faster, predictable tests
- Use MSW for API mocking instead of manual fetch mocks
- Create test utilities for common setup
- Test all states - loading, success, error
- Use waitFor for async assertions
- Mock at the network level not the query level
- Test user interactions not implementation details
Common Pitfalls
1. Not Waiting for Async
// ❌ Bad - Doesn't wait for data
test('bad test', () => {
renderWithClient(<Posts />)
expect(screen.getByText('Test Post')).toBeInTheDocument()
})
// ✅ Good - Waits for data
test('good test', async () => {
renderWithClient(<Posts />)
await waitFor(() => {
expect(screen.getByText('Test Post')).toBeInTheDocument()
})
})
2. Shared QueryClient
// ❌ Bad - Shared client causes test pollution
const queryClient = new QueryClient()
test('test 1', () => {
render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
)
})
// ✅ Good - Fresh client for each test
test('test 2', () => {
const queryClient = new QueryClient()
render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
)
})
3. Not Cleaning Up
// ✅ Clean up after each test
afterEach(() => {
vi.clearAllMocks()
})