← Back to DevBytes

Testing TanStack Query Components: From Unit to E2E Tests

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.

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

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.

β€” Ad β€”

Google AdSense will appear here after approval

← Back to all articles