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, prefergetByRole()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
- Make getByRole() your first choice - Role-based locators improve both test readability and accessibility awareness.
- Write tests based on what users see - Use text, labels, and roles instead of CSS classes or internal structure.
- 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
- Locators - Playwright Documentation
- Other Locators - Playwright Documentation
- Frame Locators - Playwright Documentation
- ARIA Roles - MDN Web Docs
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.