~18 min · vitest-components, queries, accessibility, rtl
Level 0Test Curious
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Query by what users see. The accessibility tree is the user interface."
The Priority List (and Why)
RTL's official query priority — the order in which you should try queries before falling back to the next:
getByRole with { name } option — the gold standard.
getByLabelText — for form fields with proper labels.
getByPlaceholderText — only when no label exists (rare).
getByText — for non-interactive content.
getByDisplayValue — for filled-in form fields.
getByAltText — for images.
getByTitle — last resort for elements with a title attribute.
getByTestId — escape hatch when nothing else identifies the element.
The reason this list is in this order: the top queries mirror how assistive tech (screen readers, voice control) finds elements, which is also how humans scan a page. A test that uses these queries is implicitly testing that the page is usable. A test that reaches for getByTestId can pass while the page is unusable to screen readers.
getByRole + name Is the Workhorse
Most interactive elements have an implicit ARIA role: <button> is button, <h2> is heading, <a href> is link, <input type="checkbox"> is checkbox. getByRole('button', { name: /save/i }) finds the button whose accessible name matches /save/i — case-insensitive regex matching for resilience.
The name option is computed from (in order): aria-label, the content of aria-labelledby, the element's text content, the value of certain attributes (alt text for images, title for some elements). When you query by name, you're querying the same string a screen reader would announce.
If you can't query by role, the component might not be accessible. A clickable <div onClick> has no role and no name — screen readers can't find it, and neither can your tests. Fix the component (use <button>), don't reach for getByTestId as a workaround.
The Role Cheat Sheet You'll Memorize
You don't need to memorize all 70+ ARIA roles. The dozen below cover 95% of cases:
heading — <h1> through <h6> (filter by { level: 2 } if needed)
textbox — <input type="text|email|..."> and <textarea>
checkbox, radio, combobox (select), option
list — <ul>, <ol>
listitem — <li>
img — <img alt> (without alt, it has no role)
navigation — <nav>
main — <main>
article — <article>
dialog — <dialog> or modal with role="dialog"
When getByText Earns Its Keep
getByText is right for non-interactive content — body copy, status messages, error text. "The form shows 'Email is required' when submitted empty" is naturally screen.getByText(/email is required/i). Don't use it for interactive elements just because the text matches; getByRole('button', { name: /submit/i }) still wins for the submit button.
// ✅ Good — queries mirror how users find elements
import { render, screen } from '@testing-library/react';
import { LoginForm } from './login-form';
it('renders the form with the right controls', () => {
render(<LoginForm onSubmit={() => {}} />);
expect(screen.getByRole('textbox', { name: /email/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
});
// ❌ Anti-pattern — using test ids when role + name would work
it('renders the form (the brittle way)', () => {
// Now the test depends on data-testid attributes the user can't see.
// If a designer removes the data-testid, the test breaks but the UI works.
render(<LoginForm onSubmit={() => {}} />);
expect(screen.getByTestId('email-input')).toBeInTheDocument();
expect(screen.getByTestId('submit-button')).toBeInTheDocument();
});
// Refining queries with options
import { render, screen } from '@testing-library/react';
it('finds the right heading level', () => {
render(<ArticleWithMultipleHeadings />);
// Match the h2 specifically, not the h1 or h3
const sectionHeading = screen.getByRole('heading', {
name: /related articles/i,
level: 2,
});
expect(sectionHeading).toBeInTheDocument();
});
it('handles multiple buttons with disambiguation', () => {
render(<DeleteConfirmDialog />);
// The dialog has two buttons; the `name` option disambiguates.
expect(
screen.getByRole('button', { name: /confirm delete/i })
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /cancel/i })
).toBeInTheDocument();
});
test-id — the honest escape hatch·tsx
// When test-id is genuinely the right answer
// (rare — usually for components that have no other accessible identifier)
import { render, screen } from '@testing-library/react';
it('finds a chart container', () => {
render(<RevenueChart data={...} />);
// Charts often render as <svg> with no inherent role or accessible name.
// A test-id is honest here: there is no user-visible name to query by.
expect(screen.getByTestId('revenue-chart')).toBeInTheDocument();
});
// Better, if you can add accessibility metadata:
// <svg role="img" aria-label="Revenue chart"> → getByRole('img', { name: /revenue chart/i })
Take the login form from the code block above. Add (1) an error message that appears when submit happens with an empty email, (2) a 'Remember me' checkbox, (3) a 'Forgot password?' link. Then write tests that query each: heading (role: heading), email input (role: textbox, name), submit button (role: button, name), error text (getByText), checkbox (role: checkbox, name), forgot link (role: link, name). Resist getByTestId for any of them.
Hint
If you can't find a query that doesn't need a test-id, the component is probably missing accessibility metadata (a label, an aria-label, alt text). Fix the component first; the test will follow naturally.
Progress
Progress is local-only — sign in to sync across devices.