Skip to main content

πŸ§ͺ React Testing Library Basics

A test that breaks every time you rename a variable is worse than no test at all. React Testing Library flips the script: instead of poking at a component's internals, you interact with it the way a real user would β€” find a button by its label, click it, and check what changed on screen. In this lesson you'll write your first component tests using that user-first philosophy.

Week 5 · Day 5 (Friday: Testing React Components) · Lecture 1

🎯 Learning Objectives

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

  • Explain why React Testing Library tests behavior instead of implementation details
  • Render a component into a virtual DOM with render() and inspect it with screen
  • Choose the right query and query variant (getBy, queryBy, findBy) for the job
  • Follow the accessible query priority so your tests double as accessibility checks
  • Simulate realistic interactions with userEvent v14 and async/await
  • Write clear assertions with jest-dom matchers like toBeInTheDocument()

Estimated Time: 70 minutes

Practice: Test a greeting component and a login form the way a user would use them.

In This Lesson

Why Test Behavior, Not Internals

Imagine a quality inspector at a car factory. A bad inspector unscrews every panel to verify each bolt was torqued to spec β€” slow, and if the design changes, every checklist is thrown out. A great inspector drives the car: do the doors open, does the radio play, does it stop when you brake? React Testing Library (RTL) is the second inspector. It renders your component and lets you probe it from the outside β€” the same surface your users touch.

The alternative β€” reaching into a component's state, props, or private methods β€” is called testing implementation details. Those tests break the instant you refactor, even when the app still works perfectly. RTL's guiding principle, from its author Kent C. Dodds, is worth memorizing:

"The more your tests resemble the way your software is used, the more confidence they can give you."
Two testing approaches: implementation details are brittle, user behavior is resilient ❌ Implementation reads component state spies on internal methods checks prop values πŸ’₯ breaks on refactor βœ… Behavior finds text a user reads clicks the visible button asserts on the output πŸ›‘οΈ survives refactor
Test what the user experiences, and your tests keep passing through internal rewrites.

RTL is not a test runner. You still need a runner like Jest or Vitest to execute tests and provide describe, test, and expect. RTL supplies the tools to render components and query the resulting DOM.

render & screen: Your First Test

Every RTL test starts by mounting a component into a virtual DOM (provided by jsdom). The render() function does that, and the screen object gives you a set of queries that search the whole rendered document. Here's a tiny component and its first test.

// Greeting.jsx β€” the component under test
export function Greeting({ name = 'Stranger' }) {
  return (
    <section>
      <h1>Hello, {name}!</h1>
      <p>Welcome to our application.</p>
    </section>
  );
}
// Greeting.test.jsx
import { render, screen } from '@testing-library/react';
import { Greeting } from './Greeting';

describe('Greeting', () => {
  test('renders the default greeting', () => {
    render(<Greeting />);

    // screen queries search the entire rendered output
    const heading = screen.getByRole('heading', { name: 'Hello, Stranger!' });
    expect(heading).toBeInTheDocument();
  });

  test('renders a custom name', () => {
    render(<Greeting name="Ada" />);
    expect(
      screen.getByRole('heading', { name: 'Hello, Ada!' })
    ).toBeInTheDocument();
  });
});

πŸ“– Always use screen

Older tutorials destructured queries from render: const { getByText } = render(...). The modern approach is to import screen and call screen.getByRole(...). It reads clearly, never gets out of sync with what's on the page, and you don't have to update a destructuring list every time you need a new query. RTL also auto-cleans the DOM between tests, so each render starts fresh.

The RTL Cycle

Almost every RTL test follows the same four-beat rhythm. Internalize it and writing tests becomes muscle memory: render β†’ query β†’ interact β†’ assert.

graph LR A[Render
mount the component] --> B[Query
find elements a user sees] B --> C[Interact
type, click, select] C --> D[Assert
check the visible result] D --> B

Simple display components skip the "interact" step β€” render, query, assert, done. Interactive components loop through query β†’ interact β†’ assert as many times as needed to walk through a scenario. Everything else in this lesson is just filling in the details of each beat.

Queries & Query Priority

A query is how you find an element. RTL deliberately steers you toward queries that mirror how users and assistive technology perceive the page. Following the recommended priority means your tests also verify your UI is accessible β€” a two-for-one deal.

PriorityQueryFinds elements by
1getByRoleARIA role + accessible name (button, heading, textbox…)
2getByLabelTextThe <label> tied to a form field
3getByPlaceholderTextAn input's placeholder
4getByTextVisible text content
5getByDisplayValueThe current value of a form field
6getByAltTextAn image's alt attribute
7getByTitleThe title attribute
8getByTestIdA data-testid β€” last resort only
function QueryExamples() {
  return (
    <div>
      <h1>Query Examples</h1>
      <button>Save</button>
      <label htmlFor="username">Username</label>
      <input id="username" />
      <input type="text" placeholder="Search…" />
      <img src="/logo.png" alt="Company logo" />
    </div>
  );
}

test('demonstrates the query priority in action', () => {
  render(<QueryExamples />);

  // 1. By role β€” the gold standard
  screen.getByRole('heading', { name: 'Query Examples' });
  screen.getByRole('button', { name: 'Save' });

  // 2. By label text β€” for form controls
  screen.getByLabelText('Username');

  // 3. By placeholder β€” when there is no label (prefer a label!)
  screen.getByPlaceholderText('Search…');

  // 6. By alt text β€” for images
  screen.getByAltText('Company logo');
});

βœ… Why getByRole wins

Screen readers navigate by role. If getByRole('button', { name: 'Save' }) can't find your button, neither can a screen-reader user. Reaching for getByRole first nudges you to write semantic, labeled HTML. Save getByTestId for the rare element with no accessible handle at all β€” a decorative wrapper you truly must target.

getBy vs queryBy vs findBy

Each query name comes in three variants. Picking the right one is one of the most common early stumbling blocks, so let's make the rule crisp: choose based on whether the element should exist now, should be absent, or will appear later.

VariantIf not foundAsync?Use when…
getBy…throws an errornothe element should already be on screen
queryBy…returns nullnoyou're asserting an element is absent
findBy…rejects after ~1syes (Promise)the element appears after an async update
// getBy β€” must exist right now
expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument();

// queryBy β€” proves something is NOT there (getBy would throw)
expect(screen.queryByRole('alert')).not.toBeInTheDocument();

// findBy β€” waits for an element that shows up later (note: await)
const banner = await screen.findByText('Saved!');
expect(banner).toBeInTheDocument();

⚠️ The classic mistake

Never use getBy to check for absence β€” it throws before your assertion runs, and never use queryBy to grab something you intend to use, because a stray null gives a confusing error later. Memorize: get to grab, query to prove-absent, find to wait.

Simulating Users with userEvent

To test interactive components you need to drive them. RTL's companion library @testing-library/user-event (v14) simulates real interactions far more faithfully than the older fireEvent: typing dispatches keydown/keypress/input/keyup for each character, clicking includes focus and pointer events, and so on.

Two rules for v14: call userEvent.setup() once at the start of the test, and await every interaction β€” they are all async.

import { useState } from 'react';

function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!email || !password) {
      setError('All fields are required');
      return;
    }
    onSubmit({ email, password });
  };

  return (
    <form onSubmit={handleSubmit}>
      <h2>Login</h2>
      {error && <div role="alert">{error}</div>}

      <label htmlFor="email">Email</label>
      <input id="email" type="email"
             value={email} onChange={(e) => setEmail(e.target.value)} />

      <label htmlFor="password">Password</label>
      <input id="password" type="password"
             value={password} onChange={(e) => setPassword(e.target.value)} />

      <button type="submit">Login</button>
    </form>
  );
}
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

describe('LoginForm', () => {
  test('renders all fields', () => {
    render(<LoginForm onSubmit={() => {}} />);

    expect(screen.getByLabelText('Email')).toBeInTheDocument();
    expect(screen.getByLabelText('Password')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument();
  });

  test('shows an error when submitting an empty form', async () => {
    const onSubmit = jest.fn();
    const user = userEvent.setup();          // 1. set up once
    render(<LoginForm onSubmit={onSubmit} />);

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

    expect(screen.getByRole('alert')).toHaveTextContent('All fields are required');
    expect(onSubmit).not.toHaveBeenCalled();  // behavior, not internals
  });

  test('submits the entered credentials', async () => {
    const onSubmit = jest.fn();
    const user = userEvent.setup();
    render(<LoginForm onSubmit={onSubmit} />);

    await user.type(screen.getByLabelText('Email'), 'ada@example.com');
    await user.type(screen.getByLabelText('Password'), 's3cret!');
    await user.click(screen.getByRole('button', { name: 'Login' }));

    expect(onSubmit).toHaveBeenCalledWith({
      email: 'ada@example.com',
      password: 's3cret!',
    });
  });
});

Notice how these tests read almost like a QA script: "find the email box, type an address, click Login, expect the form to submit." That readability is the payoff of the user-first approach. The most common actions you'll reach for:

MethodWhat it does
await user.click(el)A full click (pointer down/up, focus)
await user.type(el, 'text')Types character by character
await user.clear(el)Clears an input
await user.selectOptions(el, 'us')Picks a <select> option
await user.keyboard('{Enter}')Presses keys
await user.tab()Moves focus to the next element

Assertions with jest-dom

The @testing-library/jest-dom package adds DOM-aware matchers to expect so your assertions describe intent clearly. Instead of expect(el).not.toBeNull() you write expect(el).toBeInTheDocument(). Register it once in your test setup file and every test can use it.

// setupTests.js β€” imported once (via Jest's setupFilesAfterEach)
import '@testing-library/jest-dom';
// A tour of the matchers you'll use most
expect(el).toBeInTheDocument();               // element is in the DOM
expect(el).toHaveTextContent('Welcome');      // contains this text
expect(input).toHaveValue('ada@example.com'); // form field value
expect(checkbox).toBeChecked();               // checkbox / radio state
expect(button).toBeDisabled();                // disabled attribute
expect(el).toBeVisible();                     // not hidden by CSS
expect(link).toHaveAttribute('href', '/home');

Output β€” a failing assertion is readable

expect(element).toBeInTheDocument()

  received value must be an HTMLElement or an SVGElement.
  Received has value: null
    // β†’ your query found nothing; check the role/name

Practice & Quiz

πŸ‹οΈ Exercise 1: Test a counter

Goal: Given the component below, write two tests β€” one that it starts at zero, and one that clicking "Increment" makes it show Count: 1. Use getByRole and userEvent.

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}
πŸ’‘ Hint

Render the component, then screen.getByText('Count: 0'). For the click test, set up userEvent, await user.click(screen.getByRole('button', { name: 'Increment' })), then assert on Count: 1.

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

test('starts at zero', () => {
  render(<Counter />);
  expect(screen.getByText('Count: 0')).toBeInTheDocument();
});

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

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

  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

πŸ‹οΈ Exercise 2: Toggle visibility

Goal: A <Disclosure> shows a "Show details" button; clicking it reveals a paragraph with role region. Write a test proving the region is absent initially and present after the click. Which query variant proves absence?

βœ… Solution
test('reveals details on demand', async () => {
  const user = userEvent.setup();
  render(<Disclosure />);

  // queryBy proves it is NOT there yet
  expect(screen.queryByRole('region')).not.toBeInTheDocument();

  await user.click(screen.getByRole('button', { name: 'Show details' }));

  // now it exists β€” getBy is fine
  expect(screen.getByRole('region')).toBeInTheDocument();
});

🎯 Quick Quiz

Question 1: You want to assert that an error message is not on the page. Which query variant should you use?

Question 2: Which query should you reach for first according to RTL's priority?

Question 3: With userEvent v14, what must you do before each interaction like type or click?

Best Practices & Pitfalls

βœ… Do

  • Use screen.getByRole(...) with an accessible name as your default query
  • Prefer userEvent over fireEvent, and await every interaction
  • Assert on what the user sees β€” visible text, enabled/disabled state, form values
  • Give inputs real <label>s so getByLabelText and screen readers both work

❌ Don't

  • Reach into component state, spy on useState, or test private methods
  • Sprinkle data-testid everywhere as a shortcut around accessible queries
  • Use getBy to check for absence β€” that throws; use queryBy
  • Forget to await a findBy or a user.* call

⚠️ fireEvent still has a niche

// userEvent can't simulate a raw scroll β€” fireEvent can
import { fireEvent } from '@testing-library/react';
fireEvent.scroll(container, { target: { scrollTop: 500 } });

For 95% of interactions use userEvent. Fall back to fireEvent only for low-level DOM events userEvent doesn't model (scroll, some media events).

Summary

πŸŽ‰ Key Takeaways

  • RTL tests behavior, not implementation β€” the more they resemble real usage, the more confidence they give
  • The cycle is render β†’ query β†’ interact β†’ assert
  • Follow the query priority: getByRole first, getByTestId last
  • getBy to grab, queryBy to prove absence, findBy to await
  • Use userEvent.setup() and await every interaction for realistic input
  • jest-dom matchers like toBeInTheDocument() make assertions read like intent

πŸ“š Additional Resources

πŸš€ What's Next?

You can now render, query, and interact with components. But real apps fetch data and use timers, which happen over time. The next lesson tackles that head-on: Testing Hooks and Async Code β€” findBy, waitFor, renderHook, and fake timers.

πŸŽ‰ First tests, done right!

You're testing the way users actually use your app β€” the kind of test that keeps passing while your code evolves.