← All practice topics
Automation Practice · Playwright

Web Tables

Find a row by its text, read a cell, add up a column, sort, page through results and click buttons inside a specific row.

Every solution on this page starts with this hook. With baseURL set to https://hiraanachwani.com in your playwright.config.ts, it opens this page before each test.

test.beforeEach(async ({ page }) => {
  await page.goto('/practice/tables');
});

Static table

A plain table of employees. Nothing moves; it's all about locating the right cell.

Employees
NameDepartmentCitySalary
Priya SharmaQAPune$85,000
James CarterDevelopmentLondon$98,000
Ravi PatelQABengaluru$72,000
Emma SchmidtDevOpsBerlin$91,000
Kenji SatoDevelopmentTokyo$105,000
Olivia BrownQAToronto$78,000

Try this

  1. Check the table has 6 employees.
  2. Find Ravi Patel's city without hard-coding his row number.
  3. List everyone in the QA department.
  4. Add up the Salary column and check the total is $529,000.

Locator tip: getByRole('row', { name: /Ravi Patel/ }) finds a row by any text in it, so the test still works when rows are added or reordered. Then getByRole('cell').nth(2) picks the third cell in that row.

Show Playwright solution
test('static table', async ({ page }) => {
  const table = page.getByRole('table', { name: 'Employees' });
  const rows = table.locator('tbody').getByRole('row');
  await expect(rows).toHaveCount(6);

  // Find a row by its text, then a cell by position within that row
  const ravi = table.getByRole('row', { name: /Ravi Patel/ });
  await expect(ravi.getByRole('cell').nth(2)).toHaveText('Bengaluru');

  // Keep only the rows that have a cell reading exactly "QA"
  const qaRows = rows.filter({ has: page.getByRole('cell', { name: 'QA', exact: true }) });
  await expect(qaRows.locator('td:first-child')).toHaveText(['Priya Sharma', 'Ravi Patel', 'Olivia Brown']);

  // Read a whole column and add it up
  const salaries = await rows.locator('td:nth-child(4)').allTextContents();
  const total = salaries
    .map((salary) => Number(salary.replace(/[^0-9]/g, '')))
    .reduce((sum, salary) => sum + salary, 0);
  expect(total).toBe(529000);
});

Sortable table

Click a column heading to sort by it. Click again to reverse the order.

Leaderboard
Zara AliPakistan88
Ben OkaforNigeria95
Chloe MartinFrance72
Arjun NairIndia91
Lucas SilvaBrazil64

Try this

  1. Sort by Score and check the scores go from lowest to highest.
  2. Click Score again and check the top player is Ben Okafor.
  3. Check the Score heading reports its sort direction.

Locator tip: read a whole column with allTextContents(), then compare it with a sorted copy in plain TypeScript. Sortable headings set aria-sort to ascending or descending, which you can assert directly.

Show Playwright solution
test('sortable table', async ({ page }) => {
  const table = page.getByRole('table', { name: 'Leaderboard' });
  const scoreHeading = table.getByRole('columnheader', { name: 'Score' });
  const scoreCells = table.locator('tbody td:nth-child(3)');

  await scoreHeading.getByRole('button').click();
  await expect(scoreHeading).toHaveAttribute('aria-sort', 'ascending');
  const scores = (await scoreCells.allTextContents()).map(Number);
  expect(scores).toEqual([...scores].sort((a, b) => a - b));

  await scoreHeading.getByRole('button').click();
  await expect(scoreHeading).toHaveAttribute('aria-sort', 'descending');
  await expect(table.locator('tbody tr').first().getByRole('cell').first()).toHaveText('Ben Okafor');
});

Paginated table

23 orders, shown 5 at a time. The order you want may be on any page.

Orders
OrderCustomerStatusAmount
ORD-1001Aarav MehtaDelivered$25.00
ORD-1002Fatima KhanCancelled$62.13
ORD-1003Noah WilliamsProcessing$99.26
ORD-1004Sophie TurnerShipped$136.39
ORD-1005Diego AlvarezDelivered$173.52
ORD-1006Ananya IyerDelivered$30.65
ORD-1007Liam ChenProcessing$67.78
ORD-1008Mei TanakaShipped$104.91
ORD-1009Aarav MehtaDelivered$141.04
ORD-1010Fatima KhanCancelled$178.17
ORD-1011Noah WilliamsDelivered$35.30
ORD-1012Sophie TurnerShipped$72.43
ORD-1013Diego AlvarezDelivered$109.56
ORD-1014Ananya IyerCancelled$146.69
ORD-1015Liam ChenProcessing$183.82
ORD-1016Mei TanakaDelivered$40.95
ORD-1017Aarav MehtaDelivered$77.08
ORD-1018Fatima KhanCancelled$114.21
ORD-1019Noah WilliamsProcessing$151.34
ORD-1020Sophie TurnerShipped$188.47
ORD-1021Diego AlvarezDelivered$45.60
ORD-1022Ananya IyerCancelled$82.73
ORD-1023Liam ChenProcessing$119.86

Try this

  1. Check Previous is disabled on page 1.
  2. Page through until order ORD-1017 is visible, then read its customer.
  3. Go to the last page and check it has 3 orders and Next is disabled.

Locator tip: rows on other pages are hidden, and role locators skip hidden elements. So "click Next until the row is visible" is a simple loop. Give it a limit so a missing row can't loop forever.

Show Playwright solution
test('paginated table', async ({ page }) => {
  const table = page.getByRole('table', { name: 'Orders' });
  const previous = page.getByRole('button', { name: 'Previous' });
  const next = page.getByRole('button', { name: 'Next' });
  await expect(previous).toBeDisabled();

  // Click Next until the order shows up, at most 10 times
  const order = table.getByRole('row', { name: /ORD-1017/ });
  for (let i = 0; i < 10 && !(await order.isVisible()); i++) {
    await next.click();
  }
  await expect(order.getByRole('cell').nth(1)).toHaveText('Aarav Mehta');
  await expect(page.getByTestId('page-status')).toHaveText('Page 4 of 5');

  await next.click();
  await expect(next).toBeDisabled();
  await expect(table.locator('tbody').getByRole('row')).toHaveCount(3);
});

Row actions

Every row has the same two buttons. The challenge is clicking the one in the right row.

Tasks
TaskStatusActions
Write test planTo do
Fix login bugIn progress
Review pull requestTo do
Update release notesDone
1 of 4 tasks done

Try this

  1. Mark Write test plan as done and check its status changes to Done.
  2. Delete Fix login bug and check the table has 3 tasks left.
  3. Check the summary says 2 of 3 tasks done.

Locator tip: there are four Delete buttons, so getByRole('button', { name: 'Delete' }) alone would fail. Find the row first, then the button inside it: row.getByRole('button', { name: 'Delete' }).

Show Playwright solution
test('row actions', async ({ page }) => {
  const table = page.getByRole('table', { name: 'Tasks' });

  const testPlan = table.getByRole('row', { name: /Write test plan/ });
  await testPlan.getByRole('button', { name: 'Mark done' }).click();
  await expect(testPlan.getByRole('cell').nth(1)).toHaveText('Done');

  await table.getByRole('row', { name: /Fix login bug/ }).getByRole('button', { name: 'Delete' }).click();
  await expect(table.locator('tbody').getByRole('row')).toHaveCount(3);
  await expect(page.getByTestId('tasks-summary')).toHaveText('2 of 3 tasks done');
});