React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

component-testingpropsinteractionssnapshotsconditional-rendering

Testing React Components

Test component rendering, user interactions, conditional UI, and dynamic content with confidence.

Recipe

Quick-reference recipe card -- copy-paste ready.

import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
 
// Test rendering with props
render(<Alert severity="warning" message="Disk full" />);
expect(screen.getByRole("alert")).toHaveTextContent("Disk full");
 
// Test user interactions
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: /delete/i }));
expect(screen.queryByText("Item 1")).not.toBeInTheDocument();
 
// Test conditional rendering
render(<Banner show={false} />);
expect(screen.queryByRole("banner")).not.toBeInTheDocument();
 
// Test lists
const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(3);
expect(within(items[0]).getByText("First")).toBeInTheDocument();

When to reach for this: Whenever you build a component that renders props, handles interactions, or shows conditional UI.

Working Example

// src/components/todo-list.tsx
"use client";
 
import { useState } from "react";
 
interface Todo {
  id: string;
  text: string;
  completed: boolean;
}
 
export function TodoList({ initialTodos = [] }: { initialTodos?: Todo[] }) {
  const [todos, setTodos] = useState<Todo[]>(initialTodos);
  const [input, setInput] = useState("");
 
  function addTodo() {
    if (!input.trim()) return;
    setTodos((prev) => [
      ...prev,
      { id: crypto.randomUUID(), text: input.trim(), completed: false },
    ]);
    setInput("");
  }
 
  function toggleTodo(id: string) {
    setTodos((prev) =>
      prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
    );
  }
 
  function deleteTodo(id: string) {
    setTodos((prev) => prev.filter((t) => t.id !== id));
  }
 
  const remaining = todos.filter((t) => !t.completed).length;
 
  return (
    <section aria-label="Todo list">
      <form
        onSubmit={(e) => {
          e.preventDefault();
          addTodo();
        }}
      >
        <label htmlFor="new-todo">New todo</label>
        <input
          id="new-todo"
          value={input}
          onChange={(e) => setInput(e.target.value)}
        />
        <button type="submit">Add</button>
      </form>
 
      {todos.length === 0 ? (
        <p>No todos yet. Add one above!</p>
      ) : (
        <>
          <ul>
            {todos.map((todo) => (
              <li key={todo.id}>
                <label>
                  <input
                    type="checkbox"
                    checked={todo.completed}
                    onChange={() => toggleTodo(todo.id)}
                  />
                  <span
                    style={{
                      textDecoration: todo.completed ? "line-through" : "none",
                    }}
                  >
                    {todo.text}
                  </span>
                </label>
                <button
                  onClick={() => deleteTodo(todo.id)}
                  aria-label={`Delete ${todo.text}`}
                >
                  Delete
                </button>
              </li>
            ))}
          </ul>
          <p>{remaining} item{remaining !== 1 ? "s" : ""} remaining</p>
        </>
      )}
    </section>
  );
}
// src/components/todo-list.test.tsx
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, describe, it, expect, beforeEach } from "vitest";
import { TodoList } from "./todo-list";
 
// Mock crypto.randomUUID for deterministic IDs
beforeEach(() => {
  let counter = 0;
  vi.stubGlobal("crypto", {
    randomUUID: () => `test-id-${++counter}`,
  });
});
 
describe("TodoList", () => {
  const user = userEvent.setup();
 
  describe("empty state", () => {
    it("shows empty message when no todos", () => {
      render(<TodoList />);
      expect(screen.getByText(/no todos yet/i)).toBeInTheDocument();
    });
 
    it("does not show the list", () => {
      render(<TodoList />);
      expect(screen.queryByRole("list")).not.toBeInTheDocument();
    });
  });
 
  describe("rendering with initial todos", () => {
    const todos = [
      { id: "1", text: "Write tests", completed: false },
      { id: "2", text: "Fix bugs", completed: true },
    ];
 
    it("renders all todos", () => {
      render(<TodoList initialTodos={todos} />);
      expect(screen.getAllByRole("listitem")).toHaveLength(2);
      expect(screen.getByText("Write tests")).toBeInTheDocument();
      expect(screen.getByText("Fix bugs")).toBeInTheDocument();
    });
 
    it("shows completed todos with checked checkbox", () => {
      render(<TodoList initialTodos={todos} />);
      const items = screen.getAllByRole("listitem");
      const fixBugsItem = items[1];
      expect(within(fixBugsItem).getByRole("checkbox")).toBeChecked();
    });
 
    it("shows remaining count", () => {
      render(<TodoList initialTodos={todos} />);
      expect(screen.getByText("1 item remaining")).toBeInTheDocument();
    });
  });
 
  describe("adding todos", () => {
    it("adds a new todo on form submit", async () => {
      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.getByLabelText(/new todo/i)).toHaveValue("");
    });
 
    it("does not add empty todos", async () => {
      render(<TodoList />);
      await user.click(screen.getByRole("button", { name: /add/i }));
      expect(screen.queryByRole("list")).not.toBeInTheDocument();
    });
  });
 
  describe("toggling todos", () => {
    it("toggles completion on checkbox click", async () => {
      render(
        <TodoList
          initialTodos={[{ id: "1", text: "Test", completed: false }]}
        />
      );
      const checkbox = screen.getByRole("checkbox");
      expect(checkbox).not.toBeChecked();
 
      await user.click(checkbox);
      expect(checkbox).toBeChecked();
      expect(screen.getByText("0 items remaining")).toBeInTheDocument();
    });
  });
 
  describe("deleting todos", () => {
    it("removes todo on delete click", async () => {
      render(
        <TodoList
          initialTodos={[{ id: "1", text: "Test", completed: false }]}
        />
      );
      await user.click(screen.getByRole("button", { name: /delete test/i }));
 
      expect(screen.queryByText("Test")).not.toBeInTheDocument();
      expect(screen.getByText(/no todos yet/i)).toBeInTheDocument();
    });
  });
});

What this demonstrates:

  • Testing empty state, initial rendering, and dynamic updates
  • User interactions: typing, clicking, form submission
  • Conditional rendering assertions with queryBy
  • Scoped queries with within()
  • Accessible aria-label for targeted delete button queries

Deep Dive

How It Works

  • Each render() call creates a fresh DOM tree -- tests are isolated by default
  • within() scopes queries to a specific container element, useful for testing individual list items
  • queryBy returns null instead of throwing, making it ideal for asserting absence
  • getAllBy returns an array of matching elements -- use .toHaveLength() to assert list sizes
  • Testing Library auto-cleans up after each test (unmounts and removes DOM nodes)

Variations

Snapshot testing (use sparingly):

it("matches snapshot", () => {
  const { container } = render(
    <Alert severity="error" message="Something broke" />
  );
  expect(container.firstChild).toMatchSnapshot();
});
// Snapshots are useful for detecting unintended UI changes
// but are brittle and often auto-updated without review

Inline snapshots for small output:

it("renders correct class names", () => {
  render(<Badge variant="success">OK</Badge>);
  expect(screen.getByText("OK").className).toMatchInlineSnapshot(
    `"badge badge-success"`
  );
});

Testing with rerender for prop changes:

it("updates when severity changes", () => {
  const { rerender } = render(<Alert severity="info" message="Note" />);
  expect(screen.getByRole("alert")).toHaveClass("alert-info");
 
  rerender(<Alert severity="error" message="Note" />);
  expect(screen.getByRole("alert")).toHaveClass("alert-error");
});

TypeScript Notes

// Type-safe test factories for complex props
function createTodo(overrides: Partial<Todo> = {}): Todo {
  return {
    id: crypto.randomUUID(),
    text: "Default todo",
    completed: false,
    ...overrides,
  };
}
 
// Use in tests
render(<TodoList initialTodos={[createTodo({ text: "Custom" })]} />);

Gotchas

  • Testing implementation details -- Checking internal state, instance methods, or CSS class names ties tests to code structure. Fix: Assert what the user sees -- text content, visibility, enabled/disabled state.

  • Over-relying on snapshots -- Large snapshots are hard to review and get rubber-stamped on update. Fix: Use snapshots only for small, stable output. Prefer explicit assertions.

  • Forgetting to await userEvent -- All userEvent methods return promises in v14+. Fix: Always await them or the test may pass before the interaction completes.

  • Not using within() for list items -- screen.getByText("Delete") matches the first delete button on the page. Fix: Scope to the specific list item with within() or use unique aria-label attributes.

  • Testing too many things in one test -- Tests that add, toggle, and delete in a single it block are hard to debug when they fail. Fix: Write focused tests for each behavior.

Alternatives

AlternativeUse WhenDon't Use When
Storybook + ChromaticYou need visual regression testing alongside component storiesYou only need behavioral tests
Playwright Component TestingYou need real browser rendering (canvas, complex CSS)Fast unit-level component tests are sufficient
Snapshot testing onlyGuarding against unintended changes in stable, small componentsComponents change frequently or snapshots are large

FAQs

How do I test that an element is NOT rendered (conditional rendering)?

Use queryBy which returns null instead of throwing:

render(<Banner show={false} />);
expect(screen.queryByRole("banner")).not.toBeInTheDocument();
How do I test a specific item in a list?

Use within() to scope queries to a container:

const items = screen.getAllByRole("listitem");
expect(within(items[0]).getByText("First")).toBeInTheDocument();
When should I use snapshot testing?
  • Use sparingly, only for small and stable output.
  • Snapshots are brittle and often auto-updated without proper review.
  • Prefer explicit assertions like toHaveTextContent or toBeInTheDocument.
How do I test that a component updates when props change?

Use rerender from the render result:

const { rerender } = render(<Alert severity="info" message="Note" />);
rerender(<Alert severity="error" message="Note" />);
expect(screen.getByRole("alert")).toHaveClass("alert-error");
Gotcha: Why does screen.getByText("Delete") match the wrong button?

If multiple elements contain the same text, getByText matches the first one. Use within() to scope to a specific parent, or use unique aria-label attributes.

How do I mock crypto.randomUUID() for deterministic test IDs?
let counter = 0;
vi.stubGlobal("crypto", {
  randomUUID: () => `test-id-${++counter}`,
});
Why should I write focused tests instead of testing multiple behaviors in one it block?
  • Focused tests are easier to debug when they fail.
  • The test name tells you exactly what broke.
  • Multi-behavior tests give vague failure messages.
Gotcha: What happens if I forget to await userEvent methods?

In userEvent v14+, all methods are async. Without await, interactions may not complete before assertions run, causing false positives or flaky tests.

How do I create a type-safe test factory for complex props in TypeScript?
function createTodo(overrides: Partial<Todo> = {}): Todo {
  return {
    id: crypto.randomUUID(),
    text: "Default todo",
    completed: false,
    ...overrides,
  };
}
Does Testing Library automatically clean up after each test?

Yes. Modern Testing Library auto-cleans up after each test by unmounting components and removing DOM nodes. You do not need to call cleanup() manually.

How do I assert the number of items in a list?
const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(3);
What is the difference between inline snapshots and regular snapshots?
  • Regular snapshots save output to a separate .snap file.
  • Inline snapshots embed the expected value directly in the test file, making them easier to review for small output.