Introduction
Tables and lists are everywhere in modern web applications—from data grids showing order histories to sortable product catalogs to paginated result sets. Yet testing them reliably can feel like quicksand. This guide covers testing tables and lists: a hands-on guide for engineers, focusing on the strategies that work in production environments where your test suite needs to handle dynamic content, sorting, filtering, and layout changes without flaking. We'll walk through the patterns that senior teams use to assert rows and cells with confidence.
Why table and list testing matters
Tables hold structured data, and users interact with them constantly—sorting columns, filtering rows, selecting items, paginating. A broken table test often means either false positives (tests pass when the UI is broken) or flaky failures (tests fail randomly). The difference between a mediocre table test and a reliable one is precision: knowing exactly what to query, how to filter results, and which assertions catch real bugs without noise.
Most teams start by querying generic selectors like tr or .row, then spend months debugging flaky tests. The better approach uses semantic locators—roles and accessible names—combined with smart filtering, which is what Playwright excels at.
Using getByRole('row') with filter({ hasText })
The foundation of reliable table testing is getByRole('row'). This semantic locator finds all elements with the row ARIA role, which includes native <tr> elements and divs marked up as tables. But a table with 50 rows means 50 matches—you need to narrow it down.
The most powerful filter is hasText, which matches rows containing specific text. Let's say you're testing an e-commerce order table:
// Find a specific order row by order ID
const orderRow = page.getByRole('row', { hasText: 'ORD-12345' });
await expect(orderRow).toBeVisible();This is clean and resilient. If the table reorders, the layout shifts, or columns change, the test still finds the right row because it's matching semantically on content.
For more complex scenarios, chain filters:
// Find a row with order ID AND status
const row = page.getByRole('row').filter({
hasText: 'ORD-12345'
}).filter({
hasText: 'Shipped'
});
await expect(row).toBeVisible();Once you've isolated a row, extract cells from it:
const orderRow = page.getByRole('row', { hasText: 'ORD-12345' });
const statusCell = orderRow.getByRole('cell', { name: 'Shipped' });
await expect(statusCell).toBeVisible();
// Or get the price cell (nth-child approach if no accessible name)
const cells = await orderRow.getByRole('cell').all();
const priceCell = cells[3]; // 0-indexed, 4th column
await expect(priceCell).toHaveText(/\$\d+\.\d{2}/);This pattern scales. You can apply it to nested rows, collapsible sections, or paginated data. The key is starting with a specific row, then drilling down to cells within it.
Using toHaveCount for list sizes
When you need to verify the number of rows or items, toHaveCount is your assertion. It's more reliable than checking length because Playwright waits for the expected count before asserting, handling dynamic content gracefully.
Common scenarios:
// Verify the table has exactly 10 rows (excluding header)
const rows = page.getByRole('row').filter({ hasText: /[A-Z0-9-]+/ });
await expect(rows).toHaveCount(10);But here's the gotcha: are you counting the header row? Native <thead> rows are still row roles. A safer approach:
// Count only body rows
const bodyRows = page.locator('tbody tr');
await expect(bodyRows).toHaveCount(10);
// Or for divs-as-tables, use a more specific selector
const dataRows = page.getByRole('row').locator('xpath=//following-sibling::*[not(self::thead)]');
await expect(dataRows).toHaveCount(10);For filtered or searched lists, verify the count after the action:
await page.getByPlaceholder('Search').fill('high-priority');
await page.waitForLoadState('networkidle');
const filteredRows = page.getByRole('row').filter({ hasText: 'high-priority' });
await expect(filteredRows).toHaveCount(5);
Use toHaveCount(0) to verify empty states:
await page.getByRole('button', { name: 'Clear all' }).click();
const rows = page.getByRole('row');
await expect(rows).toHaveCount(0);Checking sorted order with allTextContents
Sorting is a common table feature. Users click a column header and expect rows to reorder. To test this, extract all text from a column and verify it's sorted alphabetically, numerically, or by date.
The allTextContents() method grabs text from all matched elements in order:
// Click the "Amount" column header to sort descending
await page.getByRole('columnheader', { name: 'Amount' }).click();
await page.waitForLoadState('networkidle');
// Get all amount cells
const amounts = await page.getByRole('cell', { name: /\$\d+/ }).allTextContents();
// Verify descending order
const numeric = amounts.map(a => parseFloat(a.replace(/\$|,/g, '')));
const sorted = [...numeric].sort((a, b) => b - a);
expect(numeric).toEqual(sorted);For text columns (names, titles):
await page.getByRole('columnheader', { name: 'Product' }).click();
await page.waitForLoadState('networkidle');
const names = await page.getByRole('cell').locator('xpath=//td[2]').allTextContents();
const sorted = [...names].sort();
expect(names).toEqual(sorted);For dates, parse them into comparable values:
const dateStrings = await page.locator('td:nth-child(3)').allTextContents();
const dates = dateStrings.map(d => new Date(d).getTime());
const descending = dates.every((d, i, arr) => i === 0 || d <= arr[i - 1]);
expect(descending).toBe(true);This pattern confirms the UI sorts correctly without relying on backend state or API responses—you're testing what the user sees.
Using cell roles for grids
Data grids—especially those built with libraries like Tanstack Table, AG Grid, or Datagrid—often use role="grid" and role="gridcell" instead of native table semantics. The approach is slightly different but just as powerful.
Query the grid and navigate by role:
// Get all gridcells in the first row
const firstRow = page.getByRole('row').first();
const cells = await firstRow.getByRole('gridcell').all();
// Assert each cell
await expect(cells[0]).toHaveText('ORD-001');
await expect(cells[1]).toHaveText('Alice Chen');
await expect(cells[2]).toHaveText('$450.00');For editable grids, locate and interact with cells:
// Find a specific gridcell and click to edit
const cell = page.getByRole('row', { hasText: 'ORD-001' })
.getByRole('gridcell')
.filter({ hasText: 'Pending' });
await cell.click();
await page.getByRole('option', { name: 'Shipped' }).click();
// Verify the update
await expect(cell).toHaveText('Shipped');For large grids with virtual scrolling, fetch cell content carefully—only visible cells exist in the DOM:
// Scroll to a specific row (by value, not index)
const targetRow = page.getByRole('row', { hasText: 'ORD-5000' });
await targetRow.scrollIntoViewIfNeeded();
await page.waitForTimeout(300); // Virtual scroll render time
const cell = targetRow.getByRole('gridcell').first();
await expect(cell).toBeVisible();Common mistakes
Mistake 1: Querying all rows, then indexing. If you write const rows = await page.getByRole('row').all(); rows[2].textContent(), you're brittle to pagination, sorting, and filtering. Always filter to the specific row first.
Mistake 2: Not waiting for dynamic content. Tables load asynchronously. After filtering, sorting, or paginating, call waitForLoadState('networkidle') or wait for a specific row to appear:
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('row', { hasText: 'ORD-11' }).waitFor();Mistake 3: Using innerHTML or complex CSS selectors. They break when styling changes. Stick to roles and accessible names.
Mistake 4: Forgetting header vs. body rows. When you query getByRole('row'), you get everything. If your count is off by one, the header row is the culprit. Use tbody tr or filter specifically.
Mistake 5: Asserting without filtering by context. In a page with multiple tables, always filter rows to the correct table first:
// Wrong: might match rows from any table
const row = page.getByRole('row', { hasText: 'ORD-123' });
// Right: scope to the specific table
const table = page.getByRole('table', { name: 'Orders' });
const row = table.getByRole('row', { hasText: 'ORD-123' });Key takeaways
getByRole('row')withfilter({ hasText })is the semantic foundation—query specific rows, not all rows.- Chain multiple filters to narrow down rows by multiple criteria (order ID AND status).
toHaveCountreliably verifies list and table sizes, handling dynamic content and waits automatically.allTextContents()extracts column values in order, perfect for asserting sorts and filtering outcomes.- For grids with
role="gridcell", use the same row-then-cell pattern; it scales to large, virtualized datasets. - Always filter rows to a specific table or context; scope prevents false positives when multiple tables exist.
- Wait for dynamic content (filters, sorts, pagination) before asserting;
waitForLoadStateor wait for a specific row to appear. - Avoid indexing all rows and CSS selectors; they're fragile to layout and content changes.






Comments
Loading comments…