React Testing Library: Which Queries to Use and Why
Stop second-guessing getByRole vs getByText. Here's exactly which React Testing Library queries to use, when to use them, and why the order matters.
The Query Priority Order Everyone Gets Wrong
Honestly, most React developers pick queries based on whatever worked last time. getByText here, getByTestId there — no real system. That approach works until you refactor a component and watch 40 tests fail because you renamed a CSS class.
React Testing Library v14 ships with a documented priority order for queries, and it's not arbitrary. The order reflects how real users interact with your UI. Accessible roles first, then labels, then text content, then test IDs as a last resort. Following this order means your tests break when behaviour changes, not when implementation details change.
That distinction is everything. Tests should tell you when your app stops doing what users expect — not when you refactored a div into a section.
getByRole: Your Default Starting Point
getByRole is the query you'll reach for most often. It queries by ARIA role, which means it finds elements the same way screen readers do. A <button> has role button. A <h1> has role heading. An <input type='checkbox'> has role checkbox. No configuration needed.
The name option makes it surgical. getByRole('button', { name: /submit/i }) finds exactly the submit button, not every button on the page. The name matches against the accessible name — which could come from the button's text content, an aria-label, or an associated <label> element.
Here's a real example. Say you're testing a dialog component that has a close button and a confirm button:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ConfirmDialog } from './ConfirmDialog';
test('calls onConfirm when confirm button is clicked', async () => {
const user = userEvent.setup();
const onConfirm = jest.fn();
render(
<ConfirmDialog
title="Delete item?"
onConfirm={onConfirm}
onCancel={() => {}}
/>
);
// getByRole finds the button by its accessible role + name
await user.click(screen.getByRole('button', { name: /confirm/i }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});Notice we're using userEvent.setup() from @testing-library/user-event v14 rather than the older userEvent.click() shorthand. The setup pattern handles pointer events properly and is the recommended approach as of v14.5.0.
getByLabelText for Form Inputs
Forms are where a lot of developers reach for getByPlaceholderText out of habit. Don't. Placeholder text is not a label — it disappears when users start typing, and screen readers don't always expose it reliably.
getByLabelText is the right tool for form fields. It finds inputs associated with a <label> element via htmlFor, or via aria-label, or via aria-labelledby. Using this query in your tests actually nudges you toward writing more accessible forms, because if the query can't find your input, neither can a screen reader.
If you're building themed components — say, a glassmorphism-style input with background: rgba(255,255,255,0.08) and a floating label — getByLabelText will still work as long as the label association is correct. The visual style doesn't affect the accessible name calculation. That's a useful property. Your tests stay green across theme variations, which matters when you're working with a multi-style component library like Empire UI's glassmorphism generator.
getByText, getByAltText, and getByTitle
getByText is the third tier. It's for non-interactive content — paragraphs, headings, list items — where there's no role or label to query by. It's also where the exact option becomes useful. getByText('Submit', { exact: false }) matches any element that contains the word "Submit", which is handy when text is split across child elements.
getByAltText is specifically for images. If you have an <img alt='User avatar'>, that's your selector. It's also the right query for SVG elements that expose an accessible name through aria-label. Worth noting: if you're doing heavy image work, the patterns in our image optimisation guide apply here too — accessible alt text and performance go hand in hand.
getByTitle is rarely useful in practice. The title attribute isn't reliably exposed to all assistive technologies, and browsers render it inconsistently. Save it for edge cases.
When getByTestId Is Actually Fine
Everyone acts like getByTestId is cheating. It's not. It's the right tool when no semantic query fits. Some examples: a canvas element rendering a chart, a custom virtualized list where the row DOM structure is implementation-specific, or an animation wrapper that has no meaningful accessible role.
The pattern is straightforward — add data-testid='price-chart' to your element and query it with screen.getByTestId('price-chart'). The attribute doesn't affect rendering, doesn't bloat your bundle, and strips out cleanly in production if you configure Babel to remove data attributes.
What you want to avoid is using getByTestId as your default because it's fast to write. Tests that lean on test IDs don't tell you anything about accessibility, and they create a false sense of coverage. Use getByRole or getByLabelText first. Fall back to getByTestId only when you genuinely can't get there any other way.
Async Queries: findBy vs waitFor
Ever written a test that passes locally and fails in CI? Async timing is usually the culprit. React Testing Library gives you findBy* queries for elements that appear asynchronously — they return promises and automatically retry until the element appears or the timeout (default 1000ms) expires.
findByRole('status') will keep polling the DOM until an element with role='status' appears. That's perfect for toast notifications, loading states that resolve after a fetch, or any UI that updates after an async side effect.
test('shows success message after form submission', async () => {
const user = userEvent.setup();
render(<ContactForm />);
await user.type(
screen.getByLabelText(/email address/i),
'test@example.com'
);
await user.click(screen.getByRole('button', { name: /send message/i }));
// findByRole waits up to 1000ms for this to appear
const successAlert = await screen.findByRole('alert');
expect(successAlert).toHaveTextContent(/message sent/i);
});Use waitFor when you need to assert on something other than element presence — like checking that a function was called, or that an element is no longer in the DOM. waitFor(() => expect(mockFn).toHaveBeenCalled()) retries the assertion callback until it passes or times out. Mixing findBy and waitFor unnecessarily creates race conditions, so pick one approach per assertion.
The queryBy Variant and Testing Absence
Here's the thing: sometimes you need to assert that something is *not* in the DOM. Maybe a tooltip should disappear after the user clicks away, or an error message should clear after valid input. That's what queryBy* is for.
getBy* throws immediately if the element isn't found. queryBy* returns null instead. So expect(screen.queryByRole('alert')).not.toBeInTheDocument() is how you assert absence without crashing the test.
Don't confuse this with findBy* — there's no queryByRole that polls. The queryBy variants are synchronous. If you're checking that something is gone after an async action, use waitFor combined with a queryBy assertion: await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()). That pattern is cleaner than arbitrary setTimeout delays, which you'll see in older test suites and should refactor out when you find them.
Structuring Queries Across a Component Library
If you're building a component library — especially one with multiple visual styles like dark/light themes or different border treatments — consistent query patterns matter more than in a single app. Your component tests become the contract that downstream users rely on.
Write a custom renderWithTheme wrapper that sets up your ThemeProvider and any global context. Then use it in every test file. This gives you a single place to update when your theme setup changes, and means your queries always operate on realistically mounted components. If you're handling theme switching in your components, the patterns in building a theme toggle in React integrate well with this kind of test wrapper approach.
What query patterns work at scale? Prioritize getByRole and getByLabelText across all your component tests. Reserve getByTestId for wrapper components where accessible semantics genuinely aren't available. Run jest --coverage regularly — and pay attention to branch coverage, not just line coverage. A component with 100% line coverage can still have untested conditional renders. That's where bugs hide.
One last thing worth knowing: React Testing Library's screen object is the modern API. The older pattern of destructuring queries from render() — const { getByRole } = render(...) — still works but creates messy test output when queries fail. screen.getByRole gives you a much more readable error message that shows the full rendered DOM. Switch to screen if you haven't already.
FAQ
getByRole queries by ARIA role (like 'button', 'heading', 'textbox'), which maps to how assistive technologies see your UI. getByText matches visible text content. Use getByRole first — it catches accessibility regressions. Fall back to getByText for non-interactive content like paragraphs or headings where you need to match specific copy.
Use findBy when the element appears asynchronously — after a fetch resolves, after a state update from a setTimeout, or after any async side effect. findBy queries return promises and retry automatically up to 1000ms (configurable). getBy is synchronous and throws immediately if the element isn't in the DOM at query time.
Not inherently. data-testid is the right tool when no semantic query fits — canvas elements, custom virtualized lists, animation wrappers without meaningful roles. The problem is using it as a default because it's quick to write. That approach gives you tests that don't catch accessibility issues and break on rename refactors. Prefer getByRole, getByLabelText, and getByText first.
Use queryBy instead of getBy. queryByRole, queryByText, etc. return null when the element isn't found rather than throwing. Then assert with expect(screen.queryByRole('alert')).not.toBeInTheDocument(). If you're checking absence after an async action, wrap it: await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()).
The name option matches against the accessible name, not necessarily the visible text. For buttons, the accessible name usually comes from text content or aria-label. If your button has an icon and no visible text, you need an aria-label. If the button text is 'Submit form' but you're querying for name: /submit/i, it'll match. If it's still failing, call screen.debug() to see the rendered DOM and check what role and name your element actually has.
Add explicit ARIA role and name attributes to your custom components. A custom button built from a div needs role='button', tabIndex={0}, and either visible text or aria-label to be queryable via getByRole. This is the right solution anyway — without these attributes, keyboard and screen reader users can't interact with your component either.