Skip to main content
Technology & EngineeringTesting Services385 lines

Testing Library

"Testing Library: React/DOM testing, queries (getBy/findBy/queryBy), user events, screen, waitFor, render, accessibility-first"

Quick Summary18 lines
Testing Library is a family of testing utilities built around one guiding principle: tests should resemble the way software is used. Instead of testing component internals, state variables, or implementation details, you test what the user actually sees and interacts with. This produces tests that are resilient to refactors, meaningful as documentation, and effective at catching real bugs. The query hierarchy deliberately prioritizes accessible selectors (roles, labels, text) over test IDs, encouraging you to build accessible interfaces as a natural side effect of writing good tests. If your test can find an element, so can a screen reader.

## Key Points

- Always use `userEvent.setup()` at the start of each test rather than calling `userEvent.click()` directly; the setup instance handles event ordering correctly.
- Prefer `getByRole` with an accessible name as the default query; it verifies both visibility and accessibility in one assertion.
- Use `queryBy` only for asserting absence (`not.toBeInTheDocument()`); use `getBy` or `findBy` for presence checks.
- Wrap assertions on async state changes in `waitFor` or use `findBy` queries instead of adding manual delays.
- Create a custom `renderWithProviders` function to avoid repeating provider wrappers across tests.
- Use `within()` to scope queries to a specific container, making tests more precise in pages with repeated structures.
- Treat test failures from accessibility queries as signals to improve component markup, not as reasons to fall back to `getByTestId`.
- Never use `container.querySelector` or `innerHTML` assertions; they test implementation details and bypass the user-centric query model.
- Do not test internal component state (`useState`, `useReducer` values); test the rendered output that results from state changes.
- Avoid wrapping every assertion in `act()`; `render`, `userEvent`, and `findBy` already handle batching updates correctly.
- Do not use `fireEvent` when `userEvent` is available; `fireEvent` dispatches raw DOM events without the browser-like behavior chain (focus, keydown, input, change, blur).
- Avoid snapshot testing for component output; explicit assertions on text, roles, and attributes are more readable and maintainable.
skilldb get testing-services-skills/Testing LibraryFull skill: 385 lines
Paste into your CLAUDE.md or agent config

Testing Library

Core Philosophy

Testing Library is a family of testing utilities built around one guiding principle: tests should resemble the way software is used. Instead of testing component internals, state variables, or implementation details, you test what the user actually sees and interacts with. This produces tests that are resilient to refactors, meaningful as documentation, and effective at catching real bugs. The query hierarchy deliberately prioritizes accessible selectors (roles, labels, text) over test IDs, encouraging you to build accessible interfaces as a natural side effect of writing good tests. If your test can find an element, so can a screen reader.

Setup

Installation and Configuration

// npm install -D @testing-library/react @testing-library/jest-dom
//              @testing-library/user-event @testing-library/dom

// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";

afterEach(() => {
  cleanup();
});

Vitest Integration

// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: ["./src/test/setup.ts"],
    css: true,
  },
});

Key Techniques

Query Priority and Variants

import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { SignupForm } from "./SignupForm";

describe("Query Priority (most to least preferred)", () => {
  it("demonstrates the query hierarchy", () => {
    render(<SignupForm />);

    // 1. getByRole — best: accessible to everyone
    screen.getByRole("heading", { name: "Create Account" });
    screen.getByRole("textbox", { name: "Email address" });
    screen.getByRole("button", { name: "Submit" });
    screen.getByRole("checkbox", { name: "Accept terms" });

    // 2. getByLabelText — great for form fields
    screen.getByLabelText("Password");
    screen.getByLabelText(/confirm password/i);

    // 3. getByPlaceholderText — when label is absent
    screen.getByPlaceholderText("Search...");

    // 4. getByText — for non-interactive content
    screen.getByText("Already have an account?");
    screen.getByText(/must be at least 8 characters/i);

    // 5. getByDisplayValue — for pre-filled inputs
    screen.getByDisplayValue("user@example.com");

    // 6. getByAltText — for images
    screen.getByAltText("Company logo");

    // 7. getByTitle — rarely used
    screen.getByTitle("Close dialog");

    // 8. getByTestId — last resort for non-semantic elements
    screen.getByTestId("animated-confetti-container");
  });
});

Query Variants: getBy, queryBy, findBy

import { render, screen, waitFor } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { NotificationBell } from "./NotificationBell";

describe("Query variant usage", () => {
  it("getBy — element MUST exist (throws if not found)", () => {
    render(<NotificationBell count={3} />);
    const badge = screen.getByRole("status");
    expect(badge).toHaveTextContent("3");
  });

  it("queryBy — element MIGHT not exist (returns null)", () => {
    render(<NotificationBell count={0} />);
    // Assert absence: queryBy returns null instead of throwing
    expect(screen.queryByRole("status")).not.toBeInTheDocument();
  });

  it("findBy — element appears ASYNCHRONOUSLY (returns Promise)", async () => {
    render(<NotificationBell count={3} />);
    // findBy waits for the element to appear (combines getBy + waitFor)
    const badge = await screen.findByRole("status");
    expect(badge).toHaveTextContent("3");
  });

  it("getAllBy — multiple elements expected", () => {
    render(<NotificationBell count={3} />);
    const items = screen.getAllByRole("listitem");
    expect(items).toHaveLength(3);
  });
});

User Events (Realistic Interaction Simulation)

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { LoginForm } from "./LoginForm";

describe("LoginForm", () => {
  it("submits credentials on form submit", async () => {
    const onSubmit = vi.fn();
    const user = userEvent.setup();

    render(<LoginForm onSubmit={onSubmit} />);

    // Type into fields (simulates real keyboard events)
    await user.type(screen.getByLabelText("Email"), "alice@test.com");
    await user.type(screen.getByLabelText("Password"), "secret123");

    // Click submit
    await user.click(screen.getByRole("button", { name: "Log In" }));

    expect(onSubmit).toHaveBeenCalledWith({
      email: "alice@test.com",
      password: "secret123",
    });
  });

  it("shows validation errors for empty fields", async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={vi.fn()} />);

    // Tab through fields without entering values
    await user.tab(); // focus email
    await user.tab(); // focus password (email blurs)
    await user.tab(); // blur password

    expect(screen.getByText("Email is required")).toBeVisible();
    expect(screen.getByText("Password is required")).toBeVisible();
  });

  it("toggles password visibility", async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={vi.fn()} />);

    const passwordInput = screen.getByLabelText("Password");
    expect(passwordInput).toHaveAttribute("type", "password");

    await user.click(screen.getByRole("button", { name: "Show password" }));
    expect(passwordInput).toHaveAttribute("type", "text");
  });
});

Advanced User Event Patterns

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { TextEditor } from "./TextEditor";

describe("Advanced interactions", () => {
  it("handles keyboard shortcuts", async () => {
    const onSave = vi.fn();
    const user = userEvent.setup();
    render(<TextEditor onSave={onSave} />);

    const editor = screen.getByRole("textbox");
    await user.type(editor, "Hello world");

    // Simulate Ctrl+S keyboard shortcut
    await user.keyboard("{Control>}s{/Control}");
    expect(onSave).toHaveBeenCalledWith("Hello world");
  });

  it("handles select and option interactions", async () => {
    const user = userEvent.setup();
    render(<TextEditor onSave={vi.fn()} />);

    await user.selectOptions(
      screen.getByRole("combobox", { name: "Font size" }),
      "16"
    );

    expect(screen.getByRole("option", { name: "16px" })).toBeSelected();
  });

  it("handles file upload", async () => {
    const user = userEvent.setup();
    render(<TextEditor onSave={vi.fn()} />);

    const file = new File(["content"], "doc.txt", { type: "text/plain" });
    const input = screen.getByLabelText("Upload file");

    await user.upload(input, file);
    expect(input.files?.[0]).toBe(file);
  });

  it("clears and replaces input value", async () => {
    const user = userEvent.setup();
    render(<TextEditor onSave={vi.fn()} />);

    const input = screen.getByRole("textbox", { name: "Title" });
    await user.type(input, "Draft");
    expect(input).toHaveValue("Draft");

    await user.clear(input);
    await user.type(input, "Final");
    expect(input).toHaveValue("Final");
  });
});

Async Operations with waitFor and findBy

import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { UserSearch } from "./UserSearch";

describe("UserSearch (async data fetching)", () => {
  it("displays search results after debounced API call", async () => {
    const user = userEvent.setup();
    render(<UserSearch />);

    await user.type(screen.getByRole("searchbox"), "alice");

    // findBy returns a promise that resolves when element appears
    const resultsList = await screen.findByRole("list", {
      name: "Search results",
    });

    const items = within(resultsList).getAllByRole("listitem");
    expect(items.length).toBeGreaterThan(0);
  });

  it("shows loading state then results", async () => {
    const user = userEvent.setup();
    render(<UserSearch />);

    await user.type(screen.getByRole("searchbox"), "bob");

    // Loading indicator appears
    expect(await screen.findByText("Searching...")).toBeVisible();

    // Then results replace it
    await waitFor(() => {
      expect(screen.queryByText("Searching...")).not.toBeInTheDocument();
    });

    expect(screen.getByRole("list")).toBeInTheDocument();
  });

  it("displays empty state message", async () => {
    const user = userEvent.setup();
    render(<UserSearch />);

    await user.type(screen.getByRole("searchbox"), "zzzznonexistent");

    await waitFor(() => {
      expect(screen.getByText("No users found")).toBeVisible();
    });
  });
});

Testing with Context Providers

import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { ThemeProvider } from "../context/ThemeContext";
import { AuthProvider } from "../context/AuthContext";
import { ThemedButton } from "./ThemedButton";
import type { ReactElement } from "react";

// Custom render function with providers
function renderWithProviders(
  ui: ReactElement,
  {
    theme = "light",
    user = null,
  }: { theme?: "light" | "dark"; user?: { name: string } | null } = {}
) {
  return render(
    <AuthProvider value={{ user }}>
      <ThemeProvider value={{ theme }}>
        {ui}
      </ThemeProvider>
    </AuthProvider>
  );
}

describe("ThemedButton", () => {
  it("renders with light theme styles", () => {
    renderWithProviders(<ThemedButton>Click me</ThemedButton>, {
      theme: "light",
    });

    const button = screen.getByRole("button", { name: "Click me" });
    expect(button).toHaveClass("theme-light");
  });

  it("shows user name when authenticated", () => {
    renderWithProviders(<ThemedButton>Click me</ThemedButton>, {
      user: { name: "Alice" },
    });

    expect(screen.getByText("Hello, Alice")).toBeVisible();
  });
});

jest-dom Matchers

import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { AlertBanner } from "./AlertBanner";

describe("jest-dom custom matchers", () => {
  it("demonstrates common assertions", () => {
    render(<AlertBanner type="warning" message="Disk space low" />);

    const banner = screen.getByRole("alert");

    expect(banner).toBeInTheDocument();
    expect(banner).toBeVisible();
    expect(banner).toHaveTextContent("Disk space low");
    expect(banner).toHaveClass("alert-warning");
    expect(banner).toHaveAttribute("aria-live", "polite");
    expect(banner).not.toBeDisabled();
    expect(banner).toHaveStyle({ borderColor: "orange" });
  });
});

Best Practices

  • Always use userEvent.setup() at the start of each test rather than calling userEvent.click() directly; the setup instance handles event ordering correctly.
  • Prefer getByRole with an accessible name as the default query; it verifies both visibility and accessibility in one assertion.
  • Use queryBy only for asserting absence (not.toBeInTheDocument()); use getBy or findBy for presence checks.
  • Wrap assertions on async state changes in waitFor or use findBy queries instead of adding manual delays.
  • Create a custom renderWithProviders function to avoid repeating provider wrappers across tests.
  • Use within() to scope queries to a specific container, making tests more precise in pages with repeated structures.
  • Treat test failures from accessibility queries as signals to improve component markup, not as reasons to fall back to getByTestId.

Anti-Patterns

  • Never use container.querySelector or innerHTML assertions; they test implementation details and bypass the user-centric query model.
  • Do not test internal component state (useState, useReducer values); test the rendered output that results from state changes.
  • Avoid wrapping every assertion in act(); render, userEvent, and findBy already handle batching updates correctly.
  • Do not use fireEvent when userEvent is available; fireEvent dispatches raw DOM events without the browser-like behavior chain (focus, keydown, input, change, blur).
  • Avoid snapshot testing for component output; explicit assertions on text, roles, and attributes are more readable and maintainable.
  • Do not reach for getByTestId before trying semantic queries; it hides accessibility problems in your markup.

Install this skill directly: skilldb add testing-services-skills

Get CLI access →