Chapter 2: React Testing Library — Coverage, Use Cases & Practical Stories

The Testing Philosophy

React Testing Library (RTL) was created by Kent C. Dodds with a guiding principle:

“The more your tests resemble the way your software is used, the more confidence they can give you.”

This contrasts with enzyme’s component-internals-first approach. RTL tests interaction through the DOM — what the user sees and does — not component methods, state, or implementation details.

The Testing Trophy

Kent C. Dodds advocates for the Testing Trophy over the traditional Testing Pyramid:

        ╔══════╗
        ║  e2e  ║       ← Few, expensive (Playwright, Cypress)
        ╚══════╝
      ╔══════════╗
      ║Integration║      ← Most valuable — test components + hooks together
      ╚══════════╝
    ╔══════════════╗
    ║    Unit       ║    ← Pure functions, utilities, hooks in isolation
    ╚══════════════╝
  ╔══════════════════╗
  ║   Static (types)  ║  ← TypeScript, ESLint — huge ROI
  ╚══════════════════╝

Setup and Configuration

Installation

npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom vitest jsdom

vitest.config.ts

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

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    globals: true,
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      exclude: ['node_modules/', 'src/test/', '**/*.d.ts', '**/*.config.*'],
    },
  },
});

src/test/setup.ts

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

afterEach(() => cleanup());

Custom render() with Providers

// src/test/test-utils.tsx
import { ReactNode } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter, MemoryRouterProps } from 'react-router-dom';

interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
  initialEntries?: MemoryRouterProps['initialEntries'];
  queryClient?: QueryClient;
}

function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
  });
}

export function renderWithProviders(
  ui: React.ReactElement,
  {
    initialEntries = ['/'],
    queryClient = createTestQueryClient(),
    ...renderOptions
  }: CustomRenderOptions = {}
) {
  function Wrapper({ children }: { children: ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>
        <MemoryRouter initialEntries={initialEntries}>
          {children}
        </MemoryRouter>
      </QueryClientProvider>
    );
  }
  return { queryClient, ...render(ui, { wrapper: Wrapper, ...renderOptions }) };
}

export * from '@testing-library/react';
export { renderWithProviders as render };

Core API

Query Priority Order

// 1. Preferred — by role (what screen readers see)
screen.getByRole('button', { name: /submit/i });
screen.getByRole('heading', { name: /welcome/i, level: 1 });
screen.getByRole('textbox', { name: /email/i });

// 2. By label text — for form fields
screen.getByLabelText(/password/i);

// 3. By text — for non-interactive elements
screen.getByText(/invalid email/i);

// 4. Last resort — test id
screen.getByTestId('product-list');

Query Variants

// getBy* — throws if not found
screen.getByRole('button', { name: /save/i });

// queryBy* — returns null if not found (assert absence)
expect(screen.queryByText(/Error/)).not.toBeInTheDocument();

// findBy* — async version
const data = await screen.findByText(/John Doe/);

// getAllBy* — multiple elements
const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(5);

userEvent vs fireEvent

Always prefer userEvent.

import userEvent from '@testing-library/user-event';
const user = userEvent.setup();

// ❌ fireEvent — synthetic, misses focus/pointer behavior
fireEvent.click(button);
fireEvent.change(input, { target: { value: 'hello' } });

// ✅ userEvent — realistic simulation
await user.click(button);
await user.type(input, 'hello');
await user.clear(input);
await user.selectOptions(select, 'value');
await user.tab();
await user.keyboard('{Enter}');

Average Test Coverage Targets

Coverage Level Context
0–40% Prototype / internal tools
40–60% Early-stage startup, basic flow coverage
60–75% Standard for most production apps
75–85% Sweet spot — good ROI, no over-testing
85–95% Fintech, health, e-commerce checkout
95–100% Usually diminishing returns / trivial code

What to Always Test

What NOT to Test


Practical Stories

Story 1 — Testing a Login Form

// LoginForm.tsx
export function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  const navigate = useNavigate();
  const login = useAuthStore(state => state.login);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError(null);
    setIsLoading(true);
    try {
      await login({ email, password });
      navigate('/dashboard');
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Login failed');
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} aria-label="Sign in form">
      <label htmlFor="email">Email</label>
      <input id="email" type="email" value={email} onChange={e => setEmail(e.target.value)} required />
      <label htmlFor="password">Password</label>
      <input id="password" type="password" value={password} onChange={e => setPassword(e.target.value)} required />
      {error && <p id="form-error" role="alert">{error}</p>}
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Signing in...' : 'Sign in'}
      </button>
    </form>
  );
}
// LoginForm.test.tsx
vi.mock('@/stores/auth');
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => ({
  ...(await vi.importActual('react-router-dom')),
  useNavigate: () => mockNavigate,
}));

describe('LoginForm', () => {
  const user = userEvent.setup();
  const mockLogin = vi.fn();

  beforeEach(() => {
    vi.mocked(useAuthStore).mockReturnValue(mockLogin as any);
    mockNavigate.mockClear();
    mockLogin.mockClear();
  });

  it('renders form fields', () => {
    render(<LoginForm />);
    expect(screen.getByRole('textbox', { name: /email/i })).toBeInTheDocument();
    expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
  });

  it('submits and redirects on success', async () => {
    mockLogin.mockResolvedValueOnce(undefined);
    render(<LoginForm />);

    await user.type(screen.getByLabelText(/email/i), 'user@example.com');
    await user.type(screen.getByLabelText(/password/i), 'pass123');
    await user.click(screen.getByRole('button', { name: /sign in/i }));

    await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard'));
  });

  it('shows error on failure', async () => {
    mockLogin.mockRejectedValueOnce(new Error('Invalid credentials'));
    render(<LoginForm />);

    await user.type(screen.getByLabelText(/email/i), 'x@y.com');
    await user.type(screen.getByLabelText(/password/i), 'wrong');
    await user.click(screen.getByRole('button', { name: /sign in/i }));

    expect(await screen.findByRole('alert')).toHaveTextContent('Invalid credentials');
    expect(mockNavigate).not.toHaveBeenCalled();
  });

  it('disables button while loading', async () => {
    mockLogin.mockImplementation(() => new Promise(() => {}));
    render(<LoginForm />);

    await user.type(screen.getByLabelText(/email/i), 'u@e.com');
    await user.type(screen.getByLabelText(/password/i), 'pass');
    await user.click(screen.getByRole('button', { name: /sign in/i }));

    expect(screen.getByRole('button', { name: /signing in/i })).toBeDisabled();
  });
});

Story 2 — Async Data Fetching with React Query

// ProductList.test.tsx
vi.mock('@/api/products');

describe('ProductList', () => {
  it('shows loading state', () => {
    vi.mocked(fetchProducts).mockImplementation(() => new Promise(() => {}));
    render(<ProductList category="electronics" />);
    expect(screen.getByRole('status')).toHaveTextContent(/loading/i);
  });

  it('renders products after load', async () => {
    vi.mocked(fetchProducts).mockResolvedValueOnce([
      { id: '1', name: 'Keyboard', price: 149.99 },
      { id: '2', name: 'Mouse', price: 49.99 },
    ]);
    render(<ProductList category="electronics" />);

    await screen.findByRole('list');
    expect(screen.getAllByRole('listitem')).toHaveLength(2);
    expect(screen.getByText('Keyboard')).toBeInTheDocument();
  });

  it('shows error state on failure', async () => {
    vi.mocked(fetchProducts).mockRejectedValueOnce(new Error('Network'));
    render(<ProductList category="electronics" />);
    expect(await screen.findByRole('alert')).toHaveTextContent(/failed/i);
  });
});

Story 3 — Testing Custom Hooks

// useCounter.test.ts
describe('useCounter', () => {
  it('starts at initial value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  it('increments', () => {
    const { result } = renderHook(() => useCounter(0));
    act(() => result.current.increment());
    expect(result.current.count).toBe(1);
  });

  it('respects max boundary', () => {
    const { result } = renderHook(() => useCounter(3, { max: 3 }));
    act(() => result.current.increment());
    expect(result.current.count).toBe(3);
  });

  it('resets to initial', () => {
    const { result } = renderHook(() => useCounter(5));
    act(() => result.current.increment());
    act(() => result.current.reset());
    expect(result.current.count).toBe(5);
  });
});

Mock Service Worker (MSW)

MSW intercepts requests at the network level — the most realistic approach:

// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/products', () =>
    HttpResponse.json([
      { id: '1', name: 'Keyboard', price: 149.99 },
    ])
  ),

  http.post('/api/auth/login', async ({ request }) => {
    const { email, password } = await request.json();
    if (email === 'user@example.com' && password === 'password123') {
      return HttpResponse.json({ token: 'fake-jwt' });
    }
    return HttpResponse.json({ error: 'Invalid credentials' }, { status: 401 });
  }),
];

// src/mocks/server.ts
import { setupServer } from 'msw/node';
export const server = setupServer(...handlers);

// src/test/setup.ts
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Common Mistakes

Mistake Better Approach
getByTestId for everything getByRole with name
fireEvent.click() await user.click()
Asserting on state values Assert on what the user sees
Testing implementation details Test behavior from user’s perspective
Brittle snapshot tests Specific expect(el).toHaveTextContent(...)
Not mocking network calls Use MSW for network-level mocking