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 (usingdom-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.
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(…) |
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.
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.
Related
Permalink to "Related"- jest-axe for grid components — scans alongside queries
- Storybook accessibility checks — queries in play functions
- Names & descriptions — what name queries depend on