Testing Library Role Queries for Grids and Tables

Permalink to "Testing Library Role Queries for Grids and Tables"

Testing Library’s guiding principle is to query the DOM the way users find things. For assistive technology users, that means by role and accessible name: “the grid named Open invoices”, “the row whose header is INV-1042”, “the button named Delete INV-1042”. A test written with those queries fails when a role goes missing, a name changes, or a header association breaks — which makes every unit test a lightweight accessibility check, for free.

This page shows the query patterns for tables and grids, the matchers for names and states, and how to handle the performance cost of role queries on large tables. It belongs to accessibility testing recipes.

Spec reference

Permalink to "Spec reference"

@testing-library/dom (and its React, Vue and Angular wrappers):

  • getByRole(role, { name, description, selected, checked, pressed, expanded, level }) — matches the ARIA role and computed accessible name (using dom-accessibility-api, which implements the accname algorithm).
  • within(element) — scopes queries to a subtree.
  • Roles for tabular content: table, grid, treegrid, rowgroup, row, columnheader, rowheader, cell, gridcell.

@testing-library/jest-dom matchers: toHaveAccessibleName, toHaveAccessibleDescription, toHaveAttribute('aria-sort', 'ascending'), toHaveFocus, toBeChecked.

Role queries compute the accessibility tree for the queried subtree, which is expensive on large tables in jsdom. Queries by role on a 1,000-row table can take seconds; scope with within and use small fixtures.

From grid to cell, the way AT sees it Layers of a role-based query chain: grid by name, row by row header, cell within the row, and the state or name assertion on it. From grid to cell, the way AT sees itgetByRole('grid', { name })fails if the grid lacks a role or a namegetByRole('rowheader', { name:'INV-1042' })fails if row headers are plain cellswithin(row).getAllByRole('gridcell')fails if cells lack roles or sit outside rowsexpect(…).toHaveAccessibleName(…)fails if names are missing or generic
Each layer is a query a screen reader user effectively performs — and each can fail for an accessibility reason.

When to use role queries — and when not to

Permalink to "When to use role queries — and when not to"

Use them in every component and integration test of data UI: tables, grids, filters, pagination, row actions. They are the default query style, not an accessibility add-on.

Fall back to getByTestId only for elements that genuinely have no role or name — decorative containers you still need to reach for layout assertions. If you reach for test ids to find a button, the button probably lacks a name.

The misapplication to name is rewriting a failing getByRole('button', { name: 'Delete INV-1042' }) as getByTestId('delete-btn') to make the test pass after a refactor. The test was right: the button lost its name.

Annotated code example

Permalink to "Annotated code example"
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { InvoiceGrid } from './InvoiceGrid';
import { smallFixture } from './fixtures';            // 5 rows: role queries stay fast

test('grid structure is exposed correctly', () => {
  render(<InvoiceGrid rows={smallFixture} />);

  // SC 4.1.2: the grid has a role and a name from its heading
  const grid = screen.getByRole('grid', { name: 'Open invoices' });

  // SC 1.3.1: column headers are real headers
  expect(within(grid).getAllByRole('columnheader').map((h) => h.textContent))
    .toEqual(['Invoice', 'Customer', 'Due', 'Amount']);

  // Find a row by its row header, then a cell within it
  const rowHeader = within(grid).getByRole('rowheader', { name: 'INV-1042' });
  const row = rowHeader.closest('[role="row"]');
  const cells = within(row).getAllByRole('gridcell');
  expect(cells[2]).toHaveTextContent('1,280.00');

  // Names of row actions include the record
  expect(within(row).getByRole('button', { name: 'Actions for INV-1042' })).toBeInTheDocument();
});

test('sorting updates aria-sort and announces', async () => {
  const user = userEvent.setup();
  render(<InvoiceGrid rows={smallFixture} />);
  const header = screen.getByRole('columnheader', { name: /Amount/ });
  await user.click(within(header).getByRole('button', { name: 'Amount' }));

  expect(header).toHaveAttribute('aria-sort', 'ascending');
  expect(screen.getByRole('status')).toHaveTextContent('Sorted by Amount, ascending.');
  // Only one header is sorted
  expect(screen.getAllByRole('columnheader').filter((h) => h.hasAttribute('aria-sort')))
    .toHaveLength(1);
});

test('selection state is queryable', async () => {
  const user = userEvent.setup();
  render(<InvoiceGrid rows={smallFixture} selectable />);
  await user.click(screen.getByRole('checkbox', { name: 'Select invoice INV-1042' }));
  expect(screen.getByRole('checkbox', { name: 'Select invoice INV-1042' })).toBeChecked();
  expect(screen.getByRole('checkbox', { name: /Select all/ }))
    .toHaveAttribute('aria-checked', 'mixed');           // indeterminate header
});

getByRole('columnheader', { name: /Amount/ }) matches the header even though its content is a button, because the header’s name is computed from its content. That is the same computation a screen reader performs — if the button were an unnamed icon, the header would have no name, and the query would fail.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"

Role queries model how assistive technology finds things. They do not model how it reads them in sequence, or keyboard behaviour. Combine them with userEvent.keyboard for key assertions:

Question Query / assertion
Is the grid named? getByRole('grid', { name })
Are headers real headers? getAllByRole('columnheader')
Is the sorted column exposed? toHaveAttribute('aria-sort', …)
Is the icon button named with its record? getByRole('button', { name: 'Delete INV-1042' })
Does Enter open the editor? userEvent.keyboard('{Enter}') then getByRole('textbox') toHaveFocus()
Is the status announced? getByRole('status') toHaveTextContent(…)
Test ids versus role queries for a grid Comparison of querying a grid with test ids and class names against querying by role and accessible name. Test ids versus role queries for a grid✗ Test ids and classesgetByTestId('invoice-grid')Passes when the role is removedPasses when buttons lose their namesCoupled to implementation details✓ Roles and namesFails when the role or name breaksFails when headers become plain cellsCoupled to what users experience
Role queries turn structural accessibility regressions into failing unit tests.

Integration context

Permalink to "Integration context"

Role queries and axe scans complement each other: queries assert your expectations (this button has this name), axe checks general rules (every button has some name). Run both in component tests — writing jest-axe tests for data grid components. The same queries power Storybook play functions in Storybook accessibility checks for data components.

Accessible names are computed by the rules in accessible names & descriptions for data widgets; when a name query fails unexpectedly, that computation is where to look.

Suggested fixture size by test type Bar chart of suggested table fixture sizes: a handful of rows for jsdom unit tests, a page of rows for integration tests, and large tables only in real-browser performance tests. Suggested fixture size by test typejsdom unit test5 rowsIntegration test (realbrowser)50 rowsLarge-table performance test1000 rows — real browser only, not jsdom
Role queries compute accessible names for the whole subtree, so keep jsdom fixtures small and test large tables in a real browser.

Gotchas

Permalink to "Gotchas"

jsdom and CSS. jsdom does not apply layout; elements hidden with CSS classes may still be found by role unless hidden with hidden, display: none inline, or aria-hidden. Use { hidden: false } defaults and real hiding mechanisms.

Names from aria-labelledby in portals. If a grid’s labelling heading is rendered elsewhere, render the full composition in the test, or the name query fails.

Performance. getByRole on large subtrees is slow. Scope with within, use small fixtures, and prefer one getAllByRole over many individual queries in loops.

Design system notes

Permalink to "Design system notes"

Design-system components should ship tests written entirely with role queries, and document the roles and names consumers can query (“the grid is named by its title prop; row action buttons are named Actions for {rowLabel}”). That documentation is also the component’s accessibility contract.

Testing checklist

Permalink to "Testing checklist"

FAQ

Permalink to "FAQ"
Why use getByRole to test data tables?

Because it finds elements the way assistive technology does, by role and accessible name. If a grid loses its role, a header becomes a plain cell or a button loses its name, the query fails — turning a unit test into an accessibility check.

How do I find a specific table row with Testing Library?

Find the row header by role and name, for example getByRole(‘rowheader’, { name: ‘INV-1042’ }), take its closest row element, and query cells within that row.

Why are role queries slow on large tables?

They compute accessible roles and names for the subtree, which is expensive in jsdom. Use small fixtures in unit tests and scope queries with within.

What should I do when a role query breaks after a refactor?

Check whether the refactor removed a role or changed a name. Usually it did, and the fix belongs in the component, not in a switch to test ids.

Permalink to "Related"

← Back to Accessibility Testing Recipes