TanStack Query 16 ⚛️ Testing Queries — Writing tests with QueryClientProvider
Testing a TanStack Query hook is not the same as testing a plain React hook. The hook depends on a QueryClient that lives in React context, and it performs asynchronous network requests that resolve after the test’s synchronous assertions have finished. A test that renders the hook without a provider throws immediately. A test that renders it with a provider but without waitFor asserts on the initial pending state and never sees the resolved data. The previous chapters covered the hook’s behavior in the application; this chapter covers the test harness that exercises that behavior in isolation.
The standard approach uses renderHook from @testing-library/react with a wrapper option that supplies a fresh QueryClientProvider for each test . The provider is the same component that the application uses, and the QueryClient is configured with retry: false to prevent the three default retries from turning a failing assertion into a timeout . The test then uses waitFor to wait for the query to settle, and asserts on the isSuccess flag and the data property. The network is mocked with MSW, which intercepts the requests at the network level rather than replacing fetch .
Key point: Every test needs a fresh QueryClient, and every QueryClient needs retry: false. A shared client across tests leaks the cache from one test into the next, and the default retries with exponential backoff cause the error tests to time out before the assertion runs . The waitFor call is mandatory because the query resolves in a microtask, and the synchronous assertion runs before the data arrives.
Why testing queries requires a dedicated wrapper
The provider problem.
The useQuery hook reads the QueryClient from React context. A test that calls renderHook(() => useTodos()) without a provider throws “No QueryClient set” because the context is empty. The wrapper option accepts a component that wraps the hook in the provider, and the renderHook function renders the hook inside that component . The wrapper is the test’s substitute for the application’s root provider.
The retry problem.
The QueryClient defaults to three retries with exponential backoff. A test that mocks a network error and asserts on the isError state waits for the retries to exhaust before the state becomes isError, and the default test timeout of five seconds expires before the assertion runs . The retry: false option in the test client’s defaultOptions disables the retries, and the error state becomes available immediately.
The isolation problem.
A QueryClient created at module scope is shared across every test in the file. The cache from the first test is visible to the second, and the second test’s assertions are influenced by the first test’s data. The createTestQueryClient function returns a new client for each test, and the wrapper uses that client . The isolation is the reason the factory function exists.
a. The test client and the wrapper
The test client is a QueryClient with the retry: false option and, for Jest, gcTime: Infinity to prevent the “Jest did not exit” warning.
// src/test-utils.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, renderHook, type RenderOptions } from '@testing-library/react'
import type { ReactNode } from 'react'
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false, // Disable retries in tests
gcTime: Infinity, // Prevent garbage collection during tests
},
},
logger: {
log: console.log,
warn: console.warn,
error: () => {}, // Silence expected error logs
},
})
}
export function createWrapper() {
const queryClient = createTestQueryClient()
return function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
}
The createTestQueryClient function returns a new client with the retry: false option. The gcTime: Infinity option prevents the cache from being garbage-collected during the test, which is the fix for the Jest warning . The logger.error override silences the expected error logs from the tests that exercise the error state. The createWrapper function returns a component that wraps its children in the provider, and the renderHook call uses it as the wrapper option.
b. Testing a query hook
The test renders the hook with the wrapper, waits for the isSuccess flag, and asserts on the data.
// src/hooks/useTodos.test.tsx
import { renderHook, waitFor } from '@testing-library/react'
import { useTodos } from './useTodos'
import { createWrapper } from '../test-utils'
test('fetches todos successfully', async () => {
const { result } = renderHook(() => useTodos(), {
wrapper: createWrapper(),
})
// The initial state is pending
expect(result.current.isPending).toBe(true)
// Wait for the query to settle
await waitFor(() => expect(result.current.isSuccess).toBe(true))
// Assert on the data
expect(result.current.data).toHaveLength(3)
})
The renderHook call returns a result object whose current property is the hook’s return value . The initial assertion checks isPending, which is true before the query resolves. The waitFor call waits until the isSuccess flag becomes true, and the assertion after it sees the resolved data. The waitFor is the mechanism that bridges the asynchronous query and the synchronous test.
c. Testing the error state with MSW
The error state is tested by overriding the MSW handler to return a 500 response. The waitFor call waits for the isError flag, and the assertion checks the error message.
// src/hooks/useTodos.test.tsx
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { renderHook, waitFor } from '@testing-library/react'
import { useTodos } from './useTodos'
import { createWrapper } from '../test-utils'
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('handles the error state', async () => {
server.use(
http.get('/api/todos', () => {
return new HttpResponse(null, { status: 500 })
})
)
const { result } = renderHook(() => useTodos(), {
wrapper: createWrapper(),
})
await waitFor(() => expect(result.current.isError).toBe(true))
expect(result.current.error?.message).toContain('500')
})
The setupServer function creates the MSW server, and the server.listen and server.close calls start and stop it. The server.resetHandlers call in the afterEach resets the handlers to the default set, so the override in the error test does not leak into the other tests . The server.use call overrides the handler for the duration of the test, and the waitFor call waits for the error state.
Complete Example Session
// ============================================
// PART 1: THE TEST UTILITIES
// ============================================
// src/test-utils.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { ReactNode } from 'react'
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: Infinity },
},
logger: { log: console.log, warn: console.warn, error: () => {} },
})
}
export function createWrapper() {
const queryClient = createTestQueryClient()
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
}
// ============================================
// PART 2: THE HOOK UNDER TEST
// ============================================
// src/hooks/useTodos.ts
import { useQuery } from '@tanstack/react-query'
export function useTodos() {
return useQuery({
queryKey: ['todos'],
queryFn: async () => {
const res = await fetch('/api/todos')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
},
})
}
// ============================================
// PART 3: THE MSW SERVER
// ============================================
// src/mocks/server.ts
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
export const server = setupServer(
http.get('/api/todos', () => {
return HttpResponse.json([
{ id: 1, title: 'Test todo', completed: false },
])
})
)
// ============================================
// PART 4: THE TEST FILE
// ============================================
// src/hooks/useTodos.test.tsx
import { renderHook, waitFor } from '@testing-library/react'
import { useTodos } from './useTodos'
import { createWrapper } from '../test-utils'
import { server } from '../mocks/server'
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
test('fetches todos successfully', async () => {
const { result } = renderHook(() => useTodos(), {
wrapper: createWrapper(),
})
expect(result.current.isPending).toBe(true)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual([
{ id: 1, title: 'Test todo', completed: false },
])
})
test('handles the error state', async () => {
server.use(
http.get('/api/todos', () => new HttpResponse(null, { status: 500 }))
)
const { result } = renderHook(() => useTodos(), {
wrapper: createWrapper(),
})
await waitFor(() => expect(result.current.isError).toBe(true))
expect(result.current.error?.message).toContain('500')
})
The four parts demonstrate the complete testing setup: the test utilities with the isolated client and the wrapper, the hook under test, the MSW server with the default handler, and the test file with the success and error tests. The critical configuration is the retry: false in the test client, which makes the error test complete quickly instead of waiting for the retries to exhaust.
Quick Reference
Test Client Options
| Option | Value | Purpose |
|---|---|---|
retry | false | Disable retries for fast failure tests |
gcTime | Infinity | Prevent garbage collection during tests |
logger.error | () => {} | Silence expected error logs |
staleTime | 0 | Always consider data stale in tests |
Test Utilities
| Function | Purpose |
|---|---|
createTestQueryClient() | New isolated client per test |
createWrapper() | Provider wrapper for renderHook |
renderWithClient() | Render a component with the provider |
Hook Test Pattern
| Step | Code |
|---|---|
| Render | renderHook(() => useHook(), { wrapper }) |
| Initial state | expect(result.current.isPending).toBe(true) |
| Wait | await waitFor(() => expect(result.current.isSuccess).toBe(true)) |
| Assert | expect(result.current.data).toEqual(...) |
MSW Setup
| Function | Purpose |
|---|---|
setupServer(...handlers) | Create the mock server |
server.listen() | Start in beforeAll |
server.resetHandlers() | Reset in afterEach |
server.close() | Stop in afterAll |
server.use(...) | Override for a specific test |
Best Practices
✅ Do This:
// Create a fresh client per test
const queryClient = createTestQueryClient() # ✅
// Disable retries
retry: false # ✅
// Use waitFor before asserting on resolved data
await waitFor(() => expect(result.current.isSuccess).toBe(true)) # ✅
// Use MSW for network mocking
server.use(http.get('/api/todos', () => new HttpResponse(null, { status: 500 }))) # ✅
// Reset handlers between tests
afterEach(() => server.resetHandlers()) # ✅
// Silence expected error logs
logger: { error: () => {} } # ✅
❌ Don’t Do This:
// Share a QueryClient across tests
const queryClient = new QueryClient() // Leaks cache # ❌
// Assert without waitFor
expect(result.current.isSuccess).toBe(true) // Too early # ❌
// Leave retries enabled
retry: 3 // Error tests time out # ❌
// Replace global.fetch instead of using MSW
global.fetch = vi.fn() // Does not match the network boundary # ❌
// Forget to close the server
afterAll(() => server.close()) # ✅ (do this)
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Test times out | Retries not disabled | retry: false |
| Assertion on undefined data | No waitFor | Add waitFor |
| Cache leaks between tests | Shared client | New client per test |
| Jest does not exit | gcTime not set to Infinity | Set gcTime: Infinity |
| Error logs clutter output | Logger not silenced | logger: { error: () => {} } |
Real-World Examples
1. Create the test client
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
2. Create the wrapper
const wrapper = ({ children }) => <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
3. Render the hook
const { result } = renderHook(() => useTodos(), { wrapper })
4. Wait for success
await waitFor(() => expect(result.current.isSuccess).toBe(true))
5. Assert on the data
expect(result.current.data).toHaveLength(3)
6. Test the error state
server.use(http.get('/api/todos', () => new HttpResponse(null, { status: 500 })))
7. Test a mutation
act(() => { result.current.mutate({ title: 'New' }) })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
8. Test a component
render(<QueryClientProvider client={queryClient}><TodoList /></QueryClientProvider>)
await waitFor(() => expect(screen.getByText('Test todo')).toBeInTheDocument())
9. Pre-populate the cache
queryClient.setQueryData(['todos'], [{ id: 1, title: 'Cached' }])
10. Reset handlers
afterEach(() => server.resetHandlers())
Visual
The Test Wrapper
┌──────────────────────────────────────────────────────────────┐
│ THE WRAPPER SUPPLIES THE QUERYCLIENT CONTEXT │
│ │
│ renderHook(() => useTodos(), { wrapper }) │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ <QueryClientProvider client={testClient}> │ │
│ │ <HookComponent /> │ │
│ │ </QueryClientProvider> │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ Without the wrapper: "No QueryClient set" │
│ With the wrapper: the hook runs in isolation │
└──────────────────────────────────────────────────────────────┘
The waitFor Bridge
┌──────────────────────────────────────────────────────────────┐
│ waitFor BRIDGES THE ASYNC QUERY AND THE SYNC TEST │
│ │
│ renderHook → isPending: true │
│ │ │
│ ▼ │
│ waitFor(() => expect(isSuccess).toBe(true)) │
│ │ │
│ ├── not yet → retry the assertion │
│ │ │
│ └── success → continue │
│ │ │
│ ▼ │
│ expect(data).toEqual(...) │
│ │
│ The waitFor polls until the condition is true or the │
│ timeout expires. │
└──────────────────────────────────────────────────────────────┘
Retry: false
┌──────────────────────────────────────────────────────────────┐
│ RETRY: FALSE MAKES ERROR TESTS FAST │
│ │
│ Without retry: false: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Request fails → wait 1s → retry → wait 2s → retry │ │
│ │ → wait 4s → isError: true │ │
│ │ → the test times out before the assertion │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ With retry: false: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Request fails → isError: true │ │
│ │ → the assertion runs immediately │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Test library | @testing-library/react |
| Hook renderer | renderHook |
| Async waiting | waitFor |
| Test client | new QueryClient({ defaultOptions: { queries: { retry: false } } }) |
| Jest fix | gcTime: Infinity |
| Network mocking | MSW (setupServer) |
| Setup lifecycle | beforeAll listen, afterEach reset, afterAll close |
| Error test | server.use with a 500 response |
| Mutation test | act(() => mutate(...)) + waitFor |
Key takeaways:
- The
renderHookwrapper supplies theQueryClientProvider. The hook reads the client from React context, and a test without the provider throws “No QueryClient set.” The wrapper is the test’s substitute for the application’s root provider . retry: falseis mandatory for error tests. The default three retries with exponential backoff cause the test to time out before theisErrorstate is reached. Theretry: falseoption in the test client’sdefaultOptionsdisables the retries .waitForis mandatory for the resolved data. The query resolves in a microtask, and the synchronous assertion runs before the data arrives. ThewaitForcall polls the assertion until the condition is true or the timeout expires .- A fresh
QueryClientper test prevents cache leaks. A module-scope client is shared across tests, and the first test’s data is visible to the second. ThecreateTestQueryClientfactory function returns a new client for each test . - MSW intercepts the network at the boundary. The
setupServerfunction creates the mock server, and theserver.usecall overrides the handler for a specific test. Theserver.resetHandlerscall inafterEachprevents the override from leaking . - The
gcTime: Infinityoption prevents the Jest warning. The default garbage collection removes the cache entries during the test, and the “Jest did not exit” warning appears. TheInfinityvalue keeps the entries alive until the test finishes .
Remember: Testing a TanStack Query hook is a three-part harness: a fresh QueryClient with retry: false, a wrapper that supplies the QueryClientProvider, and a waitFor that waits for the query to settle. The network is mocked with MSW, and the error state is tested by overriding the handler with a 500 response. The act function wraps the mutation trigger, and the waitFor waits for the mutation’s success. The result is a test that exercises the hook’s behavior in isolation, without the retries, the cache leaks, or the asynchronous timing that makes the naive test fail.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!