Opening: Why locator precision matters
In production, the difference between a brittle test and a resilient one often comes down to how precisely you select elements. Chaining and filtering locators: lessons from production teaches us that the DOM isn't always kind—what works in development can fail mysteriously on staging when a new div wrapper appears or a class name shifts. We've spent enough cycles debugging flaky tests to know that spending five minutes upfront crafting the right locator strategy saves weeks of maintenance pain.
The goal isn't to click anything that looks vaguely correct. It's to narrow down to exactly the right element using Playwright's locator chains and filters, so your tests remain reliable as your product evolves.
Understanding locator.filter({ hasText })
The hasText filter is your first weapon against ambiguity. Imagine you're testing an e-commerce checkout flow where multiple buttons exist on the page—a primary CTA, a secondary action, and a back button. All might share the same role: "button" selector.
// Bad: which button are we actually clicking?
await page.locator('button').click();
// Better: filter to the exact button by visible text
await page.locator('button').filter({ hasText: 'Complete Purchase' }).click();In our production SaaS system, we noticed tests would randomly fail because a hidden button or a disabled button in the DOM was matching first. By filtering on hasText, Playwright only considers elements that actually contain that text content.
The filter supports both exact strings and regex patterns. For partial matches or case-insensitive matching, use regex:
// Case-insensitive partial match
await page.locator('button').filter({ hasText: /save/i }).click();
// Exact match
await page.locator('button').filter({ hasText: 'Save Changes' }).click();A common gotcha: hasText performs a substring search by default. If you need an exact match, wrap your string in regex anchors or use the exact equality check available in later Playwright versions. Check your version's documentation—recent releases offer more precise text matching options.
Scope inside a container first
Chaining and filtering locators works best when you establish scope. Rather than querying the entire page for a button, query for a button inside a specific modal, card, or section. This approach mirrors how users think: "I see a form, and within that form, I need to fill in the email field."
Consider a real scenario: a product listing page with multiple item cards. Each card has an "Add to Cart" button. A naive locator for all of them would be fragile:
// Risky: assumes first match is correct
await page.locator('button:has-text("Add to Cart")').first().click();
// Better: scope to a specific product card first
const productCard = page.locator('[data-testid="product-card"]').filter({ hasText: 'Wireless Headphones' });
await productCard.locator('button:has-text("Add to Cart")').click();The second approach reads like a user story: find the product card containing "Wireless Headphones", then click its add-to-cart button. When the page layout changes or a new section is added, this locator remains stable because it's scoped.
We recommend a three-level hierarchy:
- Container: Find a parent region (modal, form, list, section)
- Element type: Refine to a specific role or tag (button, input, link)
- Filter: Narrow by text, attribute, or visibility
// Level 1: Container
const checkoutForm = page.locator('form[data-step="payment"]');
// Level 2: Element type within container
const submitButton = checkoutForm.locator('button[type="submit"]');
// Level 3: Filter for exact match
await submitButton.filter({ hasText: 'Pay Now' }).click();nth() only as a last resort
Using nth() to select the third button or fifth link feels like a quick fix. In production, it's a ticking time bomb. Page layouts shift—a feature flag enables a new button, a permission hides a section, a design refresh reorders elements. Suddenly your nth(2) locator breaks because what was the third button is now the fourth.
// Fragile: if DOM order changes, this fails silently
await page.locator('button').nth(2).click();
// Acceptable only when truly necessary, but still risky
await page.locator('button[class*="secondary"]').nth(0).click();We've seen this pattern destroy test suites in trading platforms where order flow buttons must be clicked in sequence—developers defaulted to nth() because the buttons looked identical. The fix? Add semantic data attributes or leverage accessibility roles:
// Best: use explicit, semantic selectors
await page.locator('button[data-action="confirm-trade"]').click();
await page.locator('button[data-action="execute-order"]').click();
// Alternative: use accessibility attributes
await page.locator('button:has-text("Confirm Trade")').click();
await page.locator('button:has-text("Execute Order")').click();If you absolutely must use nth(), document why—and make it the last filter in your chain, not the first. Pair it with other filters to narrow the selection space first.
Locators are lazy and re-query each time
A critical insight many developers miss: Playwright locators are lazy. When you write:
const button = page.locator('button').filter({ hasText: 'Submit' });
// The query hasn't run yet.
await button.click();
// NOW the query runs.This has profound implications for test reliability. Between defining the locator and using it, the DOM might change. If an element is dynamically added or removed, Playwright re-queries the page each time you call an action. This is usually a feature—your tests adapt to dynamic content—but it can surprise you.
In a travel booking system, we built a test that selected a flight, then clicked "Book Now" on the results page. The flight list re-rendered between selection and booking, and our locator tried to click a stale element. The solution was to ensure stable identity:
// Stores a reference, but the query is still lazy
const selectedFlight = page.locator('[data-flight-id="UA123"]');
// First action: verify visibility
await selectedFlight.waitFor({ state: 'visible' });
// Second action: click—will re-query if needed
await selectedFlight.click();Use waitFor() to ensure the element is stable before interacting with it. This blocks until the element matches your criteria, preventing race conditions where the DOM shifts between actions.
Common mistakes
- Over-broad initial selectors: Starting with
page.locator('*')or relying on element order instead of establishing scope first. Always narrow the container before filtering. - Fragile text matching: Using exact text when the content includes dynamic data (timestamps, prices). Use regex or partial matching:
/\d+ added to cart/instead of"1 item added to cart". - Ignoring accessibility: Tests that bypass semantic HTML (roles, labels, landmarks) break when your app becomes truly accessible. Prefer
getByRole('button', { name: 'Submit' })over brittle CSS. - Chaining too deeply: A locator that's nested five levels deep is hard to debug. Keep chains to three or four levels; extract intermediate containers as variables for readability.
- Assuming single matches: Filter results can still return multiple elements. If you need exactly one, use
.first()or verify count with.count()before interacting.
Key takeaways
- Use
locator.filter({ hasText })to narrow by visible text content; prefer regex for flexible matching. - Scope inside a container first—establish a parent region, then refine to your target element within it.
- Avoid
nth()unless absolutely necessary; semantic attributes and text matching are far more resilient. - Remember that locators are lazy—queries re-run on each action, so use
waitFor()to stabilize the DOM before interaction. - Treat locator chains as readable user stories: "find this region, then this element, filtered by this attribute."
- Test your locators in both success and edge cases—dynamic content, loading states, missing elements—to catch brittleness early.






Comments
Loading comments…