top of page

Playwright Locators: Write Less Flaky, More Maintainable Tests + Codegen Demo on ECom Store

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:


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.

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:

  1. Right-click the search input → Inspect

  2. Click the Accessibility tab

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


Never Miss a Post. Subscribe Now!

Thanks for submitting!

©anuradha agarwal

    bottom of page