Data Fetching & Cache Testing
Most components in a modern application show data they did not create. They ask a query library — TanStack Query, SWR, RTK Query, Apollo — for it, render a loading state while it arrives, render the data or an error when it does, and refetch when the data might be stale. Those libraries add a cache between the component and the network, and the cache is where test suites go wrong: data from one test appears in the next, retries turn a quick failure into a slow timeout, background refetches fire after the test has finished, and optimistic updates are tested only on their happy path. This topic belongs to component and integration testing frameworks and covers testing data-driven components with the network intercepted and the cache under control, so every state a user can see is reachable deterministically.
Architectural Scope & Boundaries
This topic covers the component tier: a component that loads data through a query library, rendered in a simulated DOM with a real query client and an intercepted network. That combination tests the component’s loading, success and error rendering, its interaction with the cache — refetching, invalidation, optimistic updates — and the request it actually sends, all without a server.
The key boundary is what to replace. Mocking the query hook itself — vi.mock('@tanstack/react-query') returning fixed data — is tempting and nearly always wrong. It removes the cache, so staleness, deduplication and invalidation go untested; it removes the network, so the request’s shape goes unchecked; and it couples tests to how the component calls the hook. Intercepting the network instead keeps all of that real while making responses fully controllable.
The second boundary is the query client’s configuration. Production defaults — three retries with backoff, data cached for five minutes, refetch on window focus — are sensible for users and hostile to tests: a failing request takes seconds to surface, and data leaks between tests through the shared cache. Tests need a client configured for them, created fresh for each test, which is the single most important setup decision in this topic.
What this topic does not cover is server-side data fetching in React Server Components or framework loaders, which run outside the client cache; those are covered in React state and hydration testing. Nor does it cover the correctness of the API itself, which belongs to the service’s own tests and to contract testing.
There is also a boundary around hooks tested on their own. A custom hook that wraps a query — useOrders(filters) — can be tested with renderHook and the same fresh client, which is often clearer than rendering a whole component. The guides below use both, choosing by whether the behaviour under test is about the data or about its presentation.
It is worth naming why these libraries became standard, because their benefits are exactly what makes them tricky to test. They deduplicate identical requests, share results between components, keep stale data on screen while fetching fresh data, retry transient failures, and refetch when the user returns to the tab. Every one of those behaviours depends on state held outside any component and on time passing. A test that removes the library removes the behaviours; a test that keeps it must control its state and its clock. That is the whole shape of the techniques below.
Prerequisites
Step-by-Step Implementation
Step 1 — Create a fresh client per test, configured for tests. Retries off so failures surface immediately; garbage collection immediate so nothing lingers; no refetch on window focus.
// test/render-with-query.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render } from '@testing-library/react';
import type { ReactElement } from 'react';
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: Infinity, staleTime: 0, refetchOnWindowFocus: false },
mutations: { retry: false },
},
});
}
export function renderWithQuery(ui: ReactElement, client = createTestQueryClient()) {
return { client, ...render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>) };
}
gcTime: Infinity looks surprising in a setup meant to prevent leaks, but the reasoning is sound. The client is thrown away after each test, so nothing it caches can outlive the test; what matters is that it does not schedule garbage-collection timers that keep the worker busy or fire into later tests. Retries are the setting with the biggest practical effect: with the production default, every error-state test waits through several seconds of backoff before the error renders.
Step 2 — Intercept the network, not the hook. Handlers return realistic payloads; each test overrides them for its scenario.
// test/msw/orders.ts
import { http, HttpResponse, delay } from 'msw';
export const orders = {
list: (items = [{ id: 'o1', total: 4999, status: 'placed' }]) =>
http.get('/api/orders', () => HttpResponse.json({ items, nextCursor: null })),
slow: () => http.get('/api/orders', async () => { await delay(200); return HttpResponse.json({ items: [], nextCursor: null }); }),
failing: () => http.get('/api/orders', () => new HttpResponse(null, { status: 500 })),
};
Keeping handlers in named factories per endpoint gives tests a vocabulary — orders.slow(), orders.failing() — that reads like the scenario being set up. It also keeps realistic default payloads in one place, so when the API adds a field the fixture is updated once and every test benefits, rather than each test carrying its own hand-written and slowly diverging response body.
Step 3 — Test each visible state. Loading, data, empty and error are four different renders, and each deserves an assertion.
test('shows a loading state, then the orders', async () => {
server.use(orders.slow(), orders.list([{ id: 'o1', total: 4999, status: 'placed' }]));
renderWithQuery(<OrdersTable />);
expect(screen.getByRole('progressbar', { name: 'Loading orders' })).toBeInTheDocument();
expect(await screen.findByRole('row', { name: /o1/ })).toBeInTheDocument();
});
test('shows an empty state when there are no orders', async () => {
server.use(orders.list([]));
renderWithQuery(<OrdersTable />);
expect(await screen.findByText('You have no orders yet')).toBeInTheDocument();
});
test('shows a retryable error when loading fails', async () => {
server.use(orders.failing());
renderWithQuery(<OrdersTable />);
expect(await screen.findByRole('alert')).toHaveTextContent('We could not load your orders');
expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
});
The empty state deserves emphasis because it is the one teams most often ship broken. It is not a special case of the data state: a table with no rows looks like a failure to users, and a good component explains what an empty result means and what to do next. The error state likewise needs a way forward — a retry control — and a test that clicks it and sees the data load closes the loop on recovery.
Step 4 — Test invalidation after a mutation. When the user changes data, dependent queries should refetch; asserting the refetched content proves the invalidation is wired.
test('cancelling an order refreshes the list', async () => {
let status = 'placed';
server.use(
http.get('/api/orders', () => HttpResponse.json({ items: [{ id: 'o1', total: 4999, status }], nextCursor: null })),
http.post('/api/orders/o1/cancel', () => { status = 'cancelled'; return HttpResponse.json({ ok: true }); }),
);
const user = userEvent.setup();
renderWithQuery(<OrdersTable />);
await user.click(await screen.findByRole('button', { name: 'Cancel order o1' }));
expect(await screen.findByRole('row', { name: /o1.*cancelled/i })).toBeInTheDocument();
});
The handler here mutates a variable the list handler reads, which is a small in-memory stand-in for the server’s state. That is enough to prove the essential wiring: the mutation succeeded, the related query was invalidated, the refetch happened, and the new data rendered. Without invalidation the row would still say “placed”, and the test would fail for exactly the reason users would report.
Step 5 — Seed the cache when the test is about what happens next. A component that renders cached data instantly, then revalidates, is tested by seeding the client before rendering.
test('renders cached orders immediately while revalidating', async () => {
const client = createTestQueryClient();
client.setQueryData(['orders'], { items: [{ id: 'cached', total: 100, status: 'placed' }], nextCursor: null });
server.use(orders.list([{ id: 'fresh', total: 200, status: 'placed' }]));
renderWithQuery(<OrdersTable />, client);
expect(screen.getByRole('row', { name: /cached/ })).toBeInTheDocument();
expect(await screen.findByRole('row', { name: /fresh/ })).toBeInTheDocument();
});
Seeding is also the fastest way to test a component’s behaviour with data it would normally have fetched much earlier — a detail page opened from a list, say, which should render instantly from the list’s cached entry. Setting the query data directly states that precondition in one line, instead of rendering the list, clicking through and waiting, which would test the navigation rather than the detail page’s use of the cache.
Step 6 — Test hooks directly when the behaviour is about data. A hook that combines queries or derives values is clearer to test with renderHook than through a table.
test('useOrderTotals sums only placed orders', async () => {
server.use(orders.list([{ id: 'a', total: 100, status: 'placed' }, { id: 'b', total: 50, status: 'cancelled' }]));
const client = createTestQueryClient();
const { result } = renderHook(() => useOrderTotals(), {
wrapper: ({ children }) => <QueryClientProvider client={client}>{children}</QueryClientProvider>,
});
await waitFor(() => expect(result.current.data).toEqual({ count: 1, totalPence: 100 }));
});
A rule of thumb for choosing between the two: if the assertion is about a number, a list or a derived value, test the hook; if it is about what the user sees or can click, render the component. Hook tests are shorter and fail with clearer messages for data problems, while component tests are the only place loading indicators, empty states and retry buttons can be verified.
Configuration Reference Table
| Option | Production default | Test setting | Why |
|---|---|---|---|
retry |
3 with backoff | false |
Errors surface immediately instead of after seconds. |
gcTime |
5 minutes | Infinity |
Avoids timers firing after the test; the client is discarded anyway. |
staleTime |
0 | 0, or per test | Keep default unless the test is about staleness. |
refetchOnWindowFocus |
true |
false |
jsdom focus events should not trigger surprise fetches. |
refetchInterval |
off | fake timers | Advance time rather than waiting for polling. |
| client instance | one per app | one per test | The cache is shared state; a fresh client is isolation. |
onUnhandledRequest |
— | 'error' |
Any unexpected fetch fails the test loudly. |
dedupingInterval (SWR) |
2 seconds | 0 | Stops SWR serving stale results between tests. |
Verification & Assertions
The first verification is isolation. Run the data-fetching tests in shuffled order; with a fresh client per test they all pass, and any that fail are sharing a client or a module-level cache.
npx vitest run src/orders --sequence.shuffle --sequence.seed=5
# ✓ 14 passed
Isolation also covers the network handlers: server.resetHandlers() after each test must restore the defaults, or an error override from one test turns the next test’s data state into an error state.
The second is that errors surface quickly. A suite with retries left on shows error tests taking several seconds each; with retries off they take milliseconds. Sorting tests by duration finds any that still retry.
The third, and most valuable, is asserting on the request as well as the rendering for any query with parameters. Filters, pagination cursors and search terms are where bugs hide — a filter applied in the interface but not sent — and a handler that captures the request’s search parameters verifies them directly, as covered in asserting request payloads without brittle snapshots.
A fourth check worth adding for queries that paginate or poll is a count of requests. A handler that increments a counter lets a test assert that scrolling to the next page makes exactly one request, or that polling stops when the component unmounts. Over-fetching is invisible in the rendered output and expensive in production, and a request count is the only thing that reveals it.
Edge Cases & Failure Modes
Cache leakage through a module-level client. An application that creates its QueryClient at module scope and imports it into a provider shares that cache across every test in a file. Diagnose by order-dependent failures showing data from a previous test; fix by constructing the client inside the provider and passing a fresh one in tests.
Mutations whose errors are swallowed. A mutation that fails without an onError handler leaves the interface in its optimistic or pending state with no message. Diagnose with a failing mutation handler and an assertion that an error appears; fix by handling errors explicitly, and keep that failing-mutation test permanently.
Background refetches after the test ends. Stale data triggers a refetch on mount; if a test finishes before it resolves, the response arrives into an unmounted tree and produces warnings, or into the next test. Await the settled state in every test, and let cleanup unmount before the next begins.
Structural sharing hiding updates. TanStack Query preserves object identity when refetched data is deeply equal, so a component memoised on identity will not re-render. That is correct behaviour; tests that expect a re-render after an identical refetch are testing the wrong thing.
Suspense mode changing the shape of tests. With useSuspenseQuery, the loading state is a Suspense fallback rather than a flag, and errors propagate to an error boundary. Render the component inside the boundaries production uses, or the test sees an uncaught promise instead of the fallback.
Query keys that do not include every input. A key of ['orders'] for a query that also depends on a filter means changing the filter serves cached results for the old one. Diagnose by a test that changes a filter and sees stale rows; fix by including every input in the key, and assert on the refetched request’s parameters.
Performance & CI Impact
With retries off and the network intercepted, data-fetching component tests are as fast as any other component test — typically a few milliseconds each beyond render time. The single biggest slowdown in suites that have not adopted these settings is retry backoff on error tests, which can add three to seven seconds per failing query; turning retries off in the test client removes it entirely.
Creating a new client per test costs microseconds and is never a meaningful expense. What does cost time is gcTime timers when left at their default: each cached query schedules a removal timer, and with thousands of tests those timers keep workers alive after tests complete. Setting gcTime to Infinity in tests avoids scheduling them at all, since the whole client is discarded.
Flakiness in this area almost always comes from asserting too early — before data arrives, before an invalidation refetch resolves, before a mutation settles. findBy queries and waitFor absorb that without real waits, and MSW’s explicit delay makes timing-dependent states such as loading reproducible rather than dependent on how fast the machine happens to be.
Finally, keep the render helper and handler factories in the shared test utilities rather than per feature. Every data-driven component in the application needs the same client configuration, and a helper duplicated across features will eventually diverge — one copy with retries on, another with a shared client — producing a suite whose speed and reliability vary by directory for no reason anyone remembers.
In-Depth Guides
- Testing React Query hooks with a fresh cache — the per-test client, renderHook and query-key discipline.
- Testing SWR revalidation behaviour — provider isolation, dedupe, revalidate on mount and interval polling.
- Testing loading and error states deterministically — every visible state from one handler each.
- Testing optimistic updates and rollback — the instant update, the server’s verdict, and the undo.
Related
- Back to Component & Integration Testing Frameworks
- External Service Simulation — the MSW foundations these tests rely on.
- Simulating network errors and timeouts with MSW — failure shapes for the error state.
- React State & Hydration Testing — server-side data and Suspense boundaries.
Testing React Query Hooks With a Fresh Cache
Test TanStack Query hooks in isolation: a new QueryClient per test, renderHook wrappers, key factories, dependent queries and cache assertions.
Testing SWR Revalidation Behaviour
Test SWR deterministically: an isolated cache per test, dedupe off, revalidation on mount and focus, polling with fake timers, and mutate.
Testing Loading and Error States Deterministically
Make every non-happy state reachable on demand: hold responses open for loading, script each failure, and test skeletons, retries and busy states.
Testing Optimistic Updates and Rollback
Test optimistic UI end to end: the instant update, server confirmation, rollback with a clear message on failure, and concurrent mutations.