Skip to main content

Testing React Components

React Testing Library encourages testing components the way users interact with them — by finding elements through accessible roles, text, and labels rather than implementation details like class names or component state.

Setup

Install the required packages:

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

Add the custom matchers to your Jest setup file:

// jest.setup.ts
import '@testing-library/jest-dom';
// jest.config.js
module.exports = {
  setupFilesAfterSetup: ['./jest.setup.ts'],
  testEnvironment: 'jsdom',
};

The @testing-library/jest-dom package adds matchers like toBeInTheDocument(), toHaveTextContent(), and toBeDisabled().

Rendering and Querying

The render function mounts a component into a virtual DOM. The screen object provides query methods to find elements:

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

function Greeting({ name }: { name: string }) {
  return <h1>Hello, {name}!</h1>;
}

test('renders a personalized greeting', () => {
  render(<Greeting name="Alice" />);

  expect(screen.getByRole('heading')).toHaveTextContent('Hello, Alice!');
});

Query Types: getBy vs queryBy vs findBy

The query prefix determines behavior when an element is not found:

PrefixNot foundAsync?Use case
getByThrows errorNoElement should exist right now
queryByReturns nullNoAssert element does NOT exist
findByThrows after timeoutYesElement appears after async operation
// Assert element exists
expect(screen.getByText('Submit')).toBeInTheDocument();

// Assert element does NOT exist
expect(screen.queryByText('Error')).not.toBeInTheDocument();

// Wait for element to appear (async)
const message = await screen.findByText('Saved successfully');
expect(message).toBeInTheDocument();

Testing User Interactions

Use @testing-library/user-event to simulate realistic user interactions:

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

function Counter() {
  const [count, setCount] = React.useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={()=> setCount(c=> c + 1)}>Increment</button>
      <button onClick={()=> setCount(0)}>Reset</button>
    </div>
  );
}

test('increments count on button click', async () => {
  const user = userEvent.setup();
  render(<Counter />);

  expect(screen.getByText('Count: 0')).toBeInTheDocument();

  await user.click(screen.getByRole('button', { name: 'Increment' }));
  expect(screen.getByText('Count: 1')).toBeInTheDocument();

  await user.click(screen.getByRole('button', { name: 'Increment' }));
  expect(screen.getByText('Count: 2')).toBeInTheDocument();

  await user.click(screen.getByRole('button', { name: 'Reset' }));
  expect(screen.getByText('Count: 0')).toBeInTheDocument();
});

Testing Form Input

function LoginForm({ onSubmit }: { onSubmit: (data: { email: string; password: string }) => void }) {
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const form = new FormData(e.currentTarget);
    onSubmit({
      email: form.get('email') as string,
      password: form.get('password') as string,
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" />
      <label htmlFor="password">Password</label>
      <input id="password" name="password" type="password" />
      <button type="submit">Sign In</button>
    </form>
  );
}

test('submits email and password', async () => {
  const user = userEvent.setup();
  const handleSubmit = jest.fn();

  render(<LoginForm onSubmit={handleSubmit} />);

  await user.type(screen.getByLabelText('Email'), 'alice@example.com');
  await user.type(screen.getByLabelText('Password'), 'secret123');
  await user.click(screen.getByRole('button', { name: 'Sign In' }));

  expect(handleSubmit).toHaveBeenCalledWith({
    email: 'alice@example.com',
    password: 'secret123',
  });
});

Testing Async Behavior

Components that fetch data or wait for timers need async queries:

function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = React.useState<{ name: string } | null>(null);

  React.useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser);
  }, [userId]);

  if (!user) return <p>Loading...</p>;
  return <h2>{user.name}</h2>;
}

test('shows user name after loading', async () => {
  global.fetch = jest.fn().mockResolvedValue({
    json: async () => ({ name: 'Alice' }),
  });

  render(<UserProfile userId={1} />);

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

  const heading = await screen.findByRole('heading');
  expect(heading).toHaveTextContent('Alice');
});

The findByRole query waits up to 1 second (configurable) for the element to appear in the DOM.

Key Takeaways

  • Use screen.getByRole and screen.getByLabelText to find elements like a user would.
  • Use getBy for elements that exist, queryBy to assert absence, findBy for async elements.
  • Use userEvent over fireEvent for realistic interaction simulation.
  • Mock fetch or API layers — not React internals or component state.
  • Test behavior (what the user sees and does), not implementation (state values, effect hooks).