Tables are one of the densest UI patterns to automate well — rows repeat the same structure, columns are sortable, and content changes with search, filters, and pagination. This page walks through locating rows/cells reliably and asserting behavior across all three frameworks.
For a fixed table, use role-based locators (table/columnheader/row/cell) plus data-testid when a cell needs to be uniquely identified by row and column.
// Playwright — locate a cell by row and column testid
const table = page.getByTestId('static-table');
await expect(table).toBeVisible();
const cell = page.getByTestId('cell-1-price');
await expect(cell).toHaveText('$1,299');Sortable headers usually cycle through three states: ascending, descending, and unsorted. Assert the aria-sort attribute rather than relying on visual arrows, and verify actual row order after each click.
// Playwright — 3-click sort cycle: asc -> desc -> unsorted
const header = page.getByTestId('sort-th-name');
await header.click();
await expect(header).toHaveAttribute('aria-sort', 'ascending');
await header.click();
await expect(header).toHaveAttribute('aria-sort', 'descending');
await header.click();
await expect(header).toHaveAttribute('aria-sort', 'none');Search and dropdown filters often combine with AND logic. Test them independently first, then together, and always cover the empty-results state.
// Playwright — search + department filter (AND logic)
await page.getByTestId('search-input').fill('Priya');
await page.getByTestId('dept-filter').selectOption('Engineering');
await expect(page.getByTestId('search-result-count')).toContainText('Showing');Assert the exact row count per page, the disabled state of Prev/Next at the boundaries, and that the info text (e.g. 'Showing 1–5 of 15') updates after navigation.
// Playwright — pagination boundaries
await expect(page.getByTestId('pag-info')).toHaveText('Showing 1–5 of 15');
await expect(page.getByTestId('pag-prev')).toBeDisabled();
await page.getByTestId('pag-btn-3').click();
await expect(page.getByTestId('pag-next')).toBeDisabled();Edit/Delete buttons repeat per row, so every locator must be scoped to that row's unique data-testid or id — never rely on a global button selector.
// Playwright — scoped edit / save
await page.getByTestId('edit-btn-1').click();
await page.getByTestId('edit-name-1').fill('New Name');
await page.getByTestId('save-btn-1').click();
await expect(page.getByTestId('actions-name-1')).toHaveText('New Name');When search, sort, and pagination combine, changing any one of them typically resets the page back to 1. Assert that reset explicitly instead of assuming it.
// Playwright — sort resets the combined grid to page 1
await page.getByTestId('grid-search').fill('a');
await page.getByTestId('grid-btn-2').click(); // go to page 2
await page.getByTestId('grid-th-name').click(); // sort
await expect(page.getByTestId('grid-btn-1')).toHaveAttribute('aria-current', 'page');Quick reference across all three frameworks.
| Action | Selenium | Playwright JS | Playwright PY | Cypress |
|---|---|---|---|---|
| Locate a cell by row + column | findElement(By.css("[data-testid='cell-1-price']")) | getByTestId('cell-1-price') | get_by_test_id('cell-1-price') | get('[data-testid="cell-1-price"]') |
| Assert sort state | getAttribute("aria-sort") | toHaveAttribute('aria-sort', 'ascending') | to_have_attribute('aria-sort', 'ascending') | should('have.attr', 'aria-sort', 'ascending') |
| Select a dropdown filter | new Select(el).selectByVisibleText(...) | selectOption('Engineering') | select_option('Engineering') | .select('Engineering') |
| Assert disabled pagination button | !element.isEnabled() | expect(locator).toBeDisabled() | expect(locator).to_be_disabled() | .should('be.disabled') |
| Scope a row action button | findElement(By.css("[data-testid='edit-btn-1']")) | getByTestId('edit-btn-1') | get_by_test_id('edit-btn-1') | get('[data-testid="edit-btn-1"]') |