Locators
When to Use
Targeting elements in tests. The locator API is the single most important thing to internalize — every modern Playwright test should be ~95% locators.
Decision
Choose locators in this order, falling back only when a higher tier is infeasible:
page.getByRole()— accessibility role + accessible name. Doubles as a soft a11y checkpage.getByLabel()— for form fields. Resilient to markup churnpage.getByPlaceholder()/getByText()/getByAltText()/getByTitle()— user-visible attributespage.getByTestId()— explicit test contract viadata-testid(configurable)- CSS selectors — only when no semantic anchor exists. Prefer attribute selectors (
[data-foo="bar"]) over class chains - XPath — last resort
Pattern: Each Locator with Idiomatic Call
// Role — pass accessible name; exact:false matches substring; regex allowed
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('heading', { level: 2, name: /Latest news/i });
await page.getByRole('link', { name: 'Read more', exact: true });
// Text — visible text content; default substring + case-insensitive trim
await expect(page.getByText('Welcome, Carlos')).toBeVisible();
await page.getByText('Welcome', { exact: true });
await page.getByText(/order \d{6}/);
// Label — input/textarea/select via associated <label>
await page.getByLabel('Email').fill('user@example.com');
// Placeholder, alt, title
await page.getByPlaceholder('Search articles').fill('Drupal');
await page.getByAltText('Company logo').click();
await page.getByTitle('Close dialog').click();
// Test ID — default attribute is data-testid
await page.getByTestId('nav-toggle').click();
Pattern: testIdAttribute Config
By default getByTestId reads data-testid. To target a different attribute (e.g. ATK's data-qa-id):
// playwright.config.ts
export default defineConfig({
use: {
testIdAttribute: 'data-qa-id',
},
});
Now page.getByTestId('login-form') matches <form data-qa-id="login-form">.
Pattern: Chaining and Filtering
The locator API composes. Each call returns a new Locator, lazy-evaluated at use time:
// Chain: scope inside a parent
const card = page.getByRole('article', { name: 'Welcome to Drupal' });
await card.getByRole('button', { name: 'Edit' }).click();
// .filter({ hasText }) — keep elements whose subtree contains text
await page.getByRole('listitem')
.filter({ hasText: 'Premium' })
.getByRole('button', { name: 'Buy' })
.click();
// .filter({ has: locator }) — keep elements containing a child locator
await page.getByRole('row')
.filter({ has: page.getByRole('cell', { name: 'Active' }) })
.getByRole('button', { name: 'Disable' })
.click();
// .filter({ hasNot, hasNotText }) — negative filters
await page.getByRole('listitem').filter({ hasNotText: 'Sold out' });
// .and() / .or() — composing locators on the same node
const primary = page.getByRole('button').and(page.locator('.btn-primary'));
const dialog = page.getByRole('dialog').or(page.getByRole('alertdialog'));
// Positional — first/last/nth — use sparingly; indicates test smell
await page.getByRole('listitem').first();
await page.getByRole('listitem').nth(2);
Best-Practice Rules
- One locator per concept:
const submitBtn = page.getByRole('button', { name: 'Submit' })once at the top, reuse - Never assert on locator count without a filter —
.toHaveCount(3)on a generic role is brittle; pair with.filter() - Locators are lazy — storing
const x = page.getByText('Loading')doesn't query the DOM until the next action/assertion - Avoid
.first()as a shortcut — silently hides "two elements match" bugs; use.filter()to disambiguate or.toHaveCount(1)to assert uniqueness - Don't mix locators with raw
page.evaluate(() => document.querySelector(...))— loses auto-wait, locator highlight, retry
Common Mistakes
- Brittle CSS chains like
.btn.btn-primary.mt-3 > span:nth-child(2)— break on every theme refactor - Class-name selectors for Drupal-emitted markup that varies by render context — use
data-drupal-selectoror role-based locators - Porting Cypress
cy.get('.foo')literally — translate togetByRole/getByTextinstead
See Also
- Web-First Assertions — what to assert after locating
- ATK Integration — using
data-qa-idwith ATK's selector hooks - Reference: Playwright Locators