| | |

TanStack Query 16 ⚛️ Testing Queries — Writing tests with QueryClientProvider

⏱️ Estimated read: 12 min | |

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

OptionValuePurpose
retryfalseDisable retries for fast failure tests
gcTimeInfinityPrevent garbage collection during tests
logger.error() => {}Silence expected error logs
staleTime0Always consider data stale in tests

Test Utilities

FunctionPurpose
createTestQueryClient()New isolated client per test
createWrapper()Provider wrapper for renderHook
renderWithClient()Render a component with the provider

Hook Test Pattern

StepCode
RenderrenderHook(() => useHook(), { wrapper })
Initial stateexpect(result.current.isPending).toBe(true)
Waitawait waitFor(() => expect(result.current.isSuccess).toBe(true))
Assertexpect(result.current.data).toEqual(...)

MSW Setup

FunctionPurpose
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

PitfallWhy It HappensFix
Test times outRetries not disabledretry: false
Assertion on undefined dataNo waitForAdd waitFor
Cache leaks between testsShared clientNew client per test
Jest does not exitgcTime not set to InfinitySet gcTime: Infinity
Error logs clutter outputLogger not silencedlogger: { 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

ItemValue
Test library@testing-library/react
Hook rendererrenderHook
Async waitingwaitFor
Test clientnew QueryClient({ defaultOptions: { queries: { retry: false } } })
Jest fixgcTime: Infinity
Network mockingMSW (setupServer)
Setup lifecyclebeforeAll listen, afterEach reset, afterAll close
Error testserver.use with a 500 response
Mutation testact(() => mutate(...)) + waitFor

Key takeaways:

  • The renderHook wrapper supplies the QueryClientProvider. 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: false is mandatory for error tests. The default three retries with exponential backoff cause the test to time out before the isError state is reached. The retry: false option in the test client’s defaultOptions disables the retries .
  • waitFor is mandatory for the resolved data. The query resolves in a microtask, and the synchronous assertion runs before the data arrives. The waitFor call polls the assertion until the condition is true or the timeout expires .
  • A fresh QueryClient per test prevents cache leaks. A module-scope client is shared across tests, and the first test’s data is visible to the second. The createTestQueryClient factory function returns a new client for each test .
  • MSW intercepts the network at the boundary. The setupServer function creates the mock server, and the server.use call overrides the handler for a specific test. The server.resetHandlers call in afterEach prevents the override from leaking .
  • The gcTime: Infinity option prevents the Jest warning. The default garbage collection removes the cache entries during the test, and the “Jest did not exit” warning appears. The Infinity value 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.


Was this helpful?


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!