This episode teaches testing for TanStack Query: a test-specific QueryClient with retry false, testing-library and renderHook, API mocking with MSW, testing loading, success, and error states, as well as fake timers for refetchInterval.

Code that isn't tested is code that is assumed not to work. TanStack Query introduces several testing challenges: the built-in retry slows down error tests, the cache can leak between tests, and queryFn makes real network requests. All of them have battle-tested solutions.
Episode 18 covers the testing patterns for TanStack Query: a test-specific QueryClient, renderHook from testing-library, API mocking with MSW, and testing the three main states.
Tests must be fast and deterministic. Retrying 3 times with increasing delays makes an error test run for a very long time, so turn it off in the test configuration:
import { QueryClient } from "@tanstack/react-query"
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
mutations: {
retry: false,
},
},
})
}retry: false makes a failed query immediately produce an error without waiting for a delay. gcTime: 0 prevents the cache from leaking between tests. createTestQueryClient produces a fresh instance per test — avoid sharing a single QueryClient across all tests.
To test a hook, wrap it with QueryClientProvider and use renderHook:
import { renderHook, waitFor } from "@testing-library/react"
import { QueryClientProvider } from "@tanstack/react-query"
function wrapper({ children }) {
return (
<QueryClientProvider client={createTestQueryClient()}>
{children}
</QueryClientProvider>
)
}
test("useTodos menampilkan data", async () => {
const { result } = renderHook(() => useTodos(), { wrapper })
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(result.current.data).toHaveLength(10)
})renderHook tests a hook without rendering a full component. waitFor waits until the query finishes, then the assertion checks the result. The wrapper pattern that injects the test-specific QueryClient is reused across all tests.
MSW (Mock Service Worker) intercepts network requests at the service worker level, so the fetch in queryFn runs normally without mock code:
npm install -D msw
npx msw init public/ --savenpx msw init public/ --save creates the service worker that MSW uses in the browser. msw is installed as a dev dependency because it's only used during testing and development.
Mocking an endpoint is like defining a real server:
import { http, HttpResponse } from "msw"
export const handlers = [
http.get("https://jsonplaceholder.typicode.com/todos", () => {
return HttpResponse.json([{ id: 1, title: "Belajar test", completed: false }])
}),
http.get("https://jsonplaceholder.typicode.com/todos/:id", ({ params }) => {
const id = Number(params.id)
return HttpResponse.json({ id, title: `Todo ${id}`, completed: false })
}),
]http.get defines the endpoint and the mock response. HttpResponse.json returns JSON with a 200 status. The :id pattern in the URL captures dynamic parameters — matching the ["todos", id] query.
With MSW, you can simulate all states: success, loading, and error. For errors, return a 500 status:
http.get("https://jsonplaceholder.typicode.com/todos", () => {
return HttpResponse.json(
{ message: "Server error" },
{ status: 500 }
)
})HttpResponse.json with status: 500 makes queryFn throw an error (if res.ok is checked). With retry: false, the error test finishes quickly and isError can be asserted.
A query with refetchInterval waits for real time, which slows down tests. Use fake timers to speed things up:
import { vi } from "vitest"
vi.useFakeTimers()
test("polling berjalan", async () => {
const { result } = renderHook(() => useQuery({
queryKey: ["status"],
queryFn: fetchStatus,
refetchInterval: 5000,
}), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
const fetchCount = vi.mocked(fetchStatus).mock.calls.length
vi.advanceTimersByTime(5000)
await vi.waitFor(() => {
expect(vi.mocked(fetchStatus).mock.calls.length).toBeGreaterThan(fetchCount)
})
})vi.useFakeTimers() replaces the real timers, and vi.advanceTimersByTime(5000) skips the 5-second interval. vi.mocked checks how many times queryFn was called — proof that polling runs. advanceTimersByTime gives full control over time without waiting for real time.
Warning
When using fake timers, call vi.useRealTimers() at the end of the test or in afterEach. Fake timers leaking into other tests can make waitFor hang indefinitely.
Episode 18 turned TanStack Query testing into a light routine: a test-specific QueryClient with retry: false, renderHook with a wrapper, MSW API mocking for the three states, and fake timers for polling.
Key takeaways:
retry and set gcTime: 0 in the test QueryClient.renderHook with a provider as the wrapper.refetchInterval tests.In the next episode, episode 19, we will discuss framework adapters and core — Vue Query, Svelte Query, Solid Query, and Preact Query sharing @tanstack/query-core, plus using QueryClient outside a framework for Node services and event handlers.