TechLead

Testing React Components

Use React Testing Library to test component behavior, user interactions, hooks, forms, and async patterns accessibly

The React Testing Library Philosophy

React Testing Library encourages testing components the way users interact with them. Instead of testing internal state or implementation details, you query the DOM by accessible roles, labels, and text -- the same things a real user sees and interacts with.

Guiding Principle:

"The more your tests resemble the way your software is used, the more confidence they can give you." - Kent C. Dodds

Querying the DOM

import { render, screen } from '@testing-library/react';
import { ProductCard } from './ProductCard';

test('renders product information', () => {
  render(
    <ProductCard
      name="Wireless Headphones"
      price={79.99}
      rating={4.5}
      inStock={true}
    />
  );

  // Preferred: query by role (accessible to screen readers)
  expect(screen.getByRole('heading', { name: 'Wireless Headphones' }))
    .toBeInTheDocument();

  // Query by text content
  expect(screen.getByText('$79.99')).toBeInTheDocument();
  expect(screen.getByText(/4\.5/)).toBeInTheDocument();

  // Query by label (for form elements)
  // screen.getByLabelText('Quantity');

  // Query by test ID (last resort)
  // screen.getByTestId('product-card');

  // Check element is NOT present
  expect(screen.queryByText('Out of Stock')).not.toBeInTheDocument();

  // Accessible role check
  expect(screen.getByRole('button', { name: 'Add to Cart' }))
    .toBeEnabled();
});

// Priority of queries (best to worst):
// 1. getByRole - accessible queries
// 2. getByLabelText - form fields
// 3. getByPlaceholderText - inputs
// 4. getByText - visible text
// 5. getByDisplayValue - current input value
// 6. getByAltText - images
// 7. getByTitle - title attribute
// 8. getByTestId - last resort

Testing User Events

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchFilter } from './SearchFilter';

test('filters items as user types', async () => {
  const user = userEvent.setup();
  const onFilter = jest.fn();

  render(<SearchFilter items={['Apple', 'Banana', 'Cherry']} onFilter={onFilter} />);

  const input = screen.getByRole('searchbox', { name: 'Search items' });

  // Type in search box
  await user.type(input, 'App');
  expect(onFilter).toHaveBeenLastCalledWith('App');

  // Clear and type again
  await user.clear(input);
  await user.type(input, 'Ban');
  expect(onFilter).toHaveBeenLastCalledWith('Ban');

  // Click a button
  await user.click(screen.getByRole('button', { name: 'Clear' }));
  expect(input).toHaveValue('');
});

test('handles keyboard navigation', async () => {
  const user = userEvent.setup();
  render(<DropdownMenu items={['Edit', 'Delete', 'Archive']} />);

  // Open dropdown
  await user.click(screen.getByRole('button', { name: 'Options' }));

  // Navigate with keyboard
  await user.keyboard('{ArrowDown}');
  await user.keyboard('{ArrowDown}');
  await user.keyboard('{Enter}');

  expect(screen.getByText('Item deleted')).toBeInTheDocument();
});

Testing Forms

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RegistrationForm } from './RegistrationForm';

test('submits form with valid data', async () => {
  const user = userEvent.setup();
  const onSubmit = jest.fn();

  render(<RegistrationForm onSubmit={onSubmit} />);

  await user.type(screen.getByLabelText('Name'), 'Alice Johnson');
  await user.type(screen.getByLabelText('Email'), 'alice@example.com');
  await user.type(screen.getByLabelText('Password'), 'Str0ngP@ss!');

  // Select from dropdown
  await user.selectOptions(screen.getByLabelText('Role'), 'developer');

  // Check a checkbox
  await user.click(screen.getByLabelText('Accept Terms'));

  // Submit
  await user.click(screen.getByRole('button', { name: 'Register' }));

  await waitFor(() => {
    expect(onSubmit).toHaveBeenCalledWith({
      name: 'Alice Johnson',
      email: 'alice@example.com',
      password: 'Str0ngP@ss!',
      role: 'developer',
      acceptTerms: true,
    });
  });
});

test('shows validation errors', async () => {
  const user = userEvent.setup();
  render(<RegistrationForm onSubmit={jest.fn()} />);

  // Submit without filling fields
  await user.click(screen.getByRole('button', { name: 'Register' }));

  expect(await screen.findByText('Name is required')).toBeInTheDocument();
  expect(await screen.findByText('Email is required')).toBeInTheDocument();
});

Testing Async Behavior & Custom Hooks

// Testing async data fetching
import { render, screen } from '@testing-library/react';
import { UserProfile } from './UserProfile';

test('loads and displays user data', async () => {
  // Assuming MSW is configured for /api/users/1
  render(<UserProfile userId={1} />);

  // Loading state
  expect(screen.getByText('Loading...')).toBeInTheDocument();

  // Wait for data
  expect(await screen.findByText('Alice Johnson')).toBeInTheDocument();
  expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});

test('displays error state', async () => {
  // Override MSW to return error for this test
  server.use(
    http.get('/api/users/:id', () => {
      return HttpResponse.json(null, { status: 500 });
    })
  );

  render(<UserProfile userId={999} />);
  expect(await screen.findByText(/error/i)).toBeInTheDocument();
});

// Testing custom hooks with renderHook
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

test('useCounter hook', () => {
  const { result } = renderHook(() => useCounter(10));

  expect(result.current.count).toBe(10);

  act(() => result.current.increment());
  expect(result.current.count).toBe(11);

  act(() => result.current.decrement());
  expect(result.current.count).toBe(10);

  act(() => result.current.reset());
  expect(result.current.count).toBe(10);
});

Key Takeaways

  • Query by role and label first -- it tests accessibility for free
  • Use userEvent over fireEvent for realistic interactions
  • Use findBy for async elements, queryBy to assert absence
  • Test behavior from the user perspective, not component internals
  • Combine with MSW for realistic data-fetching tests

Continue Learning