Testing Library patterns for React component testing — queries, user events,
99
99%
Does it follow best practices?
Impact
100%
1.03xAverage score across 8 eval scenarios
Passed
No known issues
Test React components the way users interact with them.
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.
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.
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 instantlyRule 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.
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(); // THROWSQuery variant cheatsheet:
getBy* — element must exist, throws if missing (synchronous)queryBy* — returns null if missing, use for asserting absencefindBy* — waits for element to appear, returns Promise, throws on timeoutwithin() — Scope Queries to a Containerimport { 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").
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 DOMtoBeInTheDocument() — element exists in the DOM (even if hidden)toBeVisible() — element is visible (not display: none, visibility: hidden, opacity: 0, or hidden attribute)// 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();
});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();
});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';getByRole with { name: /text/i } regex pattern, not exact stringsuserEvent.setup() is called before render(), not afteruserEvent action is await-edfireEvent is never used when userEvent can replace itfindBy* is used for elements that appear asynchronouslywaitFor contains only assertions, never side effectsqueryBy* is used for asserting element absence (not getBy*)within() is used to scope queries when the page has duplicate contenttoBeVisible() is used instead of toBeInTheDocument() when visibility matterscontainer.querySelector or container.innerHTML usage