Testing TanStack Query Components: From Unit to E2E Tests
TanStack Query (formerly React Query) has become the de facto standard for managing server state in React applications. It handles caching, background refetching, stale data, and optimistic updates out of the box. However, its asynchronous nature and global cache introduce unique testing challenges. This tutorial walks you through a complete testing strategy β from isolated unit tests to full end-to-end (E2E) tests β so you can ship TanStack Query-powered features with confidence.
Why Testing TanStack Query Is Different
Unlike plain synchronous state, TanStack Query components depend on a QueryClient instance, a cache, and asynchronous resolvers. If you render a component without a provider, hooks like useQuery will throw. If you reuse a cache across tests, stale data from one test can leak into another. Understanding these constraints is the foundation of a reliable test suite.
- Provider requirement: Every component using
useQueryoruseMutationneeds aQueryClientProviderancestor. - Cache isolation: Tests must not share cache state, or you get flaky, order-dependent failures.
- Async timing: Queries resolve asynchronously, so assertions must wait for re-renders.
- Network mocking: You need deterministic control over what the fetcher returns, including errors and loading states.
Project Setup
We will use Vitest and React Testing Library for unit and integration tests, and Playwright for E2E tests. Install the dependencies below.
npm install @tanstack/react-query
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
npm install -D @playwright/test
Create a vitest.config.ts file to configure the test environment.
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./test/setup.ts'],
},
});
In test/setup.ts, import the custom matchers from @testing-library/jest-dom.
import '@testing-library/jest-dom/vitest';
A Sample Component Under Test
Consider a simple component that fetches and displays a list of users. We will test this component throughout the tutorial.
// src/api/users.ts
export interface User {
id: number;
name: string;
email: string;
}
export async function fetchUsers(): Promise<User[]> {
const res = await fetch('/api/users');
if (!res.ok) throw new Error('Failed to load users');
return res.json();
}
// src/components/UserList.tsx
import { useQuery } from '@tanstack/react-query';
import { fetchUsers } from '../api/users';
export function UserList() {
const { data, isLoading, isError, error } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
if (isLoading) return <p role="status">Loading usersβ¦</p>;
if (isError) return <p role="alert">Error: {error.message}</p>;
return (
<ul aria-label="users">
{data!.map((u) => (
<li key={u.id}>{u.name} β {u.email}</li>
))}
</ul>
);
}
Creating a Test Wrapper
The cleanest way to provide a fresh QueryClient for every test is to build a reusable render wrapper. The key is to disable retries and garbage collection timeouts so tests run fast and deterministically.
// test/test-utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactElement, ReactNode } from 'react';
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
staleTime: 0,
},
mutations: {
retry: false,
},
},
});
}
export function renderWithQueryClient(
ui: ReactElement,
options?: RenderOptions
) {
const queryClient = createTestQueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
return { ...render(ui, { wrapper, ...options }), queryClient };
}
By creating a new QueryClient per render, we guarantee cache isolation between tests. Setting retry: false prevents the query from silently retrying on failure, which would otherwise delay error assertions.
Unit Testing the Loading State
The first test verifies that the component shows a loading indicator while the query is in flight. Because fetchUsers is asynchronous, we use findBy queries which wait for elements to appear.
// src/components/UserList.test.tsx
import { describe, it, expect, vi, afterEach } from 'vitest';
import { screen } from '@testing-library/react';
import { renderWithQueryClient } from '../../test/test-utils';
import { UserList } from './UserList';
import { fetchUsers } from '../api/users';
vi.mock('../api/users');
describe('UserList', () => {
afterEach(() => vi.clearAllMocks());
it('shows a loading state initially', async () => {
vi.mocked(fetchUsers).mockImplementation(
() => new Promise(() => {}) // never resolves
);
renderWithQueryClient(<UserList />);
expect(screen.getByRole('status')).toHaveTextContent('Loading usersβ¦');
});
});
Here we mock the fetchUsers module and return a promise that never resolves. This keeps the component in the loading state indefinitely, allowing us to assert on the loading text without racing against a re-render.
Unit Testing the Success State
For the success case, we resolve the mocked fetcher with sample data and wait for the list to render.
it('renders users after a successful fetch', async () => {
vi.mocked(fetchUsers).mockResolvedValue([
{ id: 1, name: 'Ada Lovelace', email: 'ada@example.com' },
{ id: 2, name: 'Alan Turing', email: 'alan@example.com' },
]);
renderWithQueryClient(<UserList />);
const list = await screen.findByRole('list', { name: 'users' });
expect(list).toBeInTheDocument();
expect(screen.getByText('Ada Lovelace β ada@example.com')).toBeInTheDocument();
expect(screen.getByText('Alan Turing β alan@example.com')).toBeInTheDocument();
});
Using findByRole and findByText ensures the test waits for the asynchronous query to resolve and the component to re-render before asserting.
Unit Testing the Error State
Error states are just as important as success states. Mock the fetcher to reject and assert that the error message appears.
it('shows an error message when the fetch fails', async () => {
vi.mocked(fetchUsers).mockRejectedValue(new Error('Failed to load users'));
renderWithQueryClient(<UserList />);
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('Error: Failed to load users');
});
Because we disabled retries in the test QueryClient, the rejection propagates immediately on the first attempt, making the test fast and deterministic.
Testing Mutations
Mutations require similar care. Consider a component that adds a user via a mutation and invalidates the users query.
// src/components/AddUserForm.tsx
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
export async function createUser(input: { name: string }) {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error('Could not create user');
return res.json();
}
export function AddUserForm() {
const [name, setName] = useState('');
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createUser,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
});
return (
<form onSubmit={(e) => {
e.preventDefault();
mutation.mutate({ name });
}}>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button type="submit" disabled={mutation.isPending}>Add</button>
{mutation.isError && <p role="alert">Failed to add user</p>}
{mutation.isSuccess && <p role="status">User added</p>}
</form>
);
}
The test verifies that submitting the form triggers the mutation and shows the success message.
// src/components/AddUserForm.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import { renderWithQueryClient } from '../../test/test-utils';
import { AddUserForm, createUser } from './AddUserForm';
vi.mock('./AddUserForm', async (importOriginal) => {
const actual = await importOriginal<typeof import('./AddUserForm')>();
return { ...actual, createUser: vi.fn() };
});
describe('AddUserForm', () => {
it('submits and shows success', async () => {
vi.mocked(createUser).mockResolvedValue({ id: 3, name: 'Grace' });
renderWithQueryClient(<AddUserForm />);
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Grace' } });
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
expect(await screen.findByRole('status')).toHaveTextContent('User added');
expect(createUser).toHaveBeenCalledWith({ name: 'Grace' });
});
});
Integration Testing with MSW
Mocking at the module level is fast, but it bypasses your real fetch logic. For higher-fidelity integration tests, use Mock Service Worker (MSW) to intercept network requests. This lets you test the actual fetchUsers implementation.
npm install -D msw
Set up an MSW server in test/msw.ts.
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () =>
HttpResponse.json([
{ id: 1, name: 'Ada Lovelace', email: 'ada@example.com' },
])
),
];
export const server = setupServer(...handlers);
Wire the server into your Vitest setup file so handlers reset between tests.
// test/setup.ts
import '@testing-library/jest-dom/vitest';
import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from './msw';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Now write an integration test that exercises the real fetcher.
it('renders users fetched over the network', async () => {
renderWithQueryClient(<UserList />);
expect(await screen.findByText('Ada Lovelace β ada@example.com')).toBeInTheDocument();
});
To test the error path with MSW, override the handler for a single test.
import { server } from '../../test/msw';
import { http, HttpResponse } from 'msw';
it('shows an error when the API returns 500', async () => {
server.use(
http.get('/api/users', () =>
new HttpResponse(null, { status: 500 })
)
);
renderWithQueryClient(<UserList />);
expect(await screen.findByRole('alert')).toHaveTextContent('Error: Failed to load users');
});
Testing Query Invalidation and Cache Behavior
Sometimes you need to assert that a mutation invalidates the correct query and triggers a refetch. Combine the QueryClient returned from your test utility with MSW handlers that count requests.
it('refetches users after a successful mutation', async () => {
let fetchCount = 0;
server.use(
http.get('/api/users', () => {
fetchCount++;
return HttpResponse.json([{ id: 1, name: 'Ada', email: 'ada@example.com' }]);
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: 2, ...body });
})
);
const { queryClient } = renderWithQueryClient(
<>
<UserList />
<AddUserForm />
</>
);
await screen.findByText('Ada β ada@example.com');
expect(fetchCount).toBe(1);
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Grace' } });
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
await screen.findByText('User added');
await screen.findByText('Ada β ada@example.com');
expect(fetchCount).toBeGreaterThanOrEqual(2);
// Ensure no unexpected cache entries linger
queryClient.clear();
});
End-to-End Testing with Playwright
E2E tests run against a real browser and a real (or locally hosted) backend. They validate the full stack, including routing, network, and TanStack Query's runtime behavior. Start by configuring Playwright.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
use: {
baseURL: 'http://localhost:5173',
headless: true,
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
});
For E2E tests you typically want deterministic data. You can either run a seeded local backend or intercept requests with Playwright's page.route API. The example below intercepts the users endpoint.
// e2e/users.spec.ts
import { test, expect } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.route('**/api/users', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Ada Lovelace', email: 'ada@example.com' },
{ id: 2, name: 'Alan Turing', email: 'alan@example.com' },
]),
});
});
});
test('displays the list of users', async ({ page }) => {
await page.goto('/');
const list = page.getByRole('list', { name: 'users' });
await expect(list).toBeVisible();
await expect(page.getByText('Ada Lovelace β ada@example.com')).toBeVisible();
await expect(page.getByText('Alan Turing β alan@example.com')).toBeVisible();
});
Testing the loading state in E2E requires slowing down the response so the loading indicator is visible long enough to assert on it.
test('shows a loading indicator while fetching', async ({ page }) => {
await page.route('**/api/users', async (route) => {
await new Promise((r) => setTimeout(r, 500));
await route.fulfill({
status: 200,
body: JSON.stringify([]),
});
});
await page.goto('/');
await expect(page.getByRole('status')).toHaveText('Loading usersβ¦');
await expect(page.getByRole('list', { name: 'users' })).toBeVisible();
});
You can also test the error path by fulfilling with a 500 status.
test('shows an error message on server failure', async ({ page }) => {
await page.route('**/api/users', (route) =>
route.fulfill({ status: 500 })
);
await page.goto('/');
await expect(page.getByRole('alert')).toContainText('Error:');
});
Best Practices
- Always create a fresh
QueryClientper test. Sharing a client causes cache leakage and flaky failures. - Disable retries and set
gcTimeto 0 in tests. This keeps assertions deterministic and fast. - Prefer
findBy*queries for async content. They wait for the element to appear, avoiding brittlewaitForwrappers. - Mock at the right level. Use module mocks for pure unit tests and MSW for integration tests that should exercise real fetch logic.
- Use semantic roles in selectors. Both React Testing Library and Playwright work best with role-based queries, which mirror how users interact with the page.
- Test all three states. Loading, success, and error paths each deserve explicit coverage.
- Assert on side effects, not implementation details. Verify that invalidation triggers a refetch by observing data changes, not by inspecting internal cache keys.
- Keep E2E tests small and focused. They are slower than unit tests, so reserve them for critical user flows rather than every branch.
- Call
queryClient.clear()in teardown when a test manipulates the cache directly, to ensure no state escapes.
Conclusion
Testing TanStack Query components requires a deliberate strategy across three layers. Unit tests with module mocks give you fast, isolated feedback on individual component states. Integration tests with MSW validate your real data-fetching logic and cache invalidation behavior. E2E tests with Playwright confirm that the entire stack works together in a real browser. By using a fresh QueryClient per test, disabling retries, choosing the right level of mocking, and covering loading, success, and error states at each layer, you build a resilient test suite that catches regressions early without slowing down your development workflow.