A locator describes how Playwright should find an element when an action or check runs. It is not the element itself.
A locator is like an address: it tells someone where to look each time.
locator → search current page → element → action or assertion
Series: Playwright Zero to Expert | Demo app used throughout this series: saucedemo.com
Every single thing Playwright does to a page — clicking, typing, reading text, asserting something is visible — starts with the same question: which exact element, on this page, am I talking about? A locator is Playwright’s answer to that question — an object representing “how to find this element,” which Playwright can then act on, wait for, or assert against. This part is where Part 1’s HTML/CSS/XPath knowledge and Part 6’s first taste of getByRole/getByPlaceholder finally come together into a real, defensible strategy.
Locator vs. Selector — a Distinction Worth Actually Understanding
In Part 1, you learned CSS selectors and XPath — strings that describe where an element is, at the exact moment you evaluate them. A selector is that string. A locator, in Playwright specifically, is something meaningfully different: it’s a live, reusable reference to “however I find this element,” re-evaluated fresh, every single time you use it.
const loginButton = page.getByRole("button", { name: "Login" });
// At this exact moment, nothing has actually been "found" yet.
// loginButton just knows *how* to find the button, whenever it's needed.
await loginButton.click(); // NOW Playwright actually searches the page and clicks it
This distinction genuinely matters, not just as trivia. If the page changes between when you create the locator and when you use it — a button gets removed and a new one with the same role and name gets added back in, for instance — a raw selector captured once, up front, could point at a now-stale, disconnected element.
A Playwright locator re-searches the live DOM fresh, right at the moment of the action, which is exactly the behavior that makes Playwright’s auto-waiting (covered properly at the end of this part, and in full in Part 12) actually work.
Why Locators Exist, and Why Some Strategies Are Fragile
Think back to Module 1.5’s honest observation: several different CSS selectors could all technically find the same element, but they weren’t equally safe. That single idea is the entire reason Playwright gives you multiple locator strategies instead of just one, and it’s worth walking through concretely, using an analogy from your original spec: finding a person by their name is more reliable than saying “the third person from the left.” Positions shift. Names — assuming they’re actually unique in context — don’t.
Analogy: GPS Coordinates vs. Logical Descriptions Imagine you are a delivery driver looking for the local Post Office building:
- Fragile Strategy (GPS / Absolute CSS / Index): Your navigator tells you: “Deliver this mail to latitude 40.7128, longitude -74.0060.” If the building is demolished and replaced with a park, or if the layout shifts, you will deliver the mail to a park bench or a wrong office.
- Resilient Strategy (Accessible Role / Attribute): Your navigator tells you: “Deliver this mail to the building clearly labeled ‘Post Office’ with a blue mailbox out front.” Even if the city builds new office blocks around it or changes the street names, you will scan the street, find the labeled building, and make a correct delivery.
Lazy Evaluation: Why Locators Do Not Cache Elements
In Playwright, writing const loginButton = page.getByRole('button', { name: 'Login' }) does not query the browser DOM. The locator is lazy: it acts as a blueprint or reference recipe. The actual database search in the DOM tree occurs only when an action is fired, such as calling await loginButton.click().
If the element disappears and reappears, or if the page dynamically re-renders in the background, Playwright avoids stale element reference errors because it executes the search recipe fresh at the millisecond of interaction.
📊 Visual Flowchart: Locator Action Resolution & Actionability Checks
Here is the step-by-step verification pipeline Playwright executes under the hood before completing an action like a click:
graph TD
Trigger["Call await locator.click()"] --> Search["Query DOM tree using locator recipe"]
Search --> Found{"Is element found?"}
Found -->|No| Timeout{"Has default 30s timeout elapsed?"}
Timeout -->|No| RetrySearch["Wait 100ms & Retry Search"]
RetrySearch --> Search
Timeout -->|Yes| Error["Raise TimeoutError"]
Found -->|Yes| Scroll["Scroll element into view if not visible"]
Scroll --> Visible{"Is element visible?"}
Visible -->|No| RetrySearch
Visible -->|Yes| Stable{"Is element stable?<br>(Not animating/moving)"}
Stable -->|No| RetrySearch
Stable -->|Yes| Enabled{"Is element enabled?<br>(Not disabled)"}
Enabled -->|No| RetrySearch
Enabled -->|Yes| Obscured{"Is element unobscured?<br>(Not covered by overlay)"}
Obscured -->|Yes| Click["Dispatch click event"]
Obscured -->|No| RetrySearch
Click --> Success["Action Completed Successfully"]
Now translate that directly onto real HTML. Imagine SauceDemo redesigns its login page’s CSS, giving every input a shared class like .form-control for consistent new styling. A test locating the username field by that class (page.locator('.form-control')) might suddenly break, or worse, silently start matching the wrong field, the moment a second .form-control element gets added anywhere on the page.
A test locating the same field by its accessible role and label, or by a dedicated data-test attribute, is untouched by that exact same redesign — because neither of those things had anything to do with styling in the first place.
This is the real principle underneath everything in this part: prefer locating elements by what they fundamentally are and mean to a user, not by incidental details of how they happen to be styled or positioned right now.
Playwright’s Built-In Locators
getByRole — the Recommended Default
await page.getByRole("button", { name: "Login" }).click();
await page.getByRole("link", { name: "Sauce Labs Backpack" }).click();
await page.getByRole("checkbox", { name: "Remember me" }).check();
getByRole finds elements by their accessibility role — a concept borrowed directly from web accessibility (which gets a full, dedicated treatment in Part 35). Every meaningful interactive element on a well-built page has an implicit role: a <button> has the role button, a <a href="..."> has the role link, an <input type="checkbox"> has the role checkbox — regardless of what CSS classes or styling happen to be layered on top.
Why is this the recommended default, not just one option among several? Because a role reflects an element’s actual purpose, which is exactly the kind of thing that survives a visual redesign untouched. A button styled with .btn-primary today and .cta-button after a rebrand is still, underneath, a button with the role button — getByRole('button', { name: 'Login' }) doesn’t care about that change at all.
This also has a genuinely valuable side effect worth stating plainly: writing tests this way actively rewards — and indirectly tests — good accessibility practice in the application itself, since a getByRole locator working correctly is itself evidence that a screen reader user could identify that same element too.
getByText
await expect(page.getByText("Products")).toBeVisible();
await page.getByText("Add to cart").first().click();
Finds an element by its visible text content. Genuinely useful and readable, but worth a note of caution: text can be more likely to change than a role or a dedicated test attribute — a marketing copy tweak from “Add to cart” to “Add to Cart” (capitalization) or “Add to Bag” would break this locator, even though the underlying button’s actual function never changed at all.
getByLabel
await page.getByLabel("Username").fill("standard_user");
Finds a form input by its associated <label> text — exactly how a real user would identify the field visually. This is a strong, recommended choice specifically for form fields.
getByPlaceholder
await page.getByPlaceholder("Username").fill("standard_user");
Finds an input by its placeholder attribute — the greyed-out hint text before typing. Useful when a field genuinely has no proper <label> (which is itself often a real accessibility gap worth flagging as a bug, not just working around silently), but worth knowing this is a slightly weaker signal than getByLabel, since placeholder text is sometimes treated as more disposable, cosmetic copy by a team than a form’s actual label.
getByTestId
await page.getByTestId("login-button").click();
Finds an element by a dedicated test attribute (data-test or data-testid, configurable) — exactly the data-test="login-button" attribute we saw on SauceDemo’s real HTML back in Part 1. This is, deliberately, one of the most stable locator strategies available, precisely because it exists for no reason other than testing — nobody redesigns CSS classes expecting to preserve data-test values, but nobody has a reason to casually remove them either, since removing one visibly breaks a team’s own test suite, giving it a kind of natural protection other attributes don’t have.
locator() — CSS and XPath, Directly
await page.locator("#login-button").click(); // CSS
await page.locator('//input[@data-test="login-button"]').click(); // XPath
The generic locator() method accepts raw CSS selectors or XPath expressions directly — everything you learned in Part 1’s Modules 1.5 and 1.6, usable here exactly as written. This is your fallback for the (real, but less common than beginners assume) cases where role, text, label, placeholder, and test-id genuinely don’t get you to a precise, unique element on their own.
Locator Strategy — Bad vs. Good, Using SauceDemo
Here’s the honest, ranked priority order most modern Playwright teams (and Playwright’s own official guidance) actually recommend, worth genuinely internalizing rather than memorizing as a rule to recite:
getByRole— the strongest default; reflects actual purpose and rewards accessibility.getByLabel/getByText/getByPlaceholder— strong, readable, close to how a real user identifies things.getByTestId— extremely stable, but requires the application to actually havedata-testattributes added deliberately (often something you’d request from developers, not something you can assume exists).- CSS
locator()usingidor attribute selectors — a reasonable fallback when none of the above cleanly apply. - CSS
locator()using classes — riskier; only when the class is genuinely specific and unlikely to be shared. - XPath, especially absolute XPath, or positional selectors like
nth-child— last resort; fragile, and a sign worth pausing on — if you find yourself reaching for this, it’s often worth asking whether the application itself is missing something (a label, a test id) that would make a cleaner locator possible.
Let’s apply this directly to SauceDemo’s real login form:
// Weak — a shared class, risky if reused elsewhere or restyled
await page.locator(".form_input").first().fill("standard_user");
// Better — targets the field by its actual id
await page.locator("#user-name").fill("standard_user");
// Strong — reflects a purpose-built test attribute
await page.getByTestId("username").fill("standard_user");
// Strong, and arguably the most human-readable of all — matches how a real user reads the form
await page.getByPlaceholder("Username").fill("standard_user");
All four of these work today. They are not equally trustworthy tomorrow — and being able to explain, out loud, why, is precisely the skill this entire module is building.
Locator Chaining and Filtering
Real pages aren’t always as simple as “one uniquely identifiable button.” Imagine SauceDemo’s inventory page, showing six products, each with its own “Add to cart” button — all sharing the exact same text and role. page.getByRole('button', { name: 'Add to cart' }) on its own would match all six, which is ambiguous and would cause Playwright to raise an error demanding you be more specific (a genuinely helpful, deliberate safety feature, not a bug — Playwright refuses to guess which of several matches you meant).
.filter() narrows a locator down based on additional conditions:
// Find the product container that has this specific text, then find its "Add to cart" button
await page
.locator(".inventory_item")
.filter({ hasText: "Sauce Labs Backpack" })
.getByRole("button", { name: "Add to cart" })
.click();
Read this the way you’d now naturally read a DOM tree, straight from Part 1.3: start at every element matching .inventory_item (each product’s container), narrow that list down to only the one containing the text “Sauce Labs Backpack,” and within that specific, now-unique container, find its “Add to cart” button.
This is locator chaining — scoping a search into a specific section of the page — combined with filtering, working together exactly the way a human would naturally describe finding the right button: “the Add to Cart button, but specifically the one on the Backpack’s card.”
.and() and .or() combine conditions similarly — .and() requires both to match, .or() matches either.
nth(), first(), last()
let you pick an element by position when multiple genuinely-equivalent matches exist:
await page.getByRole("button", { name: "Add to cart" }).first().click();
This works, and sometimes it’s genuinely the most honest option available (there truly is no other distinguishing feature).
But it carries the exact same fragility warning as nth-child from Part 1 — if the product list’s order ever changes (a new sort option, a new default sort, a product being added or removed), “the first Add to cart button” silently stops meaning “the Backpack” and starts meaning whatever product now happens to sit first.
Whenever a .filter({ hasText: ... }) approach is available instead, it’s almost always the more resilient, more honest choice, because it expresses what you actually mean (“the Backpack’s button”) rather than an incidental detail of today’s ordering.
Auto-Waiting — Why Locators Behave the Way They Do
Here’s something you may not have consciously noticed yet, back in Part 6’s test: nowhere in that test did you write anything like “wait for the button to appear” before clicking it. And yet it worked. This isn’t luck — it’s Playwright’s auto-waiting mechanism, and understanding it now, even briefly (Part 12 covers it in full internal detail), changes how you read every locator action from here on.
When you call .click() on a locator, Playwright doesn’t just find the element and immediately click it. It waits — automatically, up to a configurable timeout — for the element to become genuinely actionable: present in the DOM, visible, not obscured by another element on top of it, not disabled, and stable (not actively animating or moving). Only once all of these are true does the actual click happen.
// You write this one line...
await page.getByRole("button", { name: "Login" }).click();
// ...and Playwright, underneath, effectively does something closer to this:
// 1. Find an element matching this role and name
// 2. Wait until it exists in the DOM
// 3. Wait until it's visible
// 4. Wait until it's not covered by anything else
// 5. Wait until it's enabled (not disabled)
// 6. Wait until it's stable (not moving/animating)
// 7. THEN actually perform the click
This is precisely why Playwright test suites tend to be dramatically less flaky, out of the box, than older Selenium suites written without careful, explicit manual waits — the waiting isn’t something you have to remember to add; it’s built into the fundamental behavior of every locator action, automatically, every time.
How It Works in a Real Test Run
A locator is a reusable query, not a frozen element. Each action or web-first assertion resolves it against the current page, which is why it can survive a re-render that replaces the original DOM node.
The practical priority is usually role and accessible name, then label or visible text, then a deliberate test id, and finally CSS or XPath when user-facing contracts cannot express the target. Strictness is useful evidence that a locator is ambiguous, not an inconvenience to silence immediately.
Interview Questions
Q: What is the difference between a locator and a selector?
Ans: A selector is a string describing where to find an element at the exact moment it’s evaluated, like a CSS or XPath expression. A Playwright locator is a live, reusable reference to how to find an element — it doesn’t search the page the instant it’s created, but re-searches the live DOM fresh every time it’s actually used for an action or assertion, which is part of what makes Playwright’s auto-waiting behavior possible.
Q: Why is getByRole generally recommended as the default locator strategy over something like a CSS class selector?
Ans: getByRole locates an element by its actual accessibility role and purpose — what it fundamentally is to a user — rather than by incidental implementation details like CSS classes, which are far more likely to change during a visual redesign that has nothing to do with the element’s actual function. A button’s role stays button regardless of restyling, so a test built around role tends to survive changes that would break a class-based locator.
Q: What is getByTestId, and why is it considered one of the most stable locator strategies, even though it’s lower in the priority order than getByRole?
Ans: getByTestId locates an element by a dedicated attribute (commonly data-test or data-testid) added specifically for testing purposes, with no other function on the page. It’s extremely stable because nothing about normal styling or content changes has any reason to touch it. It’s ranked below getByRole mainly because it depends on the application team having deliberately added those attributes in the first place, whereas role-based locators work on virtually any properly built HTML without requiring any special cooperation from developers.
Q: A page has six identical “Add to cart” buttons, one per product. How would you reliably click the one for a specific product, and why is picking it by position (like .first()) often the weaker choice?
Ans: I’d scope the search to that specific product’s container first, using something like .filter({ hasText: 'Sauce Labs Backpack' }) on the product container, and then find the “Add to cart” button within that already-narrowed, unique scope. Picking by position with .first() or .nth() is weaker because it depends on the current ordering of products, which can silently change due to sorting, filtering, or inventory changes — the locator would then click the wrong product’s button without any error being raised, since it’s still technically finding a valid match.
Q: What is auto-waiting, and why does it reduce test flakiness compared to older tools?
Ans: Auto-waiting means that when a Playwright locator action is called, like .click(), Playwright automatically waits for the target element to become genuinely actionable — present, visible, unobscured, enabled, and stable — before performing the action, up to a configurable timeout. This removes an entire category of timing-related failures common in tools requiring explicit, manually-written waits, since the correct wait behavior is built into every action by default, rather than depending on every individual test author remembering to add it correctly themselves.
Q: You inherit a test suite where nearly every locator is written using deep, absolute XPath expressions, and it’s become slow to maintain because tests break with almost every UI change. How would you explain the problem, and what would you recommend?
Ans: I’d explain that absolute XPath locators describe an element’s exact position in the DOM tree, so inserting or removing almost any element anywhere above the target — even something completely unrelated to it — breaks the path entirely, which is exactly why the suite keeps breaking on seemingly unrelated changes. I’d recommend migrating toward role-based, label-based, or test-id-based locators wherever possible, prioritizing what an element actually is and means over its incidental position in the page’s structure, and reserving XPath only for genuinely difficult cases the other strategies can’t cleanly handle.
Q: What does .filter({ hasText: ... }) actually do, and how is it different from just using getByText directly?
Ans: .filter() narrows down an already-existing locator that may match multiple elements, based on an additional condition like containing specific text — it doesn’t search the whole page fresh, it filters within a set of matches you’ve already scoped to, such as a set of product containers. getByText directly searches for an element whose own visible text matches, which is a different, broader search rather than a narrowing operation on an existing, more specific locator.
Exercises — Part 7
Understand: Without looking back, explain in your own words why a locator (as Playwright defines it) is meaningfully different from a plain CSS selector string, and why that difference matters for auto-waiting.
Simple Practice:
On SauceDemo’s inventory page, write four different Playwright locators that could each find the “Add to cart” button for the “Sauce Labs Bike Light” product specifically (not all six buttons) — one using getByRole combined with filtering, one using getByTestId (inspect the real page to find its actual data-test value), one using CSS via locator(), and one using XPath via locator(). Rank all four from most to least trustworthy for a long-term test suite, and justify the ranking.
Real-World Scenario: Imagine SauceDemo’s frontend team runs a visual redesign, restyling every button on the site with new, different CSS classes, but changing nothing about each button’s actual HTML tag, role, or text. Which of the four locators you wrote above would survive this change untouched, and which would break? Explain why, for each one.
Challenge:
Find a real product listing page on any e-commerce site with multiple, visually identical “Add to Cart”-style buttons. Using DevTools, determine whether the page provides any data-test-style attributes. If it doesn’t, write the best possible getByRole + .filter() combination you can to reliably target one specific item’s button, and explain what you’d request from the development team to make this locator even more robust.
Next: Part 8 — Actions
— now that you can reliably find any element on a page, it’s time to cover the full range of things Playwright can actually do to it: click, fill, type, check, select, hover, drag, and upload.
- Author
- TechByteByByte Editorial Team
- Reviewed by
- TechByteByByte Admin
- Published
- Last reviewed