react-testing

Testing React SPAs: React Testing Library + Vitest/Jest for components and hooks, msw for network mocks, Playwright/Cypress for e2e. Query priority, user events, async assertions, mocking patterns. U

SKILL.md

GitHub

React Testing Patterns

Test framework selection

Layer Framework
Component, hook, plain TS unit Vitest (preferred for new) or Jest
End-to-end Playwright or Cypress

Match what's installed. Vitest is the modern default for Vite projects; Jest is common in CRA / older setups.

Vitest setup

vitest.config.ts:

import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./vitest.setup.ts'],
    css: true,
    coverage: {
      reporter: ['text', 'html'],
      exclude: ['**/*.config.*', '**/*.test.*', 'src/main.tsx'],
    },
  },
  resolve: {
    alias: { '@': path.resolve(__dirname, './src') },
  },
});

vitest.setup.ts:

import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';

afterEach(() => cleanup());

Install: pnpm add -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom.

Jest setup

jest.config.ts:

import type { Config } from 'jest';

const config: Config = {
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
  setupFilesAfterEach: ['<rootDir>/jest.setup.ts'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '\\.(css|scss|less)$': 'identity-obj-proxy',
  },
};
export default config;

jest.setup.ts:

import '@testing-library/jest-dom';

Component test (RTL basics)

// src/features/users/UserCard.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserCard } from './UserCard';

describe('UserCard', () => {
  it('renders user name and email', () => {
    render(<UserCard user={{ id: '1', name: 'Alice', email: 'a@b.c' }} />);
    expect(screen.getByRole('heading', { name: 'Alice' })).toBeInTheDocument();
    expect(screen.getByText('a@b.c')).toBeInTheDocument();
  });

  it('calls onDelete when delete button clicked', async () => {
    const onDelete = vi.fn();
    const user = userEvent.setup();
    render(<UserCard user={{ id: '1', name: 'Alice', email: 'a@b.c' }} onDelete={onDelete} />);
    await user.click(screen.getByRole('button', { name: /delete/i }));
    expect(onDelete).toHaveBeenCalledWith('1');
  });
});

Query priority (USE THIS ORDER)

  1. getByRole(role, { name }) — accessible name. Mirrors how screen readers see the page.
  2. getByLabelText — for form inputs.
  3. getByPlaceholderText — fallback for inputs without label.
  4. getByText — for non-interactive text.
  5. getByDisplayValue — for inputs with current value.
  6. getByAltText — for images.
  7. getByTitle — for elements with title attribute.
  8. getByTestId — last resort, when nothing else works.

getBy* throws if not found. queryBy* returns null. findBy* is async (returns promise that retries).

expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); // assert absence
expect(await screen.findByText('Loaded')).toBeInTheDocument();    // wait for async

User events (ALWAYS prefer over fireEvent)

import userEvent from '@testing-library/user-event';

const user = userEvent.setup();
await user.click(button);
await user.type(input, 'hello');
await user.selectOptions(select, 'option-value');
await user.upload(input, file);
await user.tab();                              // keyboard navigation
await user.keyboard('{Enter}');

fireEvent is lower-level and skips realistic event sequences (mousedown→mouseup→click). Stick to user-event.

Testing hooks

// src/hooks/useDebounce.test.ts
import { describe, it, expect, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from './useDebounce';

describe('useDebounce', () => {
  it('returns the latest value after delay', async () => {
    vi.useFakeTimers();
    const { result, rerender } = renderHook(({ value }) => useDebounce(value, 300), {
      initialProps: { value: 'a' },
    });
    expect(result.current).toBe('a');

    rerender({ value: 'b' });
    expect(result.current).toBe('a'); // not yet debounced

    act(() => {
      vi.advanceTimersByTime(300);
    });
    expect(result.current).toBe('b');

    vi.useRealTimers();
  });
});

Network mocking with msw

// vitest.setup.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { afterAll, afterEach, beforeAll } from 'vitest';

const server = setupServer(
  http.get('/api/users', () => HttpResponse.json([{ id: '1', name: 'Alice' }])),
  http.post('/api/users', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({ id: '2', ...body }, { status: 201 });
  }),
);

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

export { server };

Per-test override:

import { http, HttpResponse } from 'msw';
import { server } from '../vitest.setup';

it('shows error on 500', async () => {
  server.use(http.get('/api/users', () => HttpResponse.json({ error: 'oops' }, { status: 500 })));
  render(<UsersPage />);
  expect(await screen.findByText(/error/i)).toBeInTheDocument();
});

Testing components that use TanStack Query

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render } from '@testing-library/react';

function renderWithQuery(ui: React.ReactElement) {
  const qc = new QueryClient({
    defaultOptions: { queries: { retry: false } },          // don't retry in tests
  });
  return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}

it('shows users from API', async () => {
  renderWithQuery(<UsersList />);
  expect(await screen.findByText('Alice')).toBeInTheDocument();
});

Testing routing (React Router)

import { MemoryRouter, Routes, Route } from 'react-router-dom';

it('navigates to user detail', async () => {
  const user = userEvent.setup();
  render(
    <MemoryRouter initialEntries={['/users']}>
      <Routes>
        <Route path="/users" element={<UsersList />} />
        <Route path="/users/:id" element={<UserDetail />} />
      </Routes>
    </MemoryRouter>
  );

  await user.click(screen.getByRole('link', { name: 'Alice' }));
  expect(await screen.findByText('User Detail: 1')).toBeInTheDocument();
});

For data routers (with loader etc.), use createMemoryRouter and RouterProvider.

Testing forms (react-hook-form)

it('shows validation errors and submits valid data', async () => {
  const onSubmit = vi.fn();
  const user = userEvent.setup();
  render(<LoginForm onSubmit={onSubmit} />);

  // Submit empty
  await user.click(screen.getByRole('button', { name: /log in/i }));
  expect(await screen.findByText(/invalid email/i)).toBeInTheDocument();
  expect(onSubmit).not.toHaveBeenCalled();

  // Fill correctly
  await user.type(screen.getByLabelText(/email/i), 'a@b.c');
  await user.type(screen.getByLabelText(/password/i), 'longenough');
  await user.click(screen.getByRole('button', { name: /log in/i }));

  await waitFor(() => {
    expect(onSubmit).toHaveBeenCalledWith({ email: 'a@b.c', password: 'longenough' });
  });
});

Playwright e2e

playwright.config.ts:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
  },
  use: { baseURL: 'http://localhost:5173', trace: 'on-first-retry' },
  projects: [
    { name: 'chromium', use: { browserName: 'chromium' } },
  ],
});
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';

test('user can log in', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel(/email/i).fill('a@b.c');
  await page.getByLabel(/password/i).fill('longenough');
  await page.getByRole('button', { name: /log in/i }).click();
  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByRole('heading', { name: /welcome/i })).toBeVisible();
});

Cypress e2e (alternative)

// cypress/e2e/login.cy.ts
describe('login', () => {
  it('user can log in', () => {
    cy.visit('/login');
    cy.get('[name=email]').type('a@b.c');
    cy.get('[name=password]').type('longenough');
    cy.contains('button', 'Log in').click();
    cy.url().should('include', '/dashboard');
  });
});

Cypress feels more "live" (interactive runner) but each test is browser-resident, slower at scale than Playwright.

Coverage discipline

Target ≥80% on:

  • Custom hooks.
  • Components with logic (state, conditional rendering).
  • Utility functions.

Skip / lower bar:

  • Pure presentational components (snapshot churn outweighs value).
  • Wiring code (route definitions, store setup).
  • main.tsx.

Configure exclusions:

// vitest.config.ts
test: {
  coverage: {
    exclude: [
      'src/main.tsx',
      'src/App.tsx',
      '**/*.config.*',
      '**/types.ts',
      '**/*.stories.tsx',
    ],
  },
}

Iteration cap (from QA agent)

The qa-engineer agent has a hard 3-attempt cap on fixing failing tests. Mark genuinely flaky tests it.skip(...) with a comment after attempt #3, and report in the QA summary.

Anti-patterns

  • ❌ getByTestId everywhere instead of accessible queries.
  • ❌ fireEvent for interactions when userEvent would be more realistic.
  • ❌ Stubbing fetch directly when msw exists in the project.
  • ❌ Snapshot tests of large component trees — review noise.
  • ❌ Testing implementation details (CSS classes, internal state).
  • ❌ Forgetting act() warnings — they signal real issues with async state updates.
  • ❌ E2E tests that depend on real backend without webServer config or seed data.
  • ❌ setTimeout waits in tests — use findBy* (which retries) or waitFor.
  • ❌ Mocking the SUT instead of its dependencies.