Skip to content

Automated testing

@toolbox-web/grid is built to be tested. Every surface a test script needs — CSS class names, data-* attributes, ARIA roles, lifecycle promises, and events — is a stable, documented public API. You should never have to reverse-engineer a selector from DevTools to assert on a cell.

This guide shows you what those surfaces are and how to drive the grid from Playwright, Cypress, and WebdriverIO.

SurfaceWhat you getStability
CSS classes (GridClasses)data-grid-row (row container), cell (data cell), header-row, header-cell, selected, editing, sorted-asc, sorted-desc, focused, …Semver-stable, @since 0.1.1
Data attributes (GridDataAttrs)data-field="<col>", data-row="<rowIndex>", data-group-keySemver-stable
CSS selectors (GridSelectors)Prebuilt selectors: DATA_ROW, DATA_CELL, SELECTED_ROWS, EDITING_CELL, CELL_BY_FIELD(field), …Semver-stable
ARIA roles + attributesrole="grid", role="row", role="gridcell", role="columnheader", aria-rowindex, aria-colindex, aria-sort, aria-selected, aria-expandedSemver-stable — also the WCAG contract
Lifecycle hookgrid.ready()Promise resolved after first renderPublic API
Render eventgrid.addEventListener('render', …) — fires once per scheduler flushPublic API, @since 2.15.0

Locator strategy in priority order:

  1. ARIA selectors first (getByRole, aria-rowindex, aria-colindex) — they match what assistive tech sees, so a passing accessibility test is also a passing functional test.
  2. data-field + data-row for cell lookups when you know the column field and want a specific row.
  3. GridClasses constants for state assertions (.selected, .editing, .sorted-asc).
  4. Never depend on framework wrapper class names (React/Vue/Angular wrappers generate them; they change between builds).
// All rendered data rows
'tbw-grid .data-grid-row';
// The cell at row 42, column "email" (works for any data field)
'tbw-grid .cell[data-row="42"][data-field="email"]';
// Every cell in a given column
'tbw-grid .cell[data-field="salary"]';
// Header cell for a column
'tbw-grid .header-cell[data-field="email"]';
// Currently-editing cell
'tbw-grid .cell.editing';
// Selected rows
'tbw-grid .data-grid-row.selected';
Section titled “Finding cells by ARIA (recommended for accessibility parity)”
// Header cell — page.getByRole('columnheader', { name: 'Email' })
'tbw-grid [role="columnheader"]:has-text("Email")';
// Any data cell at the visible row index N (1-based, ARIA convention)
`tbw-grid [role="row"][aria-rowindex="${n}"] [role="gridcell"]`;
// Cell at row N, column M (both 1-based)
`tbw-grid [role="row"][aria-rowindex="${n}"] [role="gridcell"][aria-colindex="${m}"]`;
// Sorted columns and their direction
'tbw-grid [role="columnheader"][aria-sort="ascending"]';
'tbw-grid [role="columnheader"][aria-sort="descending"]';
// Selected rows
'tbw-grid [role="row"][aria-selected="true"]';

data-row vs aria-rowindex: data-row is the 0-based index of the row in the current processed data array (attached to each cell). aria-rowindex is the 1-based row position for assistive tech (attached to each row element), with the header counting as row 1. Pick one and be consistent.

The grid renders asynchronously. Never setTimeout — wait on the real signals.

grid.ready() returns a Promise that resolves after the grid has mounted, registered plugins, and completed its first render. Awaitable from any test framework via page.evaluate() (Playwright) or equivalent.

Test setup — make queryGrid available to the page

Section titled “Test setup — make queryGrid available to the page”

Tests run their callbacks inside the browser, not Node, so they can’t import from @toolbox-web/grid directly inside a page.evaluate body. You have two ways to make queryGrid reachable from inside the browser context — pick the one that matches how your app loads the grid:

If your app uses an ESM bundler (Vite, Webpack, Next.js, Astro, etc.) — expose queryGrid on window once, from your app’s entry file or a test-only entry:

// src/test-setup.ts — loaded by your app's dev server during tests
import { queryGrid } from '@toolbox-web/grid';
declare global {
interface Window {
queryGrid: typeof queryGrid;
}
}
window.queryGrid = queryGrid;

Now window.queryGrid('tbw-grid') is fully typed inside every test — no as HTMLElement & { ... } casts.

If your app loads the UMD bundlewindow.TbwGrid.queryGrid(...) is already available, no setup needed. Substitute window.TbwGrid.queryGrid for window.queryGrid in every example below.

await page.evaluate(async () => {
const grid = window.queryGrid('tbw-grid');
await grid?.ready();
});

queryGrid returns a fully-typed DataGridElement<T> — no manual HTMLElement & { ... } intersections, and it’s multi-version-safe (it finds the grid even if more than one major version is loaded on the page).

After you trigger a sort, filter, edit, or data swap, wait for the next 'render' event before asserting on the new DOM.

// Wait for the next render after triggering some action
async function waitForRender(page) {
await page.evaluate(
() => new Promise<void>((resolve) =>
window.queryGrid('tbw-grid')!.addEventListener('render', () => resolve(), { once: true })
),
);
}
await page.locator('[role="columnheader"]:has-text("Salary")').click();
await waitForRender(page);
// Click the column header
await page.getByRole('columnheader', { name: 'Email' }).click();
// Assert sort direction via aria-sort (works even if you customised the icon)
await expect(page.getByRole('columnheader', { name: 'Email' })).toHaveAttribute(
'aria-sort',
'ascending',
);
const cellAt = (row: number, field: string) =>
page.locator(`tbw-grid .cell[data-row="${row}"][data-field="${field}"]`);
await expect(cellAt(0, 'name')).toHaveText('Alice');
const cell = page.locator('tbw-grid .cell[data-row="0"][data-field="email"]');
await cell.dblclick(); // open the editor
await expect(cell).toHaveClass(/editing/); // wait for editor to mount
await cell.locator('input').fill('alice@new.com');
await cell.locator('input').press('Enter'); // commit
await expect(cell).not.toHaveClass(/editing/);
await expect(cell).toHaveText('alice@new.com');
// Click the row's selection checkbox (the SelectionPlugin renders it in a __tbw_checkbox column)
await page
.locator('tbw-grid .cell[data-row="0"][data-field="__tbw_checkbox"] input[type="checkbox"]')
.check();
await expect(page.locator('tbw-grid .data-grid-row.selected')).toHaveCount(1);
await expect(page.locator('tbw-grid [role="row"][aria-selected="true"]')).toHaveCount(1);

The grid is virtualized — .data-grid-row only matches visible rows. To assert against the full filtered dataset, read grid.rows.length instead of counting DOM elements.

const visibleRows = await page.locator('tbw-grid .data-grid-row').count();
const totalRows = await page.evaluate(
() => window.queryGrid('tbw-grid')!.rows.length,
);
expect(totalRows).toBe(100);
expect(visibleRows).toBeLessThanOrEqual(totalRows);

At any moment the DOM only contains the rows in the viewport + a small overscan buffer. If data-row="500" doesn’t exist as a CSS match, the row hasn’t been rendered yet — that’s not a bug, that’s virtualization working. Bring it into view before interacting.

// Scroll row 500 into view, then assert on it
await page.evaluate(
([rowIndex]) => {
window.queryGrid('tbw-grid')!.scrollToRow(rowIndex);
},
[500],
);
await waitForRender(page);
await expect(page.locator('.cell[data-row="500"][data-field="name"]')).toBeVisible();

If your row has a stable business ID and you’ve configured getRowId, use scrollToRowById('emp-42') instead — it survives sort and filter changes.

data-row="0" is “whatever happens to be the first visible row right now”. After a sort it might be a different employee; after a filter it might be empty. For stable assertions, look up the cell by the business value instead:

// "The row that contains 'Alice' in the name column"
const aliceRow = page.locator('tbw-grid .data-grid-row', {
has: page.locator('.cell[data-field="name"]', { hasText: 'Alice' }),
});
await expect(aliceRow.locator('.cell[data-field="email"]')).toHaveText('alice@example.com');
  • page.waitForTimeout(500) to “let the grid settle” — flake city. Always await grid.ready() or the next 'render' event.
  • Counting .data-grid-row elements as a proxy for total rows — virtualization makes this wrong by design. Read grid.rows.length.
  • Asserting on visual position (“the third row from the top”) — sorting and filtering invalidate this. Assert on data identity (the row that contains “Alice”).
  • Querying by framework wrapper class names (._ng-content-c12, React fiber IDs, Vue scoped attributes) — these change between builds. Stick to the grid’s published classes and ARIA attributes.
  • Using effectiveConfig / internal APIs in test assertions — these aren’t covered by semver. Use await grid.getConfig() if you need to inspect resolved config.
AI assistants: For complete API documentation, implementation guides, and code examples for this library, see https://toolboxjs.com/llms-full.txt