# Web Testing React Testing Library

> React Testing Library — query hierarchy, userEvent, async utilities, renderHook, custom render with providers. Load when writing or reviewing tests that render React components or hooks.

- Skill: `agents-inc/web-testing-react-testing-library` (Agent Skill, multi-file: 11 files)
- Install (CLI): `npx skillmds@latest add agents-inc/web-testing-react-testing-library`
- Raw SKILL.md: https://api.skillmd.com/api/skills/agents-inc/web-testing-react-testing-library/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: agents-inc (https://skillmd.com/u/agents-inc)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/agents-inc/web-testing-react-testing-library

---


# React Testing Library Patterns

> **Quick Guide:** Query through the accessibility tree (`getByRole` first, `getByTestId` last), drive interactions with `userEvent` rather than `fireEvent`, and reach for `findBy*` whenever the element arrives asynchronously. `userEvent` has been async and `setup()`-first since v14, so every call is awaited. The library renders and interacts; the assertion API, mock functions and fake timers come from whatever test runner is executing the file.

**Detailed Resources:**

- [examples/core.md](examples/core.md) — query hierarchy, choosing between role, label, text and test id
- [examples/user-events.md](examples/user-events.md) — typing, clicking, keyboard shortcuts, tab order, and the cases fireEvent still covers
- [examples/async-testing.md](examples/async-testing.md) — `findBy*`, `waitFor`, `waitForElementToBeRemoved`
- [examples/custom-render.md](examples/custom-render.md) — a render wrapped in the app's providers, with per-test initial state
- [examples/hooks.md](examples/hooks.md) — `renderHook`, `rerender`, `unmount`, wrappers, hooks that throw
- [examples/accessibility.md](examples/accessibility.md) — keyboard navigation, ARIA attributes, live regions, focus trapping
- [examples/scoped-queries.md](examples/scoped-queries.md) — `within()` for tables, cards, sections and modals
- [examples/configuration.md](examples/configuration.md) — fake timers, pointer-events checks, timeout overrides
- [reference.md](reference.md) — query and role tables, userEvent methods, keyboard syntax, matcher list, debug checklist

---

## Which path applies

- **A component is the subject** — render it and query the resulting DOM;
  [examples/core.md](examples/core.md) and [examples/user-events.md](examples/user-events.md) are the
  path.
- **A bare hook is the subject** — no DOM to query, so `renderHook` exposes `result.current`;
  [examples/hooks.md](examples/hooks.md). Where the hook only makes sense inside its UI, test the
  component instead.
- **The subject needs providers** — theme, auth, routing — wrap once in a custom render rather than
  per test; [examples/custom-render.md](examples/custom-render.md).

---

<critical_requirements>

## Before writing React Testing Library code

**Reach for queries in accessibility order — `getByRole`, then `getByLabelText`, then `getByText`, and `getByTestId` only when nothing else reaches the element.** A query that has to fall back is telling you the element is unreachable to assistive technology too.

**Call `userEvent.setup()` before the first interaction and await every method.** Since v14 the methods return promises, so an un-awaited call lets assertions run against a DOM that has not finished updating.

**Use `findBy*` for anything that arrives asynchronously.** It retries and reports the DOM it gave up on, where `waitFor` wrapped around `getBy*` reports only the last thrown error.

**Query through `screen` rather than the object `render` returns.** One import serves every query, and the destructured form drifts out of step as a test grows.

**Assert on what the user can see or operate.** Internal state, refs and instance fields change under a refactor that keeps the behaviour identical.

</critical_requirements>

---

**Auto-detection:** @testing-library/react, @testing-library/user-event, @testing-library/dom, render, screen, userEvent.setup, fireEvent, waitFor, waitForElementToBeRemoved, findByRole, getByRole, getByLabelText, queryByRole, renderHook, result.current, within, cleanup, prettyDOM, logRoles, logTestingPlaygroundURL, testIdAttribute, asyncUtilTimeout

**Applies to:**

- Rendering a React component and asserting on what it puts in the DOM
- Driving forms, keyboard navigation and pointer interaction through a simulated user
- Testing loading, error and success states that resolve asynchronously
- Testing a custom hook in isolation, and deciding when not to
- Building a render that carries the app's providers
- Asserting that interactive elements are reachable by role, label and keyboard

**Handled elsewhere:**

- Test runner mechanics — the file's `describe`/`it`, the assertion API, mock functions, fake timers
  and the setup file are the runner's; this skill only names the seam where an option must be handed
  the runner's timer-advance function
- Test doubles for the network — a component is unaware of its transport, so responses are arranged
  by whatever intercepts them
- Journeys spanning navigations and real backends — browser-driven end-to-end coverage
- Whether a rendered frame still looks right — pixel comparison against an approved image

---

<philosophy>

## Philosophy

The library's own guiding principle: the more a test resembles the way the software is used, the more confidence it gives. Three consequences follow.

**Queries are an accessibility audit that happens to be a test.** A component reachable by `getByRole("button", { name: /submit/i })` is reachable by a screen reader for the same reason. When the only query that works is `getByTestId`, the test has just reported a defect in the UI rather than in itself.

**Implementation details are whatever survives a refactor unchanged.** State shape, hook internals, class names and DOM nesting are all free to move; rendered text, roles and accessible names are the contract.

**The DOM is real.** Components render into a real document, so integration bugs between a component and its children surface here rather than in production.

</philosophy>

---

<patterns>

## Core patterns

### Pattern 1: Query Priority Hierarchy

Pick the query by how a user would find the element, not by what is convenient in the markup.

```typescript
screen.getByRole("button", { name: /sign in/i }); // interactive elements
screen.getByLabelText(/password/i); // form fields, incl. password inputs (no textbox role)
screen.getByText(/welcome back/i); // non-interactive content
screen.getByDisplayValue("draft title"); // a field by its current value
screen.getByAltText(/profile photo/i); // images
screen.getByTestId(`product-${generatedId}`); // last resort: genuinely generated content
```

Full code: [examples/core.md](examples/core.md)

### Pattern 2: userEvent Over fireEvent

`fireEvent` dispatches one DOM event. `userEvent` replays the sequence a real interaction produces — pointer, focus and keyboard events in order — which is where handler bugs actually live.

```typescript
import userEvent from "@testing-library/user-event";

const user = userEvent.setup(); // before the first interaction
await user.type(screen.getByLabelText(/email/i), "user@example.com");
await user.click(screen.getByRole("button", { name: /create account/i }));
```

Reach for `fireEvent` only for events a user does not originate — `resize`, `scroll`, a synthetic event carrying properties you need to control.

Full code: [examples/user-events.md](examples/user-events.md)

### Pattern 3: Async Utilities

`findBy*` when the element appears; `waitFor` only when the thing you are waiting on is an assertion rather than an element; `waitForElementToBeRemoved` when it disappears.

```typescript
expect(await screen.findByText(/results/i)).toBeInTheDocument();

await waitFor(() => {
  expect(handler).toHaveBeenCalledWith("react"); // one assertion, no side effects
});

await waitForElementToBeRemoved(() => screen.queryByRole("progressbar"));
```

`waitFor` polls until its callback stops throwing, so a second assertion inside it pays the full retry cost before the first failure is reported, and a side effect inside it runs on every poll.

Full code: [examples/async-testing.md](examples/async-testing.md)

### Pattern 4: Testing Hooks with renderHook

`renderHook` mounts a hook without a component. `result.current` is re-read on every access, so hold the access rather than the value.

```typescript
const { result, rerender, unmount } = renderHook(
  ({ key }) => useLocalStorage(key, "initial"),
  { initialProps: { key: "draft" } },
);

act(() => {
  result.current[1]("updated");
});
expect(result.current[0]).toBe("updated");
```

Prefer a component test where the hook is inseparable from its UI; `renderHook` earns its place for hooks you publish, or hooks with edge cases a component would obscure.

Full code: [examples/hooks.md](examples/hooks.md)

### Pattern 5: Custom Render with Providers

Wrap the app's providers once and re-export, so no test repeats the nesting.

```typescript
function customRender(
  ui: ReactElement,
  options?: Omit<RenderOptions, "wrapper">,
) {
  return render(ui, { wrapper: AllProviders, ...options });
}

export * from "@testing-library/react";
export { customRender as render }; // shadow the default export
```

Widen the options type to accept per-test initial state — an auth state, a starting route — so a test states its precondition instead of arranging it.

Full code: [examples/custom-render.md](examples/custom-render.md)

### Pattern 6: Asserting Accessible Behaviour

Beyond finding elements, the accessibility tree is where keyboard order, expanded/selected state and announcements are asserted.

```typescript
await user.tab();
expect(screen.getByRole("combobox")).toHaveFocus();

await user.keyboard("{Enter}");
expect(screen.getByRole("button", { name: /section 1/i })).toHaveAttribute(
  "aria-expanded",
  "true",
);

expect(await screen.findByRole("alert")).toHaveTextContent(
  /email is required/i,
);
```

Full code: [examples/accessibility.md](examples/accessibility.md)

### Pattern 7: Scoped Queries with within

`within` bounds a query to a container, which is what makes repeated structures testable at all.

```typescript
const bobRow = screen.getAllByRole("row")[2];
await user.click(within(bobRow).getByRole("button", { name: /edit/i }));

const billing = screen.getByRole("region", { name: /billing address/i });
await user.type(
  within(billing).getByLabelText(/street address/i),
  "123 Billing St",
);
```

Full code: [examples/scoped-queries.md](examples/scoped-queries.md)

### Pattern 8: userEvent Setup Options

Three options change behaviour rather than tune it, and all three are set at `setup()`.

```typescript
const user = userEvent.setup({
  advanceTimers: advanceTimersBy, // your runner's timer-advance fn; without it, fake timers hang
  delay: null, // skip the inter-event wait
  pointerEventsCheck: 0, // interact with an element whose CSS sets pointer-events: none
});
```

Full code: [examples/configuration.md](examples/configuration.md)

</patterns>

---

<red_flags>

## Red flags

**Breaks at runtime:**

- A `userEvent` call without `await` — assertions run against the pre-interaction DOM, and the test passes or fails by timing
- Fake timers installed without passing `advanceTimers` to `setup()` — `userEvent`'s internal delay never resolves and the test hangs until the runner's timeout
- Calling `userEvent.click(...)` directly instead of on a `setup()` session — the direct API is deprecated and carries none of the session's pointer or keyboard state
- `getBy*` for an element that arrives asynchronously — throws immediately rather than waiting
- A side effect inside a `waitFor` callback — the callback runs on every poll, so the effect fires repeatedly

**Surprising behaviour:**

- `container.querySelector(".btn-primary")` returns `null` rather than throwing, so the failure surfaces one line later at the assertion instead of at the query — and it matches on the class names and nesting a refactor is free to change, which is the whole thing the query hierarchy exists to avoid
- `queryBy*` returns `null` instead of throwing, which is what makes it right for absence assertions and wrong for everything else
- `findBy*` defaults to a 1000ms timeout, and its third argument — not its second — is where that is raised
- `result.current` is re-read each time it is touched, so a value captured into a local goes stale after the next `act`
- `render`, `fireEvent` and `userEvent` already wrap in `act`; wrapping them again is noise that also hides genuine act warnings
- Cleanup runs automatically wherever the framework adapter is registered, so a manual `afterEach(cleanup)` is redundant
- Multiple assertions in one `waitFor` pay the full retry window before the first failure surfaces
- An empty `waitFor(() => {})` waits for the next poll tick and nothing else — a sleep wearing a matcher's clothes
- A large DOM snapshot fails with noise rather than with a reason; keep snapshots for small, stable output such as an icon or a breadcrumb
- `screen.debug()` truncates at 7000 characters, which is why a debugged form often appears to end mid-element

</red_flags>

