How to fix Playwright clicks on wrong element when using `.nth()` indexes?

Playwright

Index-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.