React Testing Library skill for Codex

Find it by role, click it like a user. Codex tests React components through their accessible surface: role and label queries, awaited user-event interactions, findBy for loading states and a custom render that supplies the app's providers. It fixes act warnings by awaiting the real outcome rather than wrapping code in act, and treats a failed role query as a possible accessibility bug.

  • Testing & QA
  • $react-testing-library
  • 199 lines
  • 2 files
  • ~1,850 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- react-testing-library

Installs to ~/.agents/skills/react-testing-library/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $react-testing-library, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Tests React components the way users interact with them using React Testing Library and user-event v14 - accessible query priority (getByRole first), findBy for async UI, queryBy for absence, within for scoping, custom render with providers, renderHook, jest-dom matchers, and fixes for act warnings. Use when writing or reviewing component tests in Jest or Vitest, when a query cannot find an element, tests emit "not wrapped in act(...)" warnings, or tests break whenever markup or class names change.

Use it when

  • Writing component tests under Jest or Vitest with jsdom
  • Queries cannot find elements or match too many
  • Tests emit act warnings or hang with fake timers
  • Tests break on markup or class-name changes

Use something else when

How to check the result

Tests pass without act warnings, still pass after a harmless markup change such as an extra wrapper div, and fail when an accessible label or handler is removed.

The mistake it prevents

Not awaiting user-event calls, or creating userEvent after render, which produces act warnings and assertions that run before the UI has updated.

~/.agents/skills/react-testing-library/SKILL.md

--- name: react-testing-library description: Tests React components the way users interact with them using React Testing Library and user-event v14 - accessible query priority (getByRole first),… license: MIT ---

A good component test renders with the same providers the app uses, finds elements by role and accessible name, drives them with user-event, and asserts on what the user can see or what the component calls back. It survives a markup refactor that keeps behavior and accessibility intact.

Inspect first

  1. Read AGENTS.md, the test runner config (Jest or Vitest) and the setup file that imports @testing-library/jest-dom.
  2. Check versions in package.json: @testing-library/react 16+ needs @testing-library/dom installed as a peer; @testing-library/user-event should be v14.
  3. Look for an existing test-utils.tsx with a custom render. Use it; do not render bare components that need router, query client, store or theme.
  4. If network calls are involved, see whether MSW is set up ($api-mocking-msw).

Query priority

PriorityQueryTypical target
1getByRole(role, { name })buttons, links, headings, textboxes, checkboxes, dialogs, rows
2getByLabelTextform fields with labels
3getByPlaceholderTextonly if no label exists (then report the missing label)
4getByTextnon-interactive text content
5getByDisplayValue, getByAltText, getByTitlespecific cases
6getByTestIdlast resort: no accessible handle exists

Variants:

PrefixMissing elementMultiple matchesWaits
getBythrowsthrowsno
queryByreturns nullthrowsno
findByrejects after timeoutrejectsyes (polls, default 1000 ms)
getAllBy / findAllBythrows / rejectsreturns arrayfindAll waits

Use queryBy only to assert absence: expect(screen.queryByRole("alert")).not.toBeInTheDocument().

Baseline test

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

it("submits trimmed credentials", async () => {
  const user = userEvent.setup();
  const onSubmit = vi.fn();          // jest.fn() under Jest
  render(<LoginForm onSubmit={onSubmit} />);

  await user.type(screen.getByLabelText("Email"), "  [email protected] ");
  await user.type(screen.getByLabelText("Password"), "s3cret!");
  await user.click(screen.getByRole("button", { name: "Sign in" }));

  expect(onSubmit).toHaveBeenCalledWith({ email: "[email protected]", password: "s3cret!" });
});
  • Call userEvent.setup() once per test, before render, and await every interaction.
  • Prefer user-event over fireEvent: it fires the full sequence (pointer, focus, keydown, input, keyup) and respects disabled elements. Use fireEvent only for events user-event does not model (for example a custom transitionend).
  • Query through screen, not destructured render results; it stays valid after rerenders.

Async UI

it("shows the order after loading", async () => {
  render(<OrderPage id="42" />, { wrapper: AppProviders });

  expect(screen.getByRole("progressbar")).toBeInTheDocument();
  expect(await screen.findByRole("heading", { name: "Order #42" })).toBeVisible();
  expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
});
  • findBy* for something that will appear. waitFor only for assertions that are not a single query (a mock being called, a count changing).
  • One assertion per waitFor callback, no side effects inside it (no clicks), and never an empty callback.
  • waitForElementToBeRemoved(() => screen.queryByText("Loading...")) for disappearance.

Scoping and lists

const row = screen.getByRole("row", { name: /INV-0042/ });
await user.click(within(row).getByRole("button", { name: "Download" }));

const dialog = await screen.findByRole("dialog", { name: "Delete invoice" });
await user.click(within(dialog).getByRole("button", { name: "Delete" }));

Accessible names come from labels, aria-label, aria-labelledby and text content. When a role query fails, print what the tree exposes:

import { logRoles } from "@testing-library/dom";
logRoles(container);   // container from render()
screen.debug(undefined, 20000);

Custom render with providers

// src/test/test-utils.tsx
import { render, type RenderOptions } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router";

export function renderWithProviders(
  ui: React.ReactElement,
  { route = "/", ...options }: RenderOptions & { route?: string } = {},
) {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },   // fail fast in tests
  });
  return render(
    <QueryClientProvider client={queryClient}>
      <MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>
    </QueryClientProvider>,
    options,
  );
}

Create a new client or store per test; a shared one leaks cache between tests.

Hooks

import { renderHook, act } from "@testing-library/react";

it("toggles", () => {
  const { result } = renderHook(() => useToggle(false));
  act(() => result.current.toggle());
  expect(result.current.on).toBe(true);
});

Test hooks directly only when they are reusable units. A hook used by one component is better tested through that component.

Fake timers with user-event

vi.useFakeTimers({ shouldAdvanceTime: true });
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });

Without advanceTimers, user-event’s internal delays wait on frozen timers and the test hangs. Under Jest pass jest.advanceTimersByTime.

Act warnings

“An update to X inside a test was not wrapped in act(…)” means state changed after the test stopped waiting. Fix by awaiting the outcome, not by wrapping in act:

CauseFix
Data loads after the last assertionawait screen.findBy... the loaded state
Interaction not awaitedawait user.click(...)
Debounce or timer fires after testfake timers and advance them, or await the result
Effect resolves after unmounttest ends correctly; cancel the effect in cleanup in the component

jest-dom matchers worth using

toBeInTheDocument, toBeVisible, toBeDisabled, toHaveValue, toBeChecked, toHaveAccessibleName, toHaveAccessibleDescription, toHaveAttribute, toHaveFocus, toHaveTextContent. They read better and fail with clearer messages than expect(el.disabled).toBe(true).

What not to test

  • Implementation details: state variable names, hook call counts, class names, internal component instances.
  • Snapshot of the full component tree as the only assertion.
  • Third-party component internals (date picker popups); test your integration through their accessible surface.

Lint support

eslint-plugin-testing-library and eslint-plugin-jest-dom catch most misuse (prefer-screen-queries, no-wait-for-side-effects, prefer-find-by, no-node-access). Enable them if the repo lints tests.

Verification

  1. Run the test file in the project runner; no act warnings in the output.
  2. Rename a CSS class or wrap markup in an extra div: the tests still pass.
  3. Remove the button label or break the handler: a test fails with a message that names the missing role or call.
  4. Run the file several times; async tests must not depend on timing.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "React Testing Library"
  short_description: "Find it by role, click it like a user"
  default_prompt: "$react-testing-library Write tests for the LoginForm component with React Testing Library"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem