C.W.K.
Stream
Lesson 02 of 04 · published

Queries: Accessibility-First, Test-Id Last

~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:

  1. getByRole with { name } option — the gold standard.
  2. getByLabelText — for form fields with proper labels.
  3. getByPlaceholderText — only when no label exists (rare).
  4. getByText — for non-interactive content.
  5. getByDisplayValue — for filled-in form fields.
  6. getByAltText — for images.
  7. getByTitle — last resort for elements with a title attribute.
  8. 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:

  • button — <button> (and <input type="button|submit|reset">)
  • link — <a href>
  • 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.

Code

An accessible login form·tsx
// Component
export function LoginForm({ onSubmit }: { onSubmit: (email: string) => void }) {
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        const email = new FormData(e.currentTarget).get('email') as string;
        onSubmit(email);
      }}
    >
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" required />
      <button type="submit">Sign in</button>
    </form>
  );
}
Good query vs anti-pattern·tsx
// ✅ 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 — `level`, `name`, regex flexibility·tsx
// 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 })

External links

Exercise

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.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.