A table test that fails after a sort is annoying. A table test that keeps passing after a sort, while checking a different row, is worse. Nothing goes red, and the bug ships.
This tutorial builds a sort-safe table locator in XPath, step by step: anchor on the row you mean, then pick the cell by its column header. Examples use Playwright, and the XPath works the same in Selenium.
The problem: row and column by position
Here's a small users table:
<table> <thead> <tr><th>Name</th><th>Email</th><th>Status</th><th>Plan</th></tr> </thead> <tbody> <tr><td>Ana Cruz</td><td>ana@example.com</td><td>Active</td><td>Team</td></tr> <tr><td>Ben Ortiz</td><td>ben@example.com</td><td>Invited</td><td>Starter</td></tr> <tr><td>Chloe Lim</td><td>chloe@example.com</td><td>Active</td><td>Team</td></tr> </tbody> </table> You want to check that Ben's status is "Invited". The quick locator:
await expect(page.locator('xpath=//tbody/tr[2]/td[3]')).toHaveText('Invited'); tr[2]/td[3] means "second row, third cell". It isn't tied to Ben at all. Sort the table by Status and Chloe moves into row 2. The locator still finds a cell, it now reads "Active", and depending on what you assert, the test either fails for the wrong reason or passes against the wrong person.
Step 1: anchor on the row's own text
Pick a cell value that identifies the row and won't change when the table is sorted. An email or an ID works better than a display name, because it's more likely to be unique.
//td[normalize-space(.)='ben@example.com']/ancestor::tr[1] Read it left to right: find the cell whose text is ben@example.com, then walk up with ancestor::tr[1] to the nearest row that contains it. Wherever sorting puts Ben, this returns Ben's row.
Now take the third cell of that row:
//td[normalize-space(.)='ben@example.com']/ancestor::tr[1]/td[3] That survives sorting. It doesn't survive someone moving the Status column, because td[3] is still a position.
Step 2: pick the column by its header
The column header is a stable anchor too. Count how many header cells come before "Status", add 1, and you have the column index, worked out at run time:
count(ancestor::table[1]//th[normalize-space(.)='Status']/preceding-sibling::th) + 1 Put it inside the row's td[...] predicate:
import { test, expect } from '@playwright/test'; test('Ben is still invited after sorting', async ({ page }) => { await page.goto('https://example.com/admin/users'); const statusFor = (email: string) => page.locator( `xpath=//td[normalize-space(.)='${email}']/ancestor::tr[1]` + `/td[count(ancestor::table[1]//th[normalize-space(.)='Status']/preceding-sibling::th)+1]` ); await expect(statusFor('ben@example.com')).toHaveText('Invited'); await page.getByRole('columnheader', { name: 'Status' }).click(); // sort await expect(statusFor('ben@example.com')).toHaveText('Invited'); }); Run it against three versions of the table above: the original order, sorted by Status, and with the Status column moved to second place. It returns Ben's "Invited" cell every time. The tr[2]/td[3] version returns "Invited", then "Active", then an email address.
ancestor::table[1] keeps the header lookup inside the same table as the row, so a second table on the page with its own "Status" column can't throw the count off.
Things that can still trip it up
- Duplicate row text. If two rows share the anchor value, the locator matches both and Playwright's strict mode will complain. That's a good failure. Pick a more unique column, such as an email or an ID.
- colspan and rowspan. Counting preceding
thelements assumes one header cell per column. Merged headers shift the count, so check grouped headers by hand. - Header text with extra markup. A sort icon or a hidden "sorted ascending" label inside the
thchanges its text.normalize-space(.)handles stray whitespace, but not extra words; usecontains()on the visible label if you need to. - Virtualized tables. If the grid only renders visible rows, scroll the row into view before you assert.
Why this counts as a resilient locator
Neither part of the final XPath depends on a position you don't control. The row is found by its data, and the column is found by its header. Sorting, filtering and reordering columns all leave both anchors in place, so the locator keeps pointing at the same cell, and an assertion like toHaveText tells you when the data itself changes.
Building it without hand-writing XPath
You don't have to type the count(...) part each time. Sort-safe table locators are a free feature in Anchor Locator, and the free Select Anchor button lets you pick any nearby element, like a row's email cell or a column header, as the anchor for the axes XPath. If you record tests, you can change any recorded step's anchor when the one it picked isn't the stable one.
I'm the solo developer of Anchor Locator.
Your page data never leaves your browser; only license checks go online.
Free on the Chrome Web Store: https://chromewebstore.google.com/detail/blaibbahmbkdkdgkabjlbpgdcldlmadg?utm_source=devto
