Skip to main content

βš›οΈ React Component Testing

Components are what your users actually touch, so they deserve tests that behave like a user. This lesson uses React Testing Library and Jest to render components, query them the accessible way, simulate real interactions, mock the network, and test custom hooks.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain the Testing Library philosophy and why it favours accessible queries
  • Render a component and query it by role, label, and text in priority order
  • Simulate interactions with user-event and choose it over fireEvent
  • Test asynchronous components and mock fetch or use Mock Service Worker
  • Test a custom hook with renderHook and act

Estimated Time: 40–50 minutes  β€’  Difficulty: Intermediate

Hands-on: Write a full test suite for a Todo list component.

In This Lesson

Why Test Components?

Components are the building blocks of your interface β€” the parts a user sees, reads, and clicks. Testing them well gives you five concrete wins:

  • Correctness β€” they render the right thing under different props and states.
  • Reliability β€” they respond correctly to clicks, typing, and keyboard use.
  • Regression safety β€” a change in one place doesn't silently break another.
  • Accessibility β€” if a test can find an element by its role, so can a screen reader.
  • Documentation β€” a readable test is the clearest spec of how a component should behave.
πŸ’‘ An analogy: Testing components is quality control on the assembly line. You check each part in isolation before you bolt the whole car together β€” cheaper to catch a faulty bracket now than after the vehicle ships.
flowchart TD A[Component behaviour] --> B[Rendering] A --> C[User interactions] A --> D[Props & state] A --> E[Async / side effects] A --> F[Error & empty states] B --> H[React Testing Library] C --> H D --> H E --> H F --> H H --> I[Confident, refactorable UI]

The Testing Library Philosophy

"The more your tests resemble the way your software is used, the more confidence they can give you." β€” Kent C. Dodds, creator of Testing Library

That single sentence drives every design decision in React Testing Library (RTL). Instead of poking at component internals β€” state variables, method names, instance properties β€” you interact with the rendered DOM the way a person would.

πŸ“– Guiding principles

Test behaviour, not implementation. Assert on what the user sees, not how the component stores it.

Find elements as a user would. Prefer accessible roles, labels, and visible text over test IDs.

Accessibility first. A component that's hard to query is usually hard to use with assistive tech, too.

Implementation testing versus behaviour testing Testing implementation details produces brittle tests, while testing user-visible behaviour produces resilient tests that survive refactors. Test the implementation reads state, calls methods, queries by test IDs β†’ brittle tests break on every refactor Test the behaviour queries by role & text, acts like a real user β†’ resilient tests survive internal changes
Figure 1 β€” Coupling tests to internals makes them brittle; coupling them to user-visible behaviour makes them resilient.

Tools & Setup

A modern React test stack has a small, well-defined set of packages:

PackageRole
@testing-library/reactRender components and query the DOM (also ships renderHook)
@testing-library/jest-domReadable DOM matchers like toBeInTheDocument()
@testing-library/user-eventRealistic user interaction simulation
jest / vitest + jsdomTest runner and a simulated browser environment
mswMock Service Worker β€” mocks the network at the request level
npm install --save-dev @testing-library/react @testing-library/jest-dom \
  @testing-library/user-event jest jest-environment-jsdom

Point Jest at a setup file so the DOM matchers are available in every test:

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
};

// jest.setup.js
import '@testing-library/jest-dom';

πŸ’‘ Already using Vite?

Vite projects usually pair with Vitest instead of Jest. The Testing Library API is identical; only the runner config differs. Everything in this lesson works unchanged under Vitest.

Querying Elements

RTL queries come in three variants that differ in how they handle a missing element:

  • getBy… β€” returns the element, or throws if it's absent (use for things that must exist now).
  • queryBy… β€” returns the element or null (use to assert absence).
  • findBy… β€” returns a Promise that resolves when the element appears (use for async UI).

Each has an …AllBy… plural that returns an array.

Query methods, in priority order

Prefer queries near the top β€” they mirror how users and assistive technology find things. Drop to getByTestId only as a last resort.

// 1. Accessible to everyone β€” strongly preferred
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText(/email/i);
screen.getByPlaceholderText(/search/i);
screen.getByText(/welcome back/i);

// 2. Semantic queries
screen.getByAltText(/profile picture/i);
screen.getByTitle(/close/i);

// 3. Escape hatch β€” avoid unless nothing above works
screen.getByTestId('custom-widget');

// Asserting absence uses queryBy (returns null, doesn't throw)
expect(screen.queryByText(/error/i)).not.toBeInTheDocument();
πŸ’‘ An analogy: Identifying an element is like finding a person in a crowd. You'd call out their role ("the chef!"), or their name, or what they're wearing before resorting to a secret badge number you assigned. The most natural identifier should win.

Testing Interactions

RTL offers two ways to trigger events. fireEvent dispatches a single raw DOM event; user-event simulates the full sequence a real user produces (focus, keydown, keypress, input, keyup…). Prefer user-event β€” it catches bugs that fireEvent misses.

The preferred approach: user-event

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

test('input updates as the user types', async () => {
  const user = userEvent.setup();     // call setup() once per test
  render(<input aria-label="name" />);
  const input = screen.getByLabelText(/name/i);

  await user.type(input, 'Hello World'); // fires per-character events

  expect(input).toHaveValue('Hello World');
});

The common interactions, all returning promises you should await:

await user.click(button);
await user.dblClick(button);
await user.type(input, 'Hello');
await user.clear(input);
await user.keyboard('{Enter}');
await user.selectOptions(select, ['option1']);
await user.tab();

⚠️ Always await user-event calls

Since v14, every user-event action is async. Forgetting await leads to assertions that run before the interaction finishes β€” the classic "my test passes but the app is broken" trap.

Worked Example: A Signup Form

Here's a form with client-side validation. Note the accessible markup β€” every input has a <label>, and errors use role="alert" β€” which makes it a joy to test.

// SignupForm.jsx
import { useState } from 'react';

export default function SignupForm({ onSubmit }) {
  const [form, setForm] = useState({ username: '', email: '', password: '' });
  const [errors, setErrors] = useState({});

  const handleChange = (e) =>
    setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }));

  const validate = () => {
    const next = {};
    if (!form.username) next.username = 'Username is required';
    if (!form.email) next.email = 'Email is required';
    else if (!/\S+@\S+\.\S+/.test(form.email)) next.email = 'Email is invalid';
    if (!form.password) next.password = 'Password is required';
    else if (form.password.length < 6) next.password = 'Password must be at least 6 characters';
    setErrors(next);
    return Object.keys(next).length === 0;
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    if (validate()) onSubmit(form);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="username">Username</label>
      <input id="username" name="username" value={form.username} onChange={handleChange} />
      {errors.username && <span role="alert">{errors.username}</span>}

      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" value={form.email} onChange={handleChange} />
      {errors.email && <span role="alert">{errors.email}</span>}

      <label htmlFor="password">Password</label>
      <input id="password" name="password" type="password" value={form.password} onChange={handleChange} />
      {errors.password && <span role="alert">{errors.password}</span>}

      <button type="submit">Sign Up</button>
    </form>
  );
}

The test suite reads like a checklist of user stories:

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

describe('SignupForm', () => {
  test('renders every field and the submit button', () => {
    render(<SignupForm onSubmit={() => {}} />);
    expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /sign up/i })).toBeInTheDocument();
  });

  test('shows required errors on empty submit', async () => {
    const user = userEvent.setup();
    render(<SignupForm onSubmit={() => {}} />);

    await user.click(screen.getByRole('button', { name: /sign up/i }));

    expect(screen.getByText(/username is required/i)).toBeInTheDocument();
    expect(screen.getByText(/email is required/i)).toBeInTheDocument();
    expect(screen.getByText(/password is required/i)).toBeInTheDocument();
  });

  test('flags an invalid email format', async () => {
    const user = userEvent.setup();
    render(<SignupForm onSubmit={() => {}} />);

    await user.type(screen.getByLabelText(/email/i), 'invalid-email');
    await user.click(screen.getByRole('button', { name: /sign up/i }));

    expect(screen.getByText(/email is invalid/i)).toBeInTheDocument();
  });

  test('calls onSubmit with the form data when valid', async () => {
    const user = userEvent.setup();
    const onSubmit = jest.fn();
    render(<SignupForm onSubmit={onSubmit} />);

    await user.type(screen.getByLabelText(/username/i), 'testuser');
    await user.type(screen.getByLabelText(/email/i), 'test@example.com');
    await user.type(screen.getByLabelText(/password/i), 'password123');
    await user.click(screen.getByRole('button', { name: /sign up/i }));

    expect(onSubmit).toHaveBeenCalledTimes(1);
    expect(onSubmit).toHaveBeenCalledWith({
      username: 'testuser',
      email: 'test@example.com',
      password: 'password123',
    });
  });
});

βœ… Notice what we did not do

We never read form state or spied on validate. We drove the component through its labels and button and asserted on visible errors and the submit callback β€” exactly what a user experiences. That's why this suite survives a refactor of the internals.

Testing Async Components

Many components fetch data on mount. To test them, mock the network and then wait for the UI to settle using findBy… or waitFor.

// UserProfile.jsx
import { useState, useEffect } from 'react';

export default function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let active = true;
    (async () => {
      try {
        setLoading(true);
        const res = await fetch(`https://api.example.com/users/${userId}`);
        if (!res.ok) throw new Error('Failed to fetch user');
        const data = await res.json();
        if (active) setUser(data);
      } catch (err) {
        if (active) setError(err.message);
      } finally {
        if (active) setLoading(false);
      }
    })();
    return () => { active = false; };
  }, [userId]);

  if (loading) return <div>Loading user data...</div>;
  if (error) return <div role="alert">Error: {error}</div>;
  if (!user) return <div>No user found</div>;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>Email: {user.email}</p>
    </div>
  );
}

Option A β€” mock fetch directly

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

beforeEach(() => {
  global.fetch = jest.fn();
});
afterEach(() => jest.restoreAllMocks());

test('renders user data when the fetch succeeds', async () => {
  global.fetch.mockResolvedValueOnce({
    ok: true,
    json: async () => ({ id: '123', name: 'John Doe', email: 'john@example.com' }),
  });

  render(<UserProfile userId="123" />);

  // findBy waits for the async render to complete
  expect(await screen.findByRole('heading', { name: /john doe/i })).toBeInTheDocument();
  expect(screen.getByText(/john@example.com/i)).toBeInTheDocument();
});

test('shows an error when the fetch fails', async () => {
  global.fetch.mockResolvedValueOnce({ ok: false });

  render(<UserProfile userId="123" />);

  expect(await screen.findByRole('alert')).toHaveTextContent(/failed to fetch user/i);
});

Option B β€” Mock Service Worker (recommended)

Mocking fetch works, but MSW intercepts requests at the network layer, so your component code β€” and even the real fetch β€” runs untouched. This is the modern MSW v2 API (http + HttpResponse):

import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
  http.get('https://api.example.com/users/:userId', ({ params }) => {
    if (params.userId === '123') {
      return HttpResponse.json({ id: '123', name: 'John Doe', email: 'john@example.com' });
    }
    return new HttpResponse(null, { status: 404 });
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

πŸ’‘ findBy vs. waitFor

Use findByRole/findByText when you're waiting for an element to appear β€” it's the cleanest option. Reach for waitFor(() => expect(...)) when you need to wait for an arbitrary condition, and waitForElementToBeRemoved to assert a spinner has disappeared.

Testing Custom Hooks

You can't call a hook outside a component, so RTL provides renderHook. Wrap any state update that happens outside an event in act so React flushes it before you assert.

// useCounter.js
import { useState, useCallback } from 'react';

export default function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);
  const increment = useCallback(() => setCount((c) => c + 1), []);
  const decrement = useCallback(() => setCount((c) => c - 1), []);
  const reset = useCallback(() => setCount(initial), [initial]);
  return { count, increment, decrement, reset };
}
// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';

describe('useCounter', () => {
  test('starts at the default value', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  test('honours an initial value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  test('increments and decrements', () => {
    const { result } = renderHook(() => useCounter(5));

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

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

  test('resets to the initial value', () => {
    const { result } = renderHook(() => useCounter(5));

    act(() => {
      result.current.increment();
      result.current.increment();
    });
    expect(result.current.count).toBe(7);

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

Best Practices

βœ… Do

  • Query by role and label first. Accessible queries double as accessibility checks.
  • Use user-event for interactions, and always await it.
  • Test all the states: loading, success, error, and empty.
  • Mock at the network boundary with MSW so your component code runs for real.
  • Assert with jest-dom matchers like toBeInTheDocument and toBeDisabled for readable failures.

⚠️ Avoid

  • Testing implementation details β€” internal state, instance methods, or CSS class names.
  • Overusing data-testid when a role or label would do.
  • Forgetting to await async interactions and queries β€” a top source of flaky tests.
  • Sharing state between tests; render fresh in each one.
πŸ’‘ From the field: Teams that follow RTL's behaviour-first approach report that a UI rewrite barely touches their tests, whereas suites glued to implementation details often have to be thrown away and rewritten. Resilience is the whole point.

Hands-on Exercise

πŸ‹οΈ Test a Todo list component

Objective: Write a full suite for the component below, driving it entirely through accessible queries and user-event.

// TodoList.jsx
import { useState } from 'react';

export default function TodoList() {
  const [todos, setTodos] = useState([]);
  const [value, setValue] = useState('');

  const add = () => {
    if (!value.trim()) return;
    setTodos((t) => [...t, { id: Date.now(), text: value, done: false }]);
    setValue('');
  };
  const toggle = (id) =>
    setTodos((t) => t.map((x) => (x.id === id ? { ...x, done: !x.done } : x)));
  const remove = (id) => setTodos((t) => t.filter((x) => x.id !== id));

  return (
    <div>
      <h2>Todo List</h2>
      <label htmlFor="new-todo">New todo</label>
      <input id="new-todo" value={value} onChange={(e) => setValue(e.target.value)} />
      <button onClick={add}>Add</button>
      {todos.length === 0 ? (
        <p>No todos yet. Add one above!</p>
      ) : (
        <ul>
          {todos.map((t) => (
            <li key={t.id}>
              <input type="checkbox" checked={t.done}
                onChange={() => toggle(t.id)}
                aria-label={`Toggle ${t.text}`} />
              <span style={{ textDecoration: t.done ? 'line-through' : 'none' }}>{t.text}</span>
              <button onClick={() => remove(t.id)} aria-label={`Delete ${t.text}`}>Delete</button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Verify that:

  1. The empty state shows "No todos yet".
  2. Typing a todo and clicking Add makes it appear in the list.
  3. Toggling the checkbox applies line-through styling.
  4. Clicking Delete removes the item.
πŸ’‘ Hint

Query the input with getByLabelText(/new todo/i) and the checkboxes/delete buttons via their aria-labels (e.g. getByRole('checkbox', { name: /toggle buy milk/i })). Use queryByText to assert the empty message is gone after adding.

βœ… Sample solution
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoList from './TodoList';

test('shows the empty state initially', () => {
  render(<TodoList />);
  expect(screen.getByText(/no todos yet/i)).toBeInTheDocument();
});

test('adds a todo', async () => {
  const user = userEvent.setup();
  render(<TodoList />);

  await user.type(screen.getByLabelText(/new todo/i), 'Buy milk');
  await user.click(screen.getByRole('button', { name: /add/i }));

  expect(screen.getByText('Buy milk')).toBeInTheDocument();
  expect(screen.queryByText(/no todos yet/i)).not.toBeInTheDocument();
});

test('toggles and deletes a todo', async () => {
  const user = userEvent.setup();
  render(<TodoList />);

  await user.type(screen.getByLabelText(/new todo/i), 'Buy milk');
  await user.click(screen.getByRole('button', { name: /add/i }));

  await user.click(screen.getByRole('checkbox', { name: /toggle buy milk/i }));
  expect(screen.getByText('Buy milk')).toHaveStyle('text-decoration: line-through');

  await user.click(screen.getByRole('button', { name: /delete buy milk/i }));
  expect(screen.queryByText('Buy milk')).not.toBeInTheDocument();
});

🎯 Quick Quiz

Question 1: Which query should you use to assert that an element is not in the document?

Question 2: Why is user-event preferred over fireEvent?

Question 3: Which helper renders and tests a custom hook in isolation?

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Test behaviour, not implementation β€” the Testing Library creed.
  • Query by role, label, and text before ever reaching for data-testid.
  • Use user-event for interactions and always await it.
  • Test async UI with findBy/waitFor, and mock the network with MSW.
  • Test custom hooks with renderHook, wrapping updates in act.

πŸ“š Further Reading

πŸš€ What's Next?

Now that you can test single components and hooks, we'll zoom out to integration testing β€” verifying that many components, contexts, and services work together across a complete user flow.

πŸŽ‰ Great work!

You can render, query, interact, and mock like a user. Your components now have a safety net that survives refactors.