Skip to content

Modern React 19: Testing components by behavior

This is part 19 of the Modern React development series.

React component tests are most useful when they describe what a user can observe or do. The test should care that a save button disables while saving, not which internal state variable stores the pending flag.

Concept

Behavior testing renders a component, interacts with it through the DOM, and asserts visible output or accessible state. React’s act helper is the underlying model for waiting until updates caused by interactions have been applied.

Terms

  • DOM: Document Object Model, the browser-like tree a test can query.
  • act: React’s test helper for flushing updates before assertions.
  • Accessible query: A query based on roles, labels, or text that mirrors how users and assistive tech find controls.
  • Implementation detail: Internal state, private functions, or markup structure that can change while behavior stays the same.

Mental model

Treat a component test like a short user session. Render the screen, perform an action, then check what changed from the user’s point of view.

How it is used

Use behavior tests for forms, buttons, validation messages, loading states, permissions, keyboard flows, and component contracts that should survive refactors.

How to use it

  1. Render the component with realistic props.
  2. Find controls by role, label, or visible text.
  3. Trigger user-like interactions.
  4. Await async UI changes when the interaction schedules updates.
  5. Assert visible text, accessible state, calls to public callbacks, or navigation effects.

Example: Counter behavior

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Counter } from "./Counter";
test("increments when clicked", async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole("button", { name: /count: 0/i }));
expect(screen.getByRole("button", { name: /count: 1/i })).toBeVisible();
});
Test result
test("increments when clicked", async () => {
  const user = userEvent.setup();
  render(<Counter />);
  await user.click(screen.getByRole("button", { name: /count: 0/i }));
  expect(screen.getByRole("button", { name: /count: 1/i })).toBeVisible();
});

The test finds the button like a user would and checks the behavior that matters.

Example: Form validation message

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test } from "vitest";
import { ProfileForm } from "./ProfileForm";
test("shows a validation message for a short display name", async () => {
const user = userEvent.setup();
render(<ProfileForm />);
await user.type(screen.getByLabelText(/display name/i), "A");
await user.click(screen.getByRole("button", { name: /save/i }));
expect(
await screen.findByText(/at least two characters/i),
).toBeVisible();
});
Test result
test("shows a validation message for a short display name", async () => {
  const user = userEvent.setup();
  render(<ProfileForm />);
  await user.type(screen.getByLabelText(/display name/i), "A");
  await user.click(screen.getByRole("button", { name: /save/i }));
  expect(
    await screen.findByText(/at least two characters/i),
  ).toBeVisible();
});

findByText waits for async UI to settle before the assertion runs.

Details to watch

  • React updates: Testing helpers commonly wrap interactions in act, but async UI still needs awaited queries.
  • Accessible selectors: Role and label queries double as accessibility pressure.
  • Mocking: Mock network or framework boundaries, not React internals.
  • Brittleness: Avoid asserting class names or component state unless that is the public contract.

Series navigation

References