Playwright Locators: Write Less Flaky, More Maintainable Tests + Codegen Demo on ECom Store
- Anuradha Agarwal
- Feb 21
- 18 min read
Why Traditional CSS-Based Automation Often Fails
If you have worked with traditional automation tools like Selenium, your tests probably looked something like this:
driver.findElement(By.cssSelector(
"div.container > form > div:nth-child(3) > button.primary"
)).click();
When engineers begin learning test automation, CSS selectors are usually the first method used to locate elements. CSS selectors are powerful, flexible, and essential—but relying exclusively on them can lead to fragile and difficult-to-maintain tests.
Modern web applications frequently update the DOM dynamically. Even small UI changes—such as adding a wrapper div, modifying a layout, or rendering components asynchronously—can break existing selectors. As a result, tests that once worked reliably begin to fail, not because the application is broken, but because the locator strategy is too dependent on DOM structure.
This is one of the key reasons automation suites become flaky over time.
Playwright addresses this challenge by introducing a smarter locators which identify elements based on their role, label, and visible text—similar to how real users interact with the application. These modern locators, combined with Playwright’s built-in auto-wait and retry mechanisms, significantly improve test stability and reduce maintenance effort.
In the next section, we will understand Playwright locators using simple examples and then apply them step-by-step to a real e-commerce workflow on the QA-Cart demo application.
What Are Playwright Locators?
Playwright locators are the primary way to identify and interact with elements during automated testing. They act as a bridge between your test script and the user interface, enabling reliable interactions such as clicking buttons, filling forms, selecting options, and validating text.
Unlike static CSS or XPath selectors, Playwright locators are dynamic and intelligent. They automatically wait for elements to become ready and retry actions when necessary, which helps eliminate many common causes of flaky tests.
Playwright also promotes an accessibility-focused approach by allowing you to target elements based on their role, label, or visible text—just like a real user interacts with the application.
Why CSS Selectors Still Matter
Even though Playwright introduces modern locator methods, CSS selectors remain an essential skill.
There are scenarios where CSS selectors are still required, including:
Targeting dynamically generated UI elements
Working with custom widgets that lack accessible labels
Debugging elements using browser DevTools
Handling complex layouts or legacy applications
Playwright allows you to combine locator strategies. The recommended approach is to use built-in locators first and use CSS selectors when semantic locators are not available.
If you are new to selectors, you can build your CSS selector foundation here:
Learn Playwright Automation Using CSS Selectors
Prerequisites: Install Playwright and Prepare Your Environment
Before practicing locators, ensure your Playwright setup is ready.
If you have not yet installed Playwright, follow these step-by-step guides:
Playwright Setup Guide
Playwright Installation Overview
If you are unfamiliar with the DOM or element identification, start here:
Understanding DOM and Locators https://www.anuradhaagarwal.com/post/understanding-dom-and-locators-in-playwright-a-beginner-s-guide-to-stable-test-automation
Types of Playwright Locators
Playwright offers several locator methods designed for specific types of elements.
Understanding when to use each one is critical for writing stable automation scripts.
getByRole(): Recommended Primary Locator
The getByRole() locator identifies elements using their accessibility role, such as button, link, textbox, or heading. These roles are derived from semantic HTML elements like <button>, <input>, <a>, or from ARIA attributes.
Example:
await page.getByRole("button", { name: "Login" }).click()
This approach is extremely reliable because it reflects how real users—and assistive technologies like screen readers—understand the page.
Unlike CSS selectors, it does not depend on layout or DOM structure.
You can also easily discover roles using Playwright Inspector.
Run your test in debug mode:
npx playwright test --debug
This opens the Playwright Inspector.
Now:
Click the Pick Locator icon
Hover over elements in the browser
Playwright will automatically show recommended locators like:
getByRole('button', { name: 'Login' })
Note: We will introduce Playwright Codegen shortly, which automates locator generation even faster.
Consider this HTML:
<button>Login</button>
<button>Register</button>
<button disabled>Login</button>
Each of these elements has the accessibility role:
role="button"
Now let’s see how Playwright identifies them.
Basic locator:
await page.getByRole("button", { name: "Login" }).click()
Playwright finds the button with visible text "Login".
Using Filters with getByRole()
Playwright allows filtering elements using additional options.
Filter by name
await page.getByRole("button", { name: "Register" }).click()
Filter by exact match using regex
await page.getByRole("button", { name: /login/i }).click()
Matches:
Login
LOGIN
login
Filter by state (disabled, checked, selected)
Example HTML:
<button disabled>Login</button>
Locator:
await page.getByRole("button", {
name: "Login",
disabled: true
})
Handling Multiple Matching Elements
Example HTML:
<div>
<button>Save</button>
</div>
<div>
<button>Save</button>
</div>
Refine using locator chaining:
const section = page.locator("div").first()
await section.getByRole("button", { name: "Save" }).click()
This ensures precise targeting.
getByLabel()
The getByLabel() locator targets form elements using their associated label text, just like how real users identify fields visually.
Example:
await page.getByLabel("Email").fill("test@email.com")
This locator works because Playwright connects the visible label "Email" to its corresponding input field
However, for this to work correctly, the label must be properly associated with the input element in HTML.
How Labels Connect to Inputs
There are three common ways labels are connected to inputs.
Playwright supports all of them.
Method 1: Using the for attribute
HTML:
<label for="email">Email</label>
<input id="email" type="text">
Here:
Label text = Email
Label connects to input via for="email"
Input connects back via id="email"
Playwright locator:
await page.getByLabel("Email").fill("test@email.com")
Playwright automatically finds the correct input.
Method 2: Input inside label
HTML:
<label>
Email
<input type="text">
</label>
The input is nested inside the label.
Playwright still resolves correctly:
await page.getByLabel("Email").fill("test@email.com")
Method 3: Using aria-label (Accessibility attribute)
HTML:
<input type="text" aria-label="Email">
Playwright:
await page.getByLabel("Email").fill("test@email.com")
This is common in modern frameworks like React and WooCommerce.
Supported Form Elements with getByLabel()
Works reliably with:
Text input fields
Password fields
Radio buttons
Checkboxes
Dropdown menus
Date inputs
Textareas
When getByLabel() Will Not Work
If label is not connected properly:
Example incorrect HTML:
<label>Email</label>
<input type="text">
No for, no nesting, no aria-label.
Playwright cannot link them reliably.
In such cases, use:
page.getByRole("textbox", { name: "Email" })
or CSS locator fallback.
In the next section, we will apply getByLabel()
getByText(): Targets Visible Text Content
The getByText() locator identifies elements based on the exact text visible to users on the page. This makes it extremely useful for validating messages, interacting with buttons, and confirming application behavior.
Example:
await page.getByText("Account details updated successfully")
Playwright searches the page and finds the element that displays this text.
This approach closely matches how real users interact with applications—they see text and respond to it.
Consider this HTML:
<button>Login</button>
<p>Account details updated successfully</p>
<div class="error">
Invalid username or password
</div>
Playwright can locate each element using visible text.
Click Login button:
await page.getByText("Login").click()
Validate success message:
await expect(
page.getByText("Account details updated successfully")
).toBeVisible()
Validate error message:
await expect(
page.getByText("Invalid username or password")
).toBeVisible()
This confirms text-based locator is available.
Using getByText() with Partial Text Matching
Often, messages may contain dynamic values.
Example HTML:
<p>
Account details updated successfully for user Anuradha
</p>
Exact match may fail.
Instead, use regex:
await page.getByText(/account details updated successfully/i)
Benefits:
Case-insensitive
Handles dynamic values
More stable
Handling Multiple Elements with Same Text
Example HTML:
<button>Save</button>
<div>
<button>Save</button>
</div>
Multiple "Save" buttons exist.
Refine locator using chaining:
const form = page.locator("form")
await form.getByText("Save").click()
This ensures correct button is clicked.
getByPlaceholder(): Locate Input Fields Using Placeholder Text
The getByPlaceholder() locator identifies input fields using their placeholder text—the hint text displayed inside the input box before the user types anything.
Example:
await page.getByPlaceholder("Search products").fill("organic")
Playwright finds the input field that shows the placeholder text "Search products" and enters the value.
This approach is especially useful for search fields and modern UI forms where labels may not be present.
Consider this HTML:
<input type="text" placeholder="Search products">
<input type="email" placeholder="Enter your email">
<input type="password" placeholder="Enter your password">
Playwright can identify each input directly using placeholder text.
Search field:
await page.getByPlaceholder("Search products").fill("organic")
Email field:
await page.getByPlaceholder("Enter your email")
.fill("anuradha.learn@gmail.com")
Password field:
await page.getByPlaceholder("Enter your password")
.fill("MySecurePassword")
On the QA-Cart DemoShop page, the search input contains:
<input type="search" placeholder="Search products…">
Playwright automation:
await page.getByPlaceholder("Search products…").fill("organic")
await page.keyboard.press("Enter")
This performs the same action as a real user typing in the search box.
Using Partial Matching with Regex
Placeholder text may change slightly.
Instead of exact match:
page.getByPlaceholder("Search products…")
Use regex:
page.getByPlaceholder(/search products/i).fill("organic")
Benefits:
Case-insensitive
More stable
Future-proof
Common Use Cases for getByPlaceholder()
Best suited for:
Search input fields
Login forms without labels
Email input fields
Password fields
Filter input fields
Example login form:
<input placeholder="Email address">
<input placeholder="Password">
Playwright:
await page.getByPlaceholder("Email address")
.fill("user@email.com")
await page.getByPlaceholder("Password")
.fill("MyPassword")
Note: Even though the placeholder text disappears visually after typing, Playwright’s getByPlaceholder() continues to work reliably because it targets the element’s underlying placeholder attribute in the HTML, which remains present in the DOM.
getByAltText(): Locate Images and Media Using Alternative Text
The getByAltText() locator identifies images and media elements using their alternative text (alt text). Alt text is an accessibility attribute that describes the content of an image, allowing screen readers and assistive technologies to interpret visual elements.
Example:
await page.getByAltText("Company logo").click()
Playwright locates the image based on its alt attribute, not its CSS class or position.
Consider this HTML:
<img src="/images/logo.png" alt="Company logo">
<img src="/images/playwright.png" alt="Playwright tutorial banner">
<img src="/images/cart-icon.png" alt="Shopping cart">
Playwright can locate each image directly:
Click company logo:
await page.getByAltText("Company logo").click()
Validate tutorial banner image is visible:
await expect(
page.getByAltText("Playwright tutorial banner")
).toBeVisible()
Click shopping cart icon:
await page.getByAltText("Shopping cart").click()
Alt text helps:
Screen readers describe images to visually impaired users
Improve accessibility compliance
Improve SEO ranking
Provide a fallback description if the image fails to load
Playwright leverages this accessibility feature to create stable locators.
Shopping cart icon example:
<img src="/cart.png" alt="Shopping cart">
Playwright automation:
await page.getByAltText("Shopping cart").click()
Using Partial Matching with Regex
For flexibility:
await page.getByAltText(/logo/i)
Matches:
Company logo
Main Logo
Site logo
Case-insensitive.
Ideal for locating:
Company logos
Product images
Cart icons
Profile images
Banner images
Icons with accessibility labels
Best practice: Use getByAltText() whenever images have meaningful alt text, especially for logos, icons, and product images in real-world applications like QA-Cart.
Core Features That Make Playwright Locators Reliable
Automatic Waiting
Playwright automatically waits until elements are ready before performing actions.
This removes the need for manual wait commands.
Built-in Retry Mechanism
Playwright retries actions and assertions automatically if the element is not immediately available. This reduces intermittent failures.
Strict Element Matching
Playwright ensures locators match the intended element, preventing accidental interaction with incorrect elements.
Locator Chaining for Complex UI
You can combine locators to increase precision.
Example:
const form = page.locator("form")
await form.getByRole("button", { name: "Save" }).click()
Using Playwright Codegen to Generate Locators and Upgrade Our Account Edit Workflow
So far, we have learned how Playwright’s built-in locators such as getByRole(), getByLabel(), and getByText(), create more stable automation compared to traditional CSS selector-based approaches.
However, identifying the best locator manually for every element can be time-consuming—especially when working with a new application.
This is where Playwright Codegen becomes an extremely powerful productivity tool.
What is Playwright Codegen?
Playwright Codegen is a built-in recording tool that automatically generates automation scripts as you interact with the application.
It helps you:
Discover the most stable locators quickly
Generate automation scripts faster
Learn Playwright locator strategies interactively
Reduce manual effort in identifying elements
Instead of manually inspecting HTML and writing locators, Playwright suggests the best locator based on accessibility and semantic meaning.
Launch Playwright Codegen
Run the following command in your project:
npx playwright codegen https://qa-cart.com
This opens:
A browser window (where you interact with the application)
A Codegen panel (where Playwright generates automation code in real time)
Now perform the same steps you did earlier manually:
Login
Navigate to Account Details
Update form inputs
Save changes
Playwright automatically generates locator-based code, such as:
await page.getByRole("button", { name: "Save changes" }).click();
This makes script creation significantly faster.
Codegen Is Helpful—but Should Be Used Carefully
Codegen is extremely useful for discovering locators, but its output should always be reviewed and refined.
Codegen generates locators based on the current UI structure. In some cases, it may produce locators that work immediately but are not ideal for long-term stability.
For example, Codegen may generate something like:
await page.locator("div:nth-child(3) > button").click();
This works—but it is fragile.
If the UI layout changes, this locator breaks.
Instead, using your understanding of the locator strategy, you should refine it to:
await page.getByRole("button", { name: "Save changes" }).click();
Why CSS Selector Knowledge Still Matters When Using Codegen
Even though Playwright recommends semantic locators, CSS selectors remain essential for handling complex UI components.
Let’s look at an example from QA-Cart price filter slider:
HTML:
<input
type="range"
class="wc-block-price-filter__range-input--max"
min="1500"
max="3500"
value="3500">
Codegen might generate:
await page.locator("input").nth(2).fill("2500");
This is unreliable because:
It depends on the element position
It may break if another input is added
Using CSS selector understanding, a better locator is:
await page.locator(
"input.wc-block-price-filter__range-input--max"
)
This version is precise and stable.
This demonstrates why combining Playwright locators with CSS selector knowledge creates the most reliable automation.
Applying Playwright Locators to Our Account Edit Workflow (Upgrading CSS Version)
Now, let’s rebuild the same workflow using Playwright’s recommended locator strategy.
Demo site:https://qa-cart.com
Workflow objective:
Login
Navigate to Account Details
Update form inputs
Save changes
Verify persistence
Make sure you have manually registered your username in the demo store.
Step 1: Login Using Role-Based Locators
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
//Load Page
await page.goto('https://qa-cart.com/');
await expect(page.locator('#ast-desktop-header')).toContainText('QA Automation Test Demo Store');
//login
await page.getByRole('textbox', { name: 'Username or email address' }).click();
await page.getByRole('textbox', { name: 'Username or email address' }).fill('anuradha.learn@gmail.com');
await page.getByRole('textbox', { name: 'Password Required' }).click();
await page.getByRole('textbox', { name: 'Password Required' }).fill('Play@1234#$');
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByLabel('Account pages').getByRole('list')).toContainText('Account details');
Step 2: Navigate to Account Details Page
//Go to account details
await page.getByRole('link', { name: 'Account details', exact: true }).click();
await page.goto('https://qa-cart.com/edit-account/');
Step 3: Update Form Inputs Using Label-Based Locators
Select radio button:
await page.getByRole('radio', { name: 'Intermediate' }).check();
Select checkboxes:
await page.getByRole('checkbox', { name: 'Playwright Automation' }).check();
Enter preferred start date:
await page.getByRole('textbox', { name: 'Preferred Start Date' }).fill('2026-04-15');
Select dropdown option:
await page.getByLabel('Preferred Automation Tool').selectOption('selenium');
await page.getByRole('checkbox', { name: 'Subscribe to AI Testing' }).check();
Label-based locators are extremely stable because labels rarely change.
Step 4: Save Changes and Validate Success Message
await page.getByRole('button', { name: 'Save changes' }).click();
await expect(page.getByRole('alert')).toContainText('Account details changed successfully.');
This validates that the update was successful.
Step 5: Verify Persistence
Re-open Account Details page and confirm values persist:
//check persistence
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Account details', exact: true }).click();
await expect(page.getByText('Intermediate')).toBeVisible();
await expect(page.locator('#post-3607')).toContainText('API Testing');
await page.getByRole('button', { name: 'Save changes' }).click();
});
This ensures data was saved correctly.
Compared to CSS selector-based scripts, this version provides:
Better readability
Higher stability
Less dependency on DOM structure
Reduced flaky failures
Easier long-term maintenance
Workflow 2: Purchase a Product (Playwright Locators + CSS Fallback)
Goal of This Workflow
Login → Open DemoShop → Search “organic” → Apply max price filter ($25) → Add first product to cart → Checkout → Capture Order ID → Verify in Orders → Logout
If you want to see the earlier CSS-selector-first implementation, refer to the complete version here
Locator Strategy We Will Follow
Prefer Playwright built-in locators: getByRole(), getByLabel(), getByText()
Use CSS locator fallback only when:
The UI is a complex widget (example: price slider)
accessible names/labels are unreliable or missing
Now, let’s walk through what Codegen generates step-by-step, and what small improvements we apply to make the workflow more stable and maintainable.
Step 1: Login
Why do we start with page.goto()?
Every workflow begins by loading the page under test:
await page.goto('https://qa-cart.com/');
goto() navigates the browser to the URL and waits for the page to start loading. Playwright will handle the timing better than manual sleeps—so you don’t need waitForTimeout().
Codegen output for Login
await page.goto('https://qa-cart.com/');
await page.getByRole('textbox', { name: 'Username or email address' }).fill('anuradha.learn@gmail.com');
await page.getByRole('textbox', { name: 'Password Required' }).fill('Play@1234#$');
await page.getByRole('button', { name: 'Log in' }).click();
We can add our own assertion after the page is loaded
await expect( page.getByRole('heading', { name: "Login" })).toBeVisible();Understanding getByRole() name: Accessible name vs HTML name
This Codegen locator:
page.getByRole('textbox', { name: 'Username or email address' })
works because Playwright reads the element’s accessible name (usually from the <label>), not the HTML name="username" attribute.
HTML name attribute (not used by getByRole)
<input name="username">
Used for:
form submission
backend processing
Accessible name (used by getByRole)
<label for="username">Username or email address</label>
<input id="username">
Because the label is linked to the input through for="username" and id="username", Playwright can reliably target the field by what the user sees.
Make Codegen Output Less Flaky
Matching the full label text can become brittle if the UI changes slightly (asterisks, “required”, spacing, etc.). A more stable approach is to match only the core keyword using a case-insensitive regex:
await page.getByRole('textbox', { name: /username/i })
.fill('anuradha.learn@gmail.com');
await page.getByRole('textbox', { name: /password/i })
.fill('****');
await page.getByRole('button', { name: /log in/i }).click();
finally we have

Step 2: Search for a Product
The next step is to go to DemoShop andf search for a product. In our example, we search for the keyword “organic” and verify that relevant results are displayed.
Codegen Output for Search
Playwright Codegen generates the following locator-based script:
await page.getByRole('searchbox', { name: 'Search' }).click();
await page.getByRole('searchbox', { name: 'Search' })
.fill('organic');
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.locator('h1'))
.toContainText('Search results: “organic”');
Understanding getByRole('searchbox')
The locator:
page.getByRole('searchbox', { name: 'Search' })
targets the search input using its accessibility role, not CSS.
Example HTML:
<input type="search" placeholder="Search products" aria-label="Search">
Playwright interprets this element as:
Role → searchbox
Accessible name → "Search"
This is more reliable than using CSS selectors like:
page.locator('input[type="search"]')
because role-based locators reflect how real users and assistive technologies interact with the application.
Note :
In case you want to verify yourself the role of given element:
Open Chrome DevTools:
Right-click the search input → Inspect
Click the Accessibility tab
You will see:
Role: searchbox
Name: Search
This is exactly what Playwright uses internally.

As we can note Playwright locators identify elements using the browser’s DOM combined with the accessibility tree, which represents how elements are structured and how users perceive them. While the DOM provides the element structure, the accessibility layer provides semantic meaning such as role (“button”, “textbox”) and name (“Login”, “Username”). This allows Playwright to create more stable, user-focused locators that are less dependent on fragile CSS selectors.
Other common automatic role mappings
HTML element | Playwright role |
<input type="text"> | textbox |
<input type="search"> | searchbox |
<button> | button |
<a> | link |
<h1> | heading |
<input type="checkbox"> | checkbox |
<input type="radio"> | radio |
Improving Stability Using Regex
To make the locator more resilient to UI text changes, we can use regex instead of matching the exact string:
await page.getByRole('searchbox', { name: /search/i })
.fill('organic');
await page.getByRole('button', { name: /search/i }).click();
This ensures the test continues to work even if the button text changes slightly, such as:
Search products
Search items
Search 🔍
Verifying Search Results Using User-Facing Assertion
Improving Codegen Output Using Semantic Locators
Playwright Codegen generates the following assertion to verify search results:
await expect(page.locator('h1'))
.toContainText('Search results: “organic”');
This works because the search results page displays the query inside an <h1> heading element. However, this locator relies on the HTML tag (h1), which is part of the DOM structure. If the application changes the heading from <h1> to <h2> or modifies the layout, this locator may break even though the functionality remains correct.
A more robust approach is to use a semantic locator based on the element’s role and accessible name:
await expect(
page.getByRole('heading', { name: /organic/i })
).toBeVisible();
Why is this approach more reliable
This locator works by identifying the element using:
Role: heading — automatically derived from heading elements like <h1>, <h2>, etc.
Accessible name: the visible text containing “organic”
This means the test remains stable even if:
the heading level changes (h1 → h2)
Additional text is added (Search results for organic products)
layout structure is modified
because the element’s semantic meaning (a heading containing the search keyword) remains the same.
Also it aligns with real user validation
This assertion verifies the page from a user perspective. Instead of checking a specific DOM element, it confirms that:
The results page loaded successfully
The searched keyword is visible to the user
This makes the test both more readable and more maintainable.
Finally we have
await expect(
page.getByRole('heading', { name: /organic/i })
).toBeVisible();
Semantic locators like getByRole('heading') are more stable than DOM-based locators like locator('h1') because they rely on the element’s meaning rather than its exact HTML structure.
Clearly Playwright Locators Make Search Automation More Reliable
Using built-in locators provides several advantages:
Auto-waits until the element is ready
Uses accessible roles instead of fragile CSS structure
More resilient to UI layout changes
Easier to read and maintain
When CSS Locator Fallback May Still Be Needed
In some applications, search inputs may not have proper accessibility attributes. In such cases, a CSS fallback is acceptable:
Step 3: Apply Price Filter (Max Price = 25)
Now that the search results are loaded, we apply a price filter. This step is important because it introduces a real-world automation challenge:
UI filters often update results asynchronously (AJAX + DOM re-render), and tests can accidentally read the old list if we don’t wait correctly.
Codegen Output for Price Filter
If you record this with Codegen, you typically get something like:
await page.getByRole('textbox', { name: 'Filter products by maximum price' }).fill('25');await page.getByRole('button', { name: 'Filter' }).click();Depending on the theme, Codegen might click the Filter button, or sometimes it may only fill the value.
Why don’t we type “$25”
Even though the UI shows $25.00, the textbox expects numeric input, because the $ symbol is UI formatting and currency rendering.
So we do:
const maxPrice = 25;
const maxPriceInput = page.getByRole("textbox", { name: /maximum price/i });
await maxPriceInput.clear();
await maxPriceInput.fill(String(maxPrice));await maxPriceInput.press("Enter");Issue we faced: “UI shows 2 results, but Playwright still reads 5 products”
This happened because the filter updates the page asynchronously. At that moment, Playwright may still be reading the previous search DOM (stale content).
Debugging we did
We looked for UI proof that the filter has been applied.
On your site, after the filter is applied, we see:
A filter “remove” button: Remove price up to …
An alert message that says: Showing all X results
These are perfect synchronisation points.
Final code: Wait for filter confirmation (stable)
await expect( page.getByRole("button", { name: /remove price up to/i })).toBeVisible();
await expect(page.getByRole("alert")).toContainText(/\d+ results/i);✅ This ensures that Playwright is now “in sync” with the filtered state.
Step 4: Identify the Product Grid Correctly (Role vs DOM reality)
Now we need to fetch products from the results grid.
What we tried initially (and why it failed)
We tried role-based list + listitem:
const productGrid = page.getByRole("list");
const items = productGrid.getByRole("listitem");But we got the wrong count (example: 5 instead of 2).
Why it happened
getByRole("listitem") returns all list items inside that list, including nested lists inside product cards.
So even if there are only 2 products, the page may still contain nested <li> elements for UI components, categories, etc.
Debugging insight
We inspected the DOM and found the actual product grid is:
<ul class="products">
<li class="product">...</li>
<li class="product">...</li>
</ul>So the “real product tiles” are direct children:
✅ ul.products > li.product
Final code: CSS fallback only where role is not sufficient
const productGrid = page.locator("ul.products").first();await expect(productGrid).toBeVisible();
const products = productGrid.locator(":scope > li.product");
console.log("Product tiles:", await products.count());✅ Prefer getByRole()
✅ But use CSS fallback when we must scope by structure/class.
Step 5: Validate Each Product Price is <= maxPrice
Now we validate that the filter actually worked. Instead of trusting the UI, we test like a real QA engineer:
✅ For every product displayed, ensure its price is not greater than the filter.
Codegen does not generate this automatically
This is where manual test logic begins.
The key challenge: price is text, not a number
Playwright returns:
"$15.00"So we convert it:
const price = parseFloat(priceText.replace("$", ""));Final code: Validate prices
const countItems = await products.count();
for (let i = 0; i < countItems; i++)
{ const product = products.nth(i);
const priceText = await product.locator("span.price bdi").first().innerText();
console.log("Raw price:", priceText);
const price = parseFloat(priceText.replace("$", ""));
console.log("Parsed price:", price);
expect(price).toBeLessThanOrEqual(maxPrice);
}✅ This confirms filter logic, not just UI.
Step 6: Add First Product to Cart (and capture product name)
After validation, we select the first product and add it to the cart.
Why capture product name?
Because we will verify that the same product appears in:
Cart page
Orders table
Order details page
Codegen output for Add to cart
Codegen often generates something like:
await page.getByRole('button', { name: 'Add to cart: “Pulses From Organic Farm”' }).click();Issue we faced: Add-to-cart confirmation is not shown as a message
Instead, the button changes state:
shows a ✅ tick
gets class added
may get class loading briefly
Final solution: wait for button state
const firstProduct = products.first();
const productName = (await firstProduct.locator(".woocommerce-loop-product__title").innerText()).trim();
console.log("Selected Product:", productName);
const addBtn = firstProduct.locator("a.add_to_cart_button");
await addBtn.click();
await expect(addBtn).not.toHaveClass(/loading/, { timeout: 15000 });
await expect(addBtn).toHaveClass(/added/, { timeout: 15000 });✅ This fixes the “cart sometimes empty” issue caused by navigating too early.
Step 7: Open Cart Page (Why clicking cart icon was flaky)
Issue we faced: the header cart link is unstable
Playwright logs showed:
element not stable
element detached from DOM
This happens because Astra header re-renders the cart icon when the count updates.
Best practice solution
Instead of clicking unstable UI:
await page.goto("https://qa-cart.com/mycart/");await expect(page).toHaveURL(/mycart/);Step 8: Verify Product Exists in Cart
const cartRows = page.locator("table.cart tr.cart_item");
await expect(cartRows.first()).toBeVisible();
let productFound = false;const countRows = await cartRows.count();
for (let i = 0; i < countRows; i++)
{ const cartProductName = (await cartRows.nth(i).locator("td.product-name").innerText()).trim();
console.log("Cart product:", cartProductName);
if (cartProductName.includes(productName))
{
productFound = true; break;
}
}
expect(productFound).toBeTruthy();Step 9: Proceed to Checkout
Issue we faced: networkidle caused timeouts
WooCommerce keeps background requests running, so networkidle is not reliable.
Final code: use URL + UI element
const checkoutLink = page.getByRole("link", { name: /proceed to checkout/i });
await checkoutLink.scrollIntoViewIfNeeded();
await expect(checkoutLink).toBeVisible();
await checkoutLink.click();
await expect(page).toHaveURL(/checkout/i, { timeout: 15000 });
await expect(page.getByRole("heading", { name: /checkout/i })).toBeVisible({ timeout: 15000 });
await expect(page.locator("form.checkout")).toBeVisible({ timeout: 15000 });Step 10: Place Order
const placeOrder = page.locator("#place_order");
await expect(placeOrder).toBeVisible({ timeout: 15000 });
await placeOrder.scrollIntoViewIfNeeded();await placeOrder.click();Step 11: Confirm Order Received + Extract orderId
await expect(page.getByText(/your order has been received/i)).toBeVisible({ timeout: 15000 });
const orderId = (await page.locator("ul.order_details > li.order strong").innerText()).trim();
console.log("OrderId:", orderId);Step 12: My Account → Orders → View order number {orderId}
await page.getByRole("link", { name: /my account/i }).click();
await expect(page.getByRole("heading", { name: /my account/i })).toBeVisible();
await page.getByRole("link", { name: /^orders$/i }).first().click();
await expect(page).toHaveURL(/orders/i);const ordersTable = page.locator("table.woocommerce-orders-table");
await expect(ordersTable).toBeVisible();
const viewOrderLink = ordersTable.getByRole("link", { name: `View order number ${orderId}` });
await expect(viewOrderLink).toBeVisible();
await viewOrderLink.click();await expect(page).toHaveURL(new RegExp(`view-order.*${orderId}`), { timeout: 15000 });
await expect( page.getByRole("heading", { name: new RegExp(`Order\\s*#${orderId}`, "i") })).toBeVisible();Step 13: Logout
await page.getByRole("link", { name: /my account/i }).click();
await expect(page.getByRole("link", { name: /log out/i }).first()).toBeVisible();await page.getByRole("link", { name: /log out/i }).first().click();Building Stable, User-Centric Automation with Playwright Locators
In this tutorial, we built a complete end-to-end e-commerce automation workflow using Playwright—from login and product search to checkout and order verification. More importantly, this exercise was not just about automating steps, but about understanding how to create stable, maintainable, and user-centric tests using Playwright locators.
From CSS Selectors to Playwright Locators: A Strategic Shift
At the beginning, we intentionally worked with traditional CSS selectors such as:
page.locator("ul.products > li.product")This helped us understand the DOM structure and how elements are organised internally. While CSS selectors are powerful, they are tightly coupled to the page structure. Even small UI changes—such as renaming a class or changing layout containers—can break tests without affecting the actual user experience.
As we progressed, we transitioned to Playwright’s built-in locators.
These locators are based on accessibility roles and visible names, which represent how real users and assistive technologies interact with the application.
This shift allowed us to create tests that are:
More aligned with user behaviour
Easier to read and understand
Less dependent on fragile DOM structure
More resilient to UI changes
In essence, we moved from DOM-centric automation to user-centric automation.
Why Playwright Locators Produce Less Flaky Tests
Playwright locators provide several built-in advantages:
Automatic waiting: Playwright automatically waits for elements to become visible, enabled, and stable before interacting with them.
Semantic targetingLocators use accessibility roles and visible text, making them more stable than CSS classes.
Improved readability, Code such as:
page.getByRole("button", { name: /place order/i })is easier to understand than:
page.locator(".checkout-button.alt.wc-forward")Synchronisation: The Biggest Real-World Challenge
One of the most important lessons from this workflow was that synchronisation is often the primary source of flaky tests.
We encountered several real-world issues:
Filter results updating asynchronously
Cart UI updating after the add-to-cart action
Dynamic header elements are being re-rendered
Checkout page loading background requests
These issues demonstrated that automation stability depends not only on locators, but also on correctly waiting for meaningful UI signals.
We used user-visible synchronization points such as:
await expect(page.getByRole("heading", { name: /checkout/i })).toBeVisible();
await expect(addBtn).toHaveClass(/added/);
await expect(page).toHaveURL(/mycart/);This approach ensures the test progresses only when the application is truly ready.
Key Takeaways from This Workflow
By completing this workflow, we achieved several important objectives:
Understood how Playwright locators work internally using accessibility roles
Learned how to transition from fragile CSS selectors to stable semantic locators
Built a complete real-world automation workflow
Debugged real synchronization issues and implemented proper solutions
Created tests that reflect actual user behavior rather than internal DOM structure
This is the foundation of building professional, production-ready automation frameworks.
What Comes Next: Refactoring Using Fixtures
While our current test is functional and stable, it still includes repeated setup steps, such as logging in and navigating. As automation frameworks grow, maintaining such code becomes difficult.
The next step in building a scalable automation framework is refactoring using Playwright Fixtures.
Fixtures allow us to:
Reuse setup logic such as login across tests
Reduce code duplication
Improve test readability
Improve maintainability and scalability
In the next post, we will begin the first step of refactoring by introducing Playwright Fixtures and restructuring our workflow into a cleaner, more modular framework.
This will mark the transition from writing individual tests to building a structured, maintainable automation framework.




Comments