Learn Playwright in 10 DaysDay 3: Locators and DOM Interaction

Day 3: Locators and DOM Interaction

What You'll Learn Today

  • Built-in locators: getByRole, getByText, getByLabel, and more
  • Why getBy* locators are preferred over CSS/XPath
  • CSS selectors and XPath as fallback options
  • Filtering locators with filter() and nth()
  • Chaining locators with locator()
  • Working with iframes via frameLocator()
  • Shadow DOM interaction
  • Locator best practices tier list

The Locator Landscape

Playwright's locators are the mechanism for finding elements on a page. Unlike other tools such as Cypress, Playwright provides user-facing locators as first-class APIs.

flowchart TB
    subgraph Tier1["Tier 1: User-Facing Locators (Most Recommended)"]
        ROLE["getByRole()"]
        TEXT["getByText()"]
        LABEL["getByLabel()"]
    end
    subgraph Tier2["Tier 2: Semantic Locators"]
        PLACEHOLDER["getByPlaceholder()"]
        ALTTEXT["getByAltText()"]
        TITLE["getByTitle()"]
    end
    subgraph Tier3["Tier 3: Test-Specific Locators"]
        TESTID["getByTestId()"]
    end
    subgraph Tier4["Tier 4: Fallback"]
        CSS["CSS Selectors"]
        XPATH["XPath"]
    end
    style Tier1 fill:#22c55e,color:#fff
    style Tier2 fill:#3b82f6,color:#fff
    style Tier3 fill:#f59e0b,color:#fff
    style Tier4 fill:#ef4444,color:#fff

getByRole() - Role-Based Locators

getByRole() is the most recommended locator in Playwright. It selects elements based on their ARIA role, ensuring your tests align with accessibility best practices.

// Get a button
await page.getByRole('button', { name: 'Sign In' }).click();

// Get a link
await page.getByRole('link', { name: 'Go to Home' }).click();

// Get a textbox
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');

// Get a checkbox
await page.getByRole('checkbox', { name: 'Agree to terms' }).check();

// Get a heading
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();

// Get navigation
const nav = page.getByRole('navigation');

Common ARIA Roles

Role HTML Elements Description
button <button>, <input type="submit"> Buttons
textbox <input type="text">, <textarea> Text inputs
checkbox <input type="checkbox"> Checkboxes
radio <input type="radio"> Radio buttons
link <a href="..."> Links
heading <h1> through <h6> Headings
list <ul>, <ol> Lists
listitem <li> List items
combobox <select> Select dropdowns
navigation <nav> Navigation landmarks

getByRole() Options

// name: Filter by accessible name
page.getByRole('button', { name: 'Submit' });

// exact: Exact match (default is substring match)
page.getByRole('button', { name: 'Submit', exact: true });

// checked: Filter by checked state
page.getByRole('checkbox', { checked: true });

// expanded: Filter by expanded state
page.getByRole('button', { expanded: true });

// level: Filter heading level
page.getByRole('heading', { level: 2 });

getByText() - Locate by Visible Text

Select elements by the text content visible to users on screen.

// Get element by text (substring match)
await page.getByText('Welcome').click();

// Exact match
await page.getByText('Welcome', { exact: true }).click();

// Regular expression
await page.getByText(/Total: \d+ items/).isVisible();

getByText() returns the smallest element containing the text node. For buttons or links, prefer getByRole() instead.


getByLabel() - Locate by Label Text

Select form inputs based on their associated <label> text.

// Get input fields by label text
await page.getByLabel('Email address').fill('user@example.com');
await page.getByLabel('Password').fill('secret123');
await page.getByLabel('Age').selectOption('30');
await page.getByLabel('Agree to terms').check();
<!-- Corresponding HTML -->
<label for="email">Email address</label>
<input id="email" type="email" />

<label>
  <input type="checkbox" /> Agree to terms
</label>

getByPlaceholder() - Locate by Placeholder

await page.getByPlaceholder('Enter your email').fill('user@example.com');
await page.getByPlaceholder('Search...').fill('Playwright');

getByAltText() and getByTitle()

// Get image by alt text
await page.getByAltText('Company Logo').click();

// Get element by title attribute
await page.getByTitle('Close').click();

getByTestId() - Test-Specific IDs

Use data-testid attributes as a fallback when other locators cannot uniquely identify an element.

// Get element by data-testid attribute
await page.getByTestId('submit-button').click();
await page.getByTestId('user-avatar').isVisible();
<button data-testid="submit-button">Submit</button>
<img data-testid="user-avatar" src="/avatar.png" />

Customizing the Test ID Attribute

By default, Playwright uses data-testid, but you can customize this in playwright.config.ts.

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    testIdAttribute: 'data-cy', // Use Cypress-compatible attribute name
  },
});

Why getBy* Locators Are Preferred

flowchart LR
    subgraph Problem["Problems with CSS/XPath"]
        P1["Coupled to implementation"]
        P2["Break on refactoring"]
        P3["Unrelated to user experience"]
    end
    subgraph Solution["Benefits of getBy*"]
        S1["Written from user perspective"]
        S2["Aligned with accessibility"]
        S3["Resilient to implementation changes"]
    end
    Problem -->|"Solved by"| Solution
    style Problem fill:#ef4444,color:#fff
    style Solution fill:#22c55e,color:#fff
Aspect CSS/XPath getBy* Locators
Readability page.locator('.btn-primary.submit') page.getByRole('button', { name: 'Submit' })
Durability Breaks when class names change Stable as long as text/role remains
Accessibility No relationship Leverages accessibility attributes
Intent clarity Describes implementation structure Describes user actions

CSS Selectors and XPath (Fallback)

When getBy* locators cannot identify an element, use page.locator() with CSS selectors or XPath.

// CSS selectors
await page.locator('.product-card').first().click();
await page.locator('#main-content').isVisible();
await page.locator('input[type="search"]').fill('keyword');
await page.locator('nav > ul > li:first-child a').click();

// XPath
await page.locator('xpath=//div[@class="content"]//p[contains(text(), "important")]').click();
await page.locator('xpath=//table//tr[3]/td[2]').textContent();

CSS selectors and XPath are coupled to implementation details. Use them only as a fallback when getBy* locators are insufficient.


Filtering Locators with filter()

filter() lets you narrow down an existing locator with additional conditions.

// Filter by text
const items = page.getByRole('listitem');
await items.filter({ hasText: 'Completed' }).count();

// Filter by regex
await items.filter({ hasText: /^Task \d+$/ }).first().click();

// Filter for elements NOT containing text
await items.filter({ hasNotText: 'Completed' }).count();

// Filter by presence of a child element
await page.getByRole('listitem').filter({
  has: page.getByRole('button', { name: 'Delete' }),
}).count();

// Filter by absence of a child element
await page.getByRole('listitem').filter({
  hasNot: page.getByRole('img'),
}).count();

Practical filter() Examples

// Get only "In Stock" products from a product list
const products = page.locator('.product-card');
const inStock = products.filter({ hasText: 'In Stock' });
await expect(inStock).toHaveCount(3);

// Get only rows that have a "Delete" button
const rows = page.getByRole('row');
const deletableRows = rows.filter({
  has: page.getByRole('button', { name: 'Delete' }),
});
await deletableRows.first().getByRole('button', { name: 'Delete' }).click();

Positional Selection with nth()

// Get the Nth element (0-based index)
await page.getByRole('listitem').nth(0).click();  // First element
await page.getByRole('listitem').nth(2).click();  // Third element
await page.getByRole('listitem').nth(-1).click(); // Last element

// first() and last() shortcuts
await page.getByRole('listitem').first().click();
await page.getByRole('listitem').last().click();

Chaining Locators with locator()

Chain locators to search for child elements within a parent.

// "Add to Cart" button inside a .product-card
await page.locator('.product-card').first()
  .getByRole('button', { name: 'Add to Cart' }).click();

// Link inside navigation
await page.getByRole('navigation')
  .getByRole('link', { name: 'Blog' }).click();

// Button inside a specific table row
await page.getByRole('row', { name: 'John Doe' })
  .getByRole('button', { name: 'Edit' }).click();
flowchart TB
    subgraph Chain["Locator Chaining"]
        PARENT["page.locator('.product-card')"]
        FILTER["  .filter({ hasText: 'In Stock' })"]
        CHILD["    .getByRole('button', { name: 'Buy' })"]
    end
    PARENT --> FILTER --> CHILD
    style Chain fill:#3b82f6,color:#fff

Working with iframes via frameLocator()

Use frameLocator() to access elements inside an <iframe>.

// Interact with elements inside an iframe
await page.frameLocator('#payment-iframe')
  .getByLabel('Card Number').fill('4242424242424242');

await page.frameLocator('#payment-iframe')
  .getByLabel('Expiry Date').fill('12/30');

await page.frameLocator('#payment-iframe')
  .getByRole('button', { name: 'Pay' }).click();

Nested iframes

// Access an iframe inside another iframe
await page.frameLocator('#outer-frame')
  .frameLocator('#inner-frame')
  .getByRole('button', { name: 'Confirm' }).click();

Multiple iframes

// When there are multiple iframes, use nth() to specify
await page.frameLocator('iframe').nth(0)
  .getByText('Content').isVisible();

// Or identify by attribute
await page.frameLocator('iframe[name="editor"]')
  .locator('.editor-content').fill('Some text');

Shadow DOM Interaction

Playwright handles Shadow DOM transparently by default. You can access elements inside Shadow DOM without any special configuration.

// Elements inside Shadow DOM can be found with standard locators
await page.getByRole('button', { name: 'Shadow Button' }).click();
await page.locator('custom-element').getByText('Inner text').isVisible();
<!-- Web Component example -->
<custom-dialog>
  #shadow-root
    <div class="dialog-content">
      <p>Inner text</p>
      <button>Shadow Button</button>
    </div>
</custom-dialog>

Cypress requires cy.shadow() to pierce Shadow DOM, but Playwright accesses shadow elements directly. This is one of Playwright's key advantages.


Locator Best Practices Tier List

Tier Locator Recommendation Reason
S getByRole() Most recommended Best for both accessibility and testing
A getByLabel(), getByText() Recommended Intuitive, user-facing
B getByPlaceholder(), getByAltText() Situational Effective for specific use cases
C getByTestId() Acceptable Fallback when other methods fail
D page.locator() (CSS) Discouraged Coupled to implementation details
F page.locator() (XPath) Last resort Fragile and hard to read

Practical Decision Flow

flowchart TB
    START["Need to locate an element"] --> Q1{"Can you identify it\nby role and name?"}
    Q1 -->|Yes| ROLE["Use getByRole()"]
    Q1 -->|No| Q2{"Does it have\na label?"}
    Q2 -->|Yes| LABEL["Use getByLabel()"]
    Q2 -->|No| Q3{"Can you identify\nit by text?"}
    Q3 -->|Yes| TEXT["Use getByText()"]
    Q3 -->|No| Q4{"Can you add\na data-testid?"}
    Q4 -->|Yes| TESTID["Use getByTestId()"]
    Q4 -->|No| CSS["Use page.locator()"]
    style ROLE fill:#22c55e,color:#fff
    style LABEL fill:#22c55e,color:#fff
    style TEXT fill:#3b82f6,color:#fff
    style TESTID fill:#f59e0b,color:#fff
    style CSS fill:#ef4444,color:#fff

Practical Example: E-Commerce Product Test

import { test, expect } from '@playwright/test';

test.describe('Product Listing Page', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/products');
  });

  test('can add an in-stock product to cart', async ({ page }) => {
    // Get the first in-stock product card
    const inStockProduct = page.getByRole('listitem')
      .filter({ hasText: 'In Stock' })
      .first();

    // Capture the product name
    const productName = await inStockProduct
      .getByRole('heading').textContent();

    // Add to cart
    await inStockProduct
      .getByRole('button', { name: 'Add to Cart' }).click();

    // Verify success notification
    await expect(page.getByText(`${productName} added to cart`))
      .toBeVisible();

    // Verify cart badge updated
    await expect(page.getByRole('navigation')
      .getByTestId('cart-badge')).toHaveText('1');
  });

  test('can search and filter products', async ({ page }) => {
    // Enter keyword in search box
    await page.getByRole('searchbox').fill('TypeScript');
    await page.getByRole('button', { name: 'Search' }).click();

    // Verify results are displayed
    const results = page.getByRole('listitem');
    await expect(results).not.toHaveCount(0);

    // Verify each result contains the keyword
    for (const item of await results.all()) {
      await expect(item).toContainText(/TypeScript/i);
    }
  });
});

Summary

Concept Description
getByRole() The most recommended locator, selecting elements by ARIA role and accessible name
getByText() / getByLabel() User-facing locators that find elements by visible text or label
getByTestId() Fallback locator using test-specific attributes
page.locator() Last resort using CSS selectors or XPath
filter() Narrow locators with hasText, has, and hasNot conditions
nth() / first() / last() Select elements by position
Locator chaining Search for child elements within a parent locator
frameLocator() Access elements inside iframes
Shadow DOM Playwright handles it transparently by default

Key Takeaways

  1. Make getByRole() your first choice - Role-based locators improve both test readability and accessibility awareness.
  2. Write tests based on what users see - Use text, labels, and roles instead of CSS classes or internal structure.
  3. Treat page.locator() as a last resort - Before reaching for CSS selectors or XPath, consider whether a getBy* locator can solve the problem.

Practice Exercises

Exercise 1: Basic

Write a Playwright test for the login form described by the following HTML.

<form>
  <label for="email">Email address</label>
  <input id="email" type="email" placeholder="example@mail.com" />
  <label for="password">Password</label>
  <input id="password" type="password" />
  <button type="submit">Sign In</button>
</form>

Exercise 2: Applied

Write a test that finds a specific user row in a table and clicks the "Edit" button, using locator chaining and filter().

Challenge Exercise

Write a test that fills in a payment form embedded in an iframe (card number, expiry date, CVC) and clicks the "Confirm Payment" button.


References


Next Up: In Day 4, we'll learn about page operations and forms. You'll master page navigation, form input, select dropdowns, file uploads, and dialog handling for practical page interactions.