CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/testing-library-patterns

Testing Library patterns for React component testing — queries, user events,

99

1.03x
Quality

99%

Does it follow best practices?

Impact

100%

1.03x

Average score across 8 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

SKILL.mdskills/testing-library-patterns/

name:
testing-library-patterns
description:
Testing Library patterns for React component testing — queries, user events, async assertions, and accessibility-first testing. Use when testing React components, when writing component tests that avoid implementation details, or when migrating from Enzyme.
keywords:
testing library, react testing library, render, screen, user event, getByRole, findByText, waitFor, component testing, accessibility testing, rtl, jest dom, vitest testing library
license:
MIT

Testing Library Patterns

Test React components the way users interact with them.


1. Query Priority — Always Start with getByRole

// RIGHT — accessible role-based queries with regex matchers
screen.getByRole('button', { name: /submit order/i });
screen.getByRole('heading', { level: 2, name: /checkout/i });
screen.getByRole('textbox', { name: /email address/i });
screen.getByRole('checkbox', { name: /agree to terms/i });
screen.getByLabelText(/customer name/i);

// WRONG — brittle implementation-detail queries
container.querySelector('.btn-submit');
container.querySelector('#email-input');
screen.getByTestId('submit-button');  // testId is last resort only
wrapper.find('SubmitButton');

Always use case-insensitive regex (/submit/i) instead of exact strings ('Submit') for name options. This avoids breakage when copy changes capitalization or whitespace.

Priority order: getByRole > getByLabelText > getByPlaceholderText > getByText > getByTestId.


2. userEvent.setup() — Always Before Render, Always Await

// RIGHT — setup() called before render, every action awaited
import userEvent from '@testing-library/user-event';

test('submits form data', async () => {
  const user = userEvent.setup();
  const onSubmit = vi.fn();

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

  await user.type(screen.getByRole('textbox', { name: /name/i }), 'Jane');
  await user.click(screen.getByRole('button', { name: /submit/i }));

  expect(onSubmit).toHaveBeenCalledWith(
    expect.objectContaining({ name: 'Jane' })
  );
});

// WRONG — fireEvent does not simulate real browser behavior
import { fireEvent } from '@testing-library/react';

test('submits form', () => {
  render(<CheckoutForm onSubmit={onSubmit} />);
  fireEvent.change(input, { target: { value: 'Jane' } });  // no keydown/keyup
  fireEvent.click(button);  // no pointerdown/pointerup/focus
});

// WRONG — setup() after render causes timing issues
test('bad order', async () => {
  render(<MyComponent />);
  const user = userEvent.setup();  // too late — should be before render
  await user.click(button);
});

userEvent fires the full browser event sequence (pointerdown, mousedown, focus, pointerup, mouseup, click). fireEvent fires only the single named event.


3. Async Patterns — findBy* vs waitFor

// RIGHT — findBy* for waiting for an element to appear (returns promise)
const heading = await screen.findByRole('heading', { name: /products/i });
expect(heading).toBeInTheDocument();

// RIGHT — waitFor when you need to assert a condition repeatedly
await waitFor(() => {
  expect(screen.getAllByRole('listitem')).toHaveLength(5);
});

// WRONG — waitFor wrapping a side effect instead of an assertion
await waitFor(() => {
  user.click(button);  // NEVER put side effects in waitFor
});

// WRONG — getBy* does not wait; test fails if element not yet rendered
const heading = screen.getByRole('heading', { name: /products/i }); // throws instantly

Rule of thumb: Use findBy* when waiting for a single element. Use waitFor when asserting a condition that may take multiple renders (e.g., list length, text change). Never put side effects inside waitFor.


4. Asserting Absence — Use queryBy*, Not getBy*

// RIGHT — queryBy* returns null when not found
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();

// WRONG — getBy* throws if not found, so this never reaches the expect
expect(screen.getByText(/loading/i)).not.toBeInTheDocument(); // THROWS

Query variant cheatsheet:

  • getBy* — element must exist, throws if missing (synchronous)
  • queryBy* — returns null if missing, use for asserting absence
  • findBy* — waits for element to appear, returns Promise, throws on timeout

5. within() — Scope Queries to a Container

import { within } from '@testing-library/react';

// RIGHT — scope queries to a specific section
const nav = screen.getByRole('navigation');
const homeLink = within(nav).getByRole('link', { name: /home/i });

// RIGHT — test specific list items
const rows = screen.getAllByRole('row');
expect(within(rows[1]).getByRole('cell', { name: /espresso/i })).toBeInTheDocument();
expect(within(rows[1]).getByRole('cell', { name: /\$4\.50/i })).toBeInTheDocument();

// WRONG — ambiguous screen-level query when page has multiple matching elements
const link = screen.getByRole('link', { name: /home/i }); // which "Home" link?

Use within() when the page has duplicate text or roles in different sections (e.g., nav and footer both have "Home").


6. toBeVisible() vs toBeInTheDocument()

// RIGHT — toBeVisible checks the element is not hidden by CSS
expect(screen.getByRole('dialog')).toBeVisible();

// WRONG — element exists in DOM but may be hidden with display:none or aria-hidden
expect(screen.getByRole('dialog')).toBeInTheDocument();  // passes even if hidden!

// Use toBeInTheDocument only when checking presence regardless of visibility
expect(screen.queryByRole('alert')).not.toBeInTheDocument(); // truly removed from DOM
  • toBeInTheDocument() — element exists in the DOM (even if hidden)
  • toBeVisible() — element is visible (not display: none, visibility: hidden, opacity: 0, or hidden attribute)

7. Custom Render Wrapper for Providers

// test-utils.tsx — wrap components that need providers
import { render, RenderOptions } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { ThemeProvider } from './ThemeContext';

function AllProviders({ children }: { children: React.ReactNode }) {
  return (
    <BrowserRouter>
      <ThemeProvider>
        {children}
      </ThemeProvider>
    </BrowserRouter>
  );
}

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

export * from '@testing-library/react';
export { customRender as render };
// In test files — import from test-utils instead of @testing-library/react
import { render, screen } from '../test-utils';

test('renders page with routing', () => {
  render(<ProfilePage />);  // automatically wrapped with providers
  expect(screen.getByRole('heading', { name: /profile/i })).toBeInTheDocument();
});

8. Testing Loading and Error States

test('shows loading, then data, then loading is gone', async () => {
  render(<ProductsPage />);

  // 1. Loading state visible
  expect(screen.getByText(/loading/i)).toBeInTheDocument();

  // 2. Wait for data to appear
  expect(await screen.findByRole('heading', { name: /products/i })).toBeInTheDocument();

  // 3. Loading state gone (use queryBy!)
  expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});

test('shows error on API failure', async () => {
  server.use(
    http.get('/api/products', () => HttpResponse.json(null, { status: 500 }))
  );

  render(<ProductsPage />);

  const alert = await screen.findByRole('alert');
  expect(alert).toHaveTextContent(/something went wrong/i);
  expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();
});

Setup

npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event
// test-setup.ts (imported via setupFilesAfterSetup in jest/vitest config)
import '@testing-library/jest-dom';

Checklist

  • Queries use getByRole with { name: /text/i } regex pattern, not exact strings
  • userEvent.setup() is called before render(), not after
  • Every userEvent action is await-ed
  • fireEvent is never used when userEvent can replace it
  • findBy* is used for elements that appear asynchronously
  • waitFor contains only assertions, never side effects
  • queryBy* is used for asserting element absence (not getBy*)
  • within() is used to scope queries when the page has duplicate content
  • toBeVisible() is used instead of toBeInTheDocument() when visibility matters
  • Custom render wrapper provides necessary context providers (Router, Theme, etc.)
  • Loading and error states are both tested
  • No implementation-detail testing (state, props, class names, component names)
  • No container.querySelector or container.innerHTML usage

References

  • Testing Library Queries
  • Query cheatsheet
  • userEvent docs
  • Common mistakes
  • Which query to use

Verifiers

  • accessible-queries — Testing Library best practices checklist

skills

testing-library-patterns

tile.json