Testing Library
"Testing Library: React/DOM testing, queries (getBy/findBy/queryBy), user events, screen, waitFor, render, accessibility-first"
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 linesTesting 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 callinguserEvent.click()directly; the setup instance handles event ordering correctly. - Prefer
getByRolewith an accessible name as the default query; it verifies both visibility and accessibility in one assertion. - Use
queryByonly for asserting absence (not.toBeInTheDocument()); usegetByorfindByfor presence checks. - Wrap assertions on async state changes in
waitForor usefindByqueries instead of adding manual delays. - Create a custom
renderWithProvidersfunction 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.querySelectororinnerHTMLassertions; they test implementation details and bypass the user-centric query model. - Do not test internal component state (
useState,useReducervalues); test the rendered output that results from state changes. - Avoid wrapping every assertion in
act();render,userEvent, andfindByalready handle batching updates correctly. - Do not use
fireEventwhenuserEventis available;fireEventdispatches 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
getByTestIdbefore trying semantic queries; it hides accessibility problems in your markup.
Install this skill directly: skilldb add testing-services-skills
Related Skills
Cypress
"Cypress: end-to-end/component testing, commands, intercept (network mocking), fixtures, custom commands, CI, retries"
K6
k6: load testing, performance testing, stress testing, soak testing, thresholds, checks, scenarios, browser testing, CI integration
MSW
"MSW (Mock Service Worker): API mocking, request handlers, rest/graphql, browser/node, setupServer, network-level interception"
Pact
Pact: consumer-driven contract testing, provider verification, pact broker, API compatibility, microservice integration testing
Playwright
"Playwright: end-to-end testing, browser automation, selectors, assertions, fixtures, page objects, visual regression, CI, codegen"
Storybook Test
Storybook Test: component testing via play functions, interaction testing, accessibility checks, visual regression, test-runner, portable stories