How to fix Playwright clicks on wrong element when using `.nth()` indexes?
PlaywrightIndex-based locators with .nth() are fragile because they rely on the DOM rendering elements in a specific, stable order. List order changes from sorting, filtering, pagination, feature flags, A/B test variants, or server-side randomization all cause .nth(2) to point to a different element than intended. Tests using index-based selectors will silently act on the wrong element when order shifts, or fail when the list length changes.
Common mistake
test('edits second team member', async ({ page }) => {
await page.goto('https://app.example.com/team');
// Assumes order is stable — breaks if team member list is re-sorted
await page.getByRole('row').nth(2).getByRole('button', { name: 'Edit' }).click();
// ^^^ position-based — no identity check
});
Also fragile: counting list items and assuming position correlates to specific data:
const items = page.getByRole('listitem');
await items.nth(0).click(); // First item today, different item tomorrow
The fix
Use semantic anchors — text content, data attributes, or role names — to identify the specific element you mean to act on:
import { test, expect } from '@playwright/test';
test('edits Ada Lovelace team member', async ({ page }) => {
await page.goto('https://app.example.com/team');
// Row identified by the name it contains — order-independent
const adaRow = page.getByRole('row', { name: /Ada Lovelace/ });
await adaRow.getByRole('button', { name: 'Edit' }).click();
await expect(page.getByRole('dialog', { name: /Edit.*Ada/ })).toBeVisible();
});
test('selects Pro plan option', async ({ page }) => {
await page.goto('https://app.example.com/pricing');
// Identify by card heading — not position
const proCard = page.getByRole('article', { name: 'Pro' });
await proCard.getByRole('button', { name: 'Choose plan' }).click();
});
When .nth() is appropriate — only when order is a stable, explicitly tested invariant:
test('header row is first row in table', async ({ page }) => {
await page.goto('https://app.example.com/reports');
// Testing that the first row IS the header — order is the test subject
const headerRow = page.getByRole('row').nth(0);
await expect(headerRow.getByRole('columnheader', { name: 'Date' })).toBeVisible();
await expect(headerRow.getByRole('columnheader', { name: 'Amount' })).toBeVisible();
});
Why it works
Semantic locators encode identity — "the row that contains Ada Lovelace" — rather than position. When the list re-renders in a different order or with different members, the locator still finds the right element because it describes what the element is, not where it is. Playwright's getByRole('row', { name: /Ada Lovelace/ }) uses accessible name matching, which works as long as the row contains the name text anywhere in its tree — a robust signal across layout changes.
Tips
- If items in a list don't have distinguishable text or accessible names, add data-testid attributes at the item level when you can modify the component — it gives tests a stable, explicit handle.
- When working with third-party components where you can't add test attributes, use .filter({ hasText: '...' }) to locate the specific item: page.getByRole('listitem').filter({ hasText: 'Ada Lovelace' }).
- .nth() in assertions like await expect(items).toHaveCount(5) is fine — you're asserting count, not acting on a specific position.
- If a test legitimately needs to act on "the most recently created item", filter by the data attribute that indicates creation time rather than assuming it's always the first or last in the list.