Testing Library skill for Claude Code

Component tests that query like a screen reader. Claude writes component tests that find elements by role and accessible name, drive them with awaited user-event calls, and assert on what a user would see. It builds one render helper with fresh providers per test, stubs the network rather than hooks, and fixes act() warnings by waiting for the visible result instead of wrapping calls in act.

  • nametesting-library
  • size188 lines · ≈1,950 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Writes component tests with Testing Library (React, Vue, Svelte, DOM) that query by role and accessible name, drive interactions with user-event v14, and wait with findBy and waitFor instead of timeouts. Covers jest-dom matchers, render wrappers for providers, renderHook, network stubbing, and fixing act() warnings. Use when testing UI components, when tests query by class names or test IDs everywhere, when act() warnings or async flakiness appear, or when reviewing component tests for implementation coupling.

Try asking

  • Write tests for the LoginForm component covering validation and server errors
  • Our component tests use container.querySelector everywhere. Rewrite them properly
  • Why do I get 'not wrapped in act' warnings in this test?

Use it when

  • Testing React, Vue, or Svelte components
  • Tests rely on class names, test IDs, or component internals
  • act() warnings or async timing failures in component tests
  • Setting up a shared render helper with providers

Skip it when

How to check the result

Tests pass with no act() warnings, still pass after a markup refactor that keeps roles and names, and fail when the user-visible behavior is removed.

The common mistake it prevents

Wrapping user-event calls in extra act() or sleeping to silence warnings, instead of awaiting the findBy query for the UI change the update produces.

~/.claude/skills/testing-library/SKILL.md

--- name: testing-library description: Writes component tests with Testing Library (React, Vue, Svelte, DOM) that query by role and accessible name, drive interactions with… license: MIT ---

A good component test reads like a user story: find the control a user would find, interact the way a user would, assert on what the user sees. If a refactor that keeps the UI identical breaks the test, the test is coupled to implementation.

Ground rules

  • Query priority: getByRole > getByLabelText > getByPlaceholderText > getByText > getByDisplayValue > getByAltText > getByTitle > getByTestId. Reach for a test ID only when no accessible handle exists, and treat that as a possible a11y bug.
  • Use screen for queries; do not destructure from render().
  • Use user-event (v14, userEvent.setup()), not fireEvent, for interactions.
  • Every interaction is awaited. Every async appearance uses findBy* or waitFor.
  • Never assert on component state, props passed to children, or CSS class names unless the class is the user-visible contract.
  • Stub the network (MSW), not the data-fetching hooks.

Workflow

- [ ] 1. Check setup: library flavor, jest-dom, user-event version, cleanup
- [ ] 2. Write a custom render with the app's providers
- [ ] 3. List user-visible behaviors for the component
- [ ] 4. Write tests: render → interact → assert visible outcome
- [ ] 5. Fix warnings (act, missing roles) instead of silencing them
- [ ] 6. Run with lint rules for Testing Library

1. Setup

// test/setup-dom.ts (Vitest) — referenced from test.setupFiles
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => cleanup());   // needed when Vitest globals are off

For Jest: import '@testing-library/jest-dom' in setupFilesAfterEnv; cleanup is automatic. @testing-library/react 16+ requires @testing-library/dom as a peer dependency; install it explicitly.

2. Custom render

// test/render.tsx
import { render, type RenderOptions } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router';

export function renderWithProviders(ui: React.ReactElement, { route = '/', ...opts }: { route?: string } & RenderOptions = {}) {
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  const user = userEvent.setup();
  const result = render(
    <QueryClientProvider client={queryClient}>
      <MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>
    </QueryClientProvider>,
    opts,
  );
  return { user, ...result };
}

A fresh QueryClient per test with retry: false prevents cache bleed and slow retries. Adapt the providers (router, theme, i18n, store) to the project.

3–4. Tests

import { screen, within } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '../test/msw-server';
import { renderWithProviders } from '../test/render';

it('adds an item and shows it in the list', async () => {
  const { user } = renderWithProviders(<TodoPage />);

  await user.type(screen.getByRole('textbox', { name: /new todo/i }), 'Buy milk');
  await user.click(screen.getByRole('button', { name: /add/i }));

  const list = await screen.findByRole('list', { name: /todos/i });
  expect(within(list).getByText('Buy milk')).toBeInTheDocument();   // listitem has no name-from-content
  expect(screen.getByRole('textbox', { name: /new todo/i })).toHaveValue('');
});

it('shows an error when saving fails', async () => {
  server.use(http.post('/api/todos', () => HttpResponse.json({ message: 'nope' }, { status: 500 })));
  const { user } = renderWithProviders(<TodoPage />);

  await user.type(screen.getByRole('textbox', { name: /new todo/i }), 'x');
  await user.click(screen.getByRole('button', { name: /add/i }));

  expect(await screen.findByRole('alert')).toHaveTextContent(/could not save/i);
});

Query variants

SituationQuery
Element must be there nowgetBy* (throws with a helpful DOM dump if missing)
Asserting absenceexpect(screen.queryByRole('alert')).not.toBeInTheDocument()
Element appears after async workawait screen.findBy*
Several matches expectedgetAllBy* / findAllBy*
Waiting for removalawait waitForElementToBeRemoved(() => screen.queryByText(/loading/i))
Non-DOM async conditionawait waitFor(() => expect(onSave).toHaveBeenCalledTimes(1))

Role query options worth knowing: name (string or regex, matches accessible name), level for headings, checked, pressed, expanded, selected, hidden: true for elements hidden from the accessibility tree.

Useful jest-dom matchers

toBeInTheDocument, toBeVisible, toHaveTextContent, toHaveValue, toBeChecked, toBeDisabled / toBeEnabled, toHaveAccessibleName, toHaveAccessibleDescription, toHaveAttribute, toHaveFocus, toBeInvalid, toHaveFormValues.

Hooks

Test hooks through a component that uses them when possible. For reusable hooks with no UI, use renderHook from @testing-library/react:

const { result } = renderHook(() => useCounter(5));
act(() => result.current.increment());
expect(result.current.count).toBe(6);

Async and act()

  • “An update was not wrapped in act(…)” means state changed after the test stopped waiting. Fix by awaiting the user-visible outcome (findBy*) that the update produces. Do not wrap user-event calls or render in extra act(); they already are.
  • waitFor callbacks must be side-effect free and contain an assertion; never put user.click inside waitFor.
  • Do not use waitFor to wait for something findBy can wait for.
  • With fake timers: userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) (or jest.advanceTimersByTime), or interactions hang.
  • Default async timeout is 1000 ms. If real work legitimately takes longer, the test is probably hitting real I/O; stub it.

Debugging queries

screen.debug();                       // pretty-print current DOM
screen.debug(screen.getByRole('form'));
screen.logTestingPlaygroundURL();     // opens a playground suggesting the best query

If getByRole('button', { name: 'Save' }) fails, the error lists every accessible role and name in the DOM. Read it: the button may be named by an icon with no label, which is a real accessibility defect to fix in the component (add aria-label or visible text).

Lint

Enable eslint-plugin-testing-library and eslint-plugin-jest-dom (recommended configs for the framework flavor). They catch container.querySelector, unawaited findBy, waitFor with side effects, and weak matchers like toHaveAttribute('disabled').

Gotchas

  • getByText matches the full text of an element by default; text split across nested elements needs a function matcher or a role query with name.
  • toBeVisible checks CSS visibility; jsdom does not load stylesheets, so classes that hide via external CSS are still “visible”.
  • Portals (modals, toasts) render outside the component container; screen finds them, within(container) does not.
  • Snapshot tests of whole components duplicate the markup and get rubber-stamped. Assert specific behavior instead.
  • Mocking child components (vi.mock('./Chart')) is acceptable for heavy third-party widgets only; mocking your own children tests wiring, not behavior.
  • Test IDs leak into production markup unless stripped; prefer roles.

Verification

  1. Run the new tests, then rerun with shuffle; no act warnings in output.
  2. Change an implementation detail (rename state variable, swap a div for a section with the same role) and confirm tests still pass.
  3. Remove the behavior under test (e.g., comment out the error toast) and confirm the relevant test fails.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc