Course topics

By WebNest Studio Engineering Team

React Tutorial

Testing React Components with React Testing Library

Automated tests give confidence that a component behaves correctly and keeps behaving correctly as the code changes. React Testing Library (RTL) is the standard tool for testing React components, and it is built around a specific philosophy: tests should interact with components the way a real user would — finding elements by visible text, labels, or roles, and clicking or typing — rather than reaching into component internals like state or instance methods.

RTL is typically paired with a test runner such as Jest or Vitest, which provides describe, it/test, and assertion functions, while RTL itself provides the rendering and querying utilities.

Why it matters

A user interface has many states, and checking them all by hand after every change does not scale. React Testing Library tests a component the way a user meets it: by what is on screen and what happens on a click. Tests written this way survive refactoring and give real confidence that the feature works.

render and screen

render(<Component />) mounts a component into a virtual DOM for testing. After rendering, you query the result using the screen object, which exposes methods like getByText, getByRole, and getByLabelText to find elements the same way a user would identify them visually — by their text or accessible role — rather than by CSS class names or internal implementation details.

Simulating User Interaction with fireEvent

fireEvent dispatches DOM events such as clicks, changes, and form submissions on elements found via screen. A typical pattern is: render the component, find an element, fire an event on it (like a click), then assert that the UI updated as expected using expect(...) matchers such as toBeInTheDocument() or toHaveTextContent(). The newer userEvent library models real user interaction even more closely (including focus and keyboard events) and is often preferred over fireEvent for new tests, but fireEvent remains the foundational, directly-included tool.

Testing Philosophy: Behavior, Not Implementation

RTL deliberately makes it awkward to test internal state or call component methods directly, because doing so couples tests to implementation details that can change without changing user-visible behavior. A well-written RTL test would rather assert "the text 'Item added' appears after clicking Add" than "the internal itemCount state equals 1" — the former still passes after a refactor that changes how state is stored internally, as long as the visible behavior is unchanged.

Examples

Testing a counter component: rendering, clicking, and asserting on the result

JavaScript
// Counter.jsx
import { useState } from 'react';

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

// Counter.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import Counter from './Counter';

test('increments the count when the button is clicked', () => {
  render(<Counter />);

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

  const button = screen.getByRole('button', { name: /increment/i });
  fireEvent.click(button);

  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
Output
Running the test suite (e.g. "npm test") reports this test as passing: the component initially shows "Count: 0", and after simulating a click on the Increment button, the text "Count: 1" is found in the rendered output.

Testing a simple login form, including an accessible label query

JavaScript
// LoginForm.jsx
import { useState } from 'react';

export default function LoginForm({ onSubmit }) {
  const [username, setUsername] = useState('');

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        onSubmit(username);
      }}
    >
      <label htmlFor="username">Username</label>
      <input
        id="username"
        value={username}
        onChange={(e) => setUsername(e.target.value)}
      />
      <button type="submit">Log In</button>
    </form>
  );
}

// LoginForm.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import LoginForm from './LoginForm';

test('calls onSubmit with the typed username', () => {
  const handleSubmit = jest.fn();
  render(<LoginForm onSubmit={handleSubmit} />);

  const input = screen.getByLabelText('Username');
  fireEvent.change(input, { target: { value: 'ash123' } });
  fireEvent.click(screen.getByRole('button', { name: /log in/i }));

  expect(handleSubmit).toHaveBeenCalledWith('ash123');
});
Output
The test passes: getByLabelText finds the input via its associated <label>, fireEvent.change simulates typing "ash123", and clicking "Log In" triggers the form submit handler, which calls the mocked onSubmit function with "ash123" as expected.

Common Mistakes

  • Querying elements by CSS class or test-only implementation details instead of by visible text, label, or ARIA role, making tests brittle to styling changes.
  • Testing internal component state directly instead of the rendered output a user would actually see.
  • Forgetting to import the jest-dom matchers (like toBeInTheDocument), causing confusing "not a function" errors on assertions.
  • Using getByText/getByRole (which throw immediately if not found) when queryByText or findByText would be more appropriate for optional or asynchronous elements.

Exercise

Do this in a Vite + React project with Vitest and React Testing Library installed (npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom, with the test environment set to jsdom). Given the Counter component in the starter, write a test file src/Counter.test.jsx with one test that renders the counter, checks that it shows "Count: 0", clicks the button, and checks that it now shows "Count: 1".

Expected result: running the tests reports one passing test.

JavaScript · starter code
// src/Counter.jsx
import { useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Add one</button>
    </div>
  );
}

// src/Counter.test.jsx
// TODO: import render, screen and fireEvent, and the Counter
// TODO: one test: render, assert "Count: 0", click the button, assert "Count: 1"
Show hints
  • Find elements the way a user would: screen.getByText("Count: 0") and screen.getByRole("button", { name: "Add one" }).
  • getByText throws if nothing matches, so calling it is already an assertion that the text is present.
Show solution
JavaScript · solution
// src/Counter.jsx
import { useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Add one</button>
    </div>
  );
}

// src/Counter.test.jsx
import { expect, test } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import Counter from "./Counter";

test("adds one when the button is clicked", () => {
  render(<Counter />);

  expect(screen.getByText("Count: 0")).toBeTruthy();

  fireEvent.click(screen.getByRole("button", { name: "Add one" }));

  expect(screen.getByText("Count: 1")).toBeTruthy();
});

Quick quiz

1. What is the guiding principle of React Testing Library?

  • A. Test the internal state of components
  • B. Test every private function
  • C. Avoid rendering components
  • D. Test the component the way a user uses it: through what is rendered and how it responds
Show answer

D. Tests then keep passing when the implementation is refactored without changing behaviour.

2. Which query is generally preferred for finding an element?

  • A. getByRole, with the accessible name
  • B. getByTestId
  • C. querySelector with a class
  • D. getByClassName
Show answer

A. It finds elements as assistive technology does, so it also checks accessibility.

3. What is the difference between getBy and queryBy queries?

  • A. There is none
  • B. getBy throws when nothing matches; queryBy returns null, which is useful for asserting that something is absent
  • C. queryBy throws
  • D. getBy is asynchronous
Show answer

B. findBy queries return a promise and wait for the element to appear.

Interview questions

How do you test a React component?

Show answer

Render it with React Testing Library, find elements with queries that reflect how a user perceives the page, such as getByRole and getByLabelText, simulate interaction with userEvent or fireEvent, and assert on what is then on screen. Network requests are mocked so that the test is fast and predictable. The test checks behaviour, not internal state or implementation details.

Why avoid testing implementation details?

Show answer

A test that inspects internal state, private functions or the exact structure of components breaks whenever the code is refactored, even when the behaviour the user sees is unchanged, which makes the tests a burden. It can also pass while the feature is actually broken for the user. Testing through the rendered output and interactions ties the test to what matters and leaves the implementation free to change.

Key Points to Remember

  • React Testing Library encourages testing components the way a user interacts with them, via visible text, labels, and roles.
  • render() mounts a component for testing; screen provides query methods to find elements in the rendered output.
  • fireEvent (or the more realistic userEvent) simulates user interactions like clicks and typing.
  • Prefer asserting on visible behavior over internal state, so tests stay valid across refactors that don't change user-facing behavior.

Practice in the playground

Write and run your own code for this topic, or start from the exercise or an example. Shows a live preview of plain HTML, CSS and JavaScript. JSX needs a React project on your computer. Your code for each topic is kept in this browser.

Run the examples

Change an input, predict the result, then compare it with the output. Explain why the result changes.

These examples need a project environment on your computer, so they can be copied but not run here. Codelab runs Python, FastAPI backends with SQLite, plain Java 17 and HTML/CSS/JavaScript.

WebNest Studio

Written by

WebNest Studio Engineering Team

Practical lessons from developers working with Java, Python, React, databases and production systems.

Led by Vansh Duggal, Founder, WebNest Studio · Full-stack & software engineering