Real pages have repeated elements. A shopping page has 20 product cards, each with an "Add to Cart" button. page.getByRole('button', { name: 'Add to Cart' }) matches all 20. You need to narrow down to a specific one. Filtering and chaining is how you do it.
When you chain locators, the second locator searches WITHIN the results of the first. Think of it as "find this container, then find this element inside it."
import { test, expect } from '@playwright/test';
test('chaining locators', async ({ page }) => {
await page.goto('/shopping');
// Step 1: Find the navigation bar
const navbar = page.getByRole('navigation');
// Step 2: Find the "Cart" link INSIDE the nav bar
await navbar.getByRole('link', { name: 'Cart' }).click();
// Chain directly -- no intermediate variable
await page.getByRole('navigation').getByRole('link', { name: 'Home' }).click();
// Find a specific product card, then click its button
const productCard = page.locator('.product-card').filter({ hasText: 'Laptop' });
await productCard.getByRole('button', { name: 'Add to Cart' }).click();
});import { test, expect } from '@playwright/test';
test('filter locators', async ({ page }) => {
await page.goto('/shopping');
// Filter by text content
const laptopCard = page.locator('.product-card').filter({
hasText: 'Laptop',
});
// Filter by NOT having text
const nonLaptopCards = page.locator('.product-card').filter({
hasNotText: 'Laptop',
});
// Filter by child element
const cardsWithDiscount = page.locator('.product-card').filter({
has: page.locator('.discount-badge'),
});
// Filter by NOT having a child
const cardsWithoutDiscount = page.locator('.product-card').filter({
hasNot: page.locator('.discount-badge'),
});
// Chain multiple filters
const activeLaptop = page.locator('.product-card')
.filter({ hasText: 'Laptop' })
.filter({ has: page.locator('.in-stock-badge') });
await activeLaptop.getByRole('button', { name: 'Buy Now' }).click();
});// First matching element
const firstProduct = page.locator('.product-card').first();
// Last matching element
const lastProduct = page.locator('.product-card').last();
// Element at specific index (0-based!)
const thirdProduct = page.locator('.product-card').nth(2);
// Count matching elements
const totalProducts = await page.locator('.product-card').count();
console.log(`Found ${totalProducts} products`);Avoid using .nth() in production tests. If the page order changes, your test breaks silently -- it interacts with the wrong element. Use .filter({ hasText: ... }) instead. nth() is fine for debugging and exploration, but fragile for real tests.
import { test, expect } from '@playwright/test';
test('add specific product to cart', async ({ page }) => {
await page.goto('/shopping');
// Find the row in a table that contains "MacBook"
const macbookRow = page.getByRole('row').filter({ hasText: 'MacBook' });
// Click the "Add" button in that specific row
await macbookRow.getByRole('button', { name: 'Add' }).click();
// Verify cart count updated
await expect(page.getByTestId('cart-count')).toHaveText('1');
});
test('verify all product cards have prices', async ({ page }) => {
await page.goto('/shopping');
// Get all product cards
const cards = page.locator('.product-card');
// Verify each card has a price element
const count = await cards.count();
for (let i = 0; i < count; i++) {
await expect(cards.nth(i).locator('.price')).toBeVisible();
}
});Identify the container -- table row, card, list item
Use .filter({ hasText: ... }) to narrow to the right container
Chain a getByRole or getByText to find the target element inside
Avoid positional selectors (.nth) unless order is guaranteed
Test that the locator is unique by checking .count() equals 1
Debug ambiguous locators by logging the count. If page.getByRole('button', { name: 'Add' }).count() returns 5, you need more filtering. Chain a parent container first to narrow the scope.
Q: How do you handle a page with multiple elements that match the same locator?
A: I use a two-step approach. First, I narrow the scope by finding a unique parent container -- a specific table row, card, or section. I use .filter({ hasText: ... }) or .filter({ has: ... }) to identify the right container. Then I chain a second locator to find the target element within that container. For example, page.getByRole('row').filter({ hasText: 'MacBook' }).getByRole('button', { name: 'Delete' }). I avoid .nth() in production tests because it depends on element order, which can change.
Key Point: Chain locators to narrow scope. Use .filter() with hasText or has options. Avoid .nth() in production tests -- use content-based filtering instead.