Definition
- Dynamic IDs – Frameworks generate class names and element IDs at build or render time, so selectors tied to them break on every build.
- getByRole – Finds by accessible role and name (button, heading, textbox), mirroring how users and screen readers perceive controls. Most reliable.
- getByLabel – Finds form fields by their associated label text, ideal for inputs.
- getByText – Finds by visible text, appropriate when the words are the real identifier.
- getByTestId – Uses a deliberate stable attribute as a contract for elements with no semantic handle.
- Preference order – getByRole > getByLabel > getByText > getByTestId > raw CSS/XPath as last resort.
On the National Bank of India transfer screen, the sign-in button might be generated with a random internal identifier like "btn-9f3a2" that changes every rebuild. If the robot looks for that exact random name, it breaks the next day. Instead the robot finds the button the way Rajesh does: "the button that says Sign In." That meaning does not change when developers reshuffle the code.
Understanding the Concepts
Why do dynamic IDs cause flakiness?
Dynamic IDs and attributes come from frameworks that generate markup at build or render time. A selector pinned to a generated class matches today and misses tomorrow, producing failures that look like bugs but are really locator rot.
// FRAGILE: tied to generated IDs that change every build
await page.locator('#btn-9f3a2').click(); // Breaks next build
await page.locator('.css-1a2b3c > div > button').click(); // Breaks on restructure
await page.locator('//div[@class="generated"]/button').click(); // XPath, same problem
How does getByRole work?
getByRole finds an element by its accessibility role and accessible name, which mirrors how users and screen readers identify controls. It is the most recommended locator.
// STABLE: role-based locators
await page.getByRole('button', { name: 'Sign In' }).click();
await page.getByRole('heading', { name: 'Dashboard' }).isVisible();
await page.getByRole('link', { name: 'View Statement' }).click();
await page.getByRole('textbox', { name: 'Amount' }).fill('5000');
await page.getByRole('checkbox', { name: 'Remember me' }).check();
await page.getByRole('alert').textContent(); // Error messages
How does getByLabel work?
getByLabel finds form fields by their associated label text. Users identify inputs by their labels, not their internal IDs.
// Form fields by label
await page.getByLabel('Username').fill('rajesh@nationalbank.in');
await page.getByLabel('Password').fill(process.env.TEST_PASS!);
await page.getByLabel('Transfer Amount').fill('50000');
await page.getByLabel('Beneficiary Account').fill('ACC-002');
When should you use getByText?
getByText is appropriate when the visible text is genuinely the right identifier, such as a confirmation message or a uniquely labelled link.
// Content assertions by visible text
await expect(page.getByText('Transfer Successful')).toBeVisible();
await expect(page.getByText('Insufficient Balance')).toBeVisible();
await page.getByText('View all transactions').click();
When should you use getByTestId?
When no user-facing handle exists, add a deliberate stable test attribute. This is a contract between developers and testers.
<!-- Developer adds a stable test ID --syn-kw">>
syn-kw"><div data-testid="account-balance-widget"syn-kw">>
syn-kw"><spansyn-kw">>Rs 9,50,000.00syn-kw"></spansyn-kw">>
syn-kw"></divsyn-kw">>
// Test uses the stable test ID
const balance = await page.getByTestId('account-balance-widget').textContent();
What is the locator preference order?
| Priority | Locator | When to Use | Stability |
|---|---|---|---|
| 1 | getByRole | Interactive elements, headings, landmarks | Best |
| 2 | getByLabel | Form fields (inputs, selects) | Best |
| 3 | getByText | Content, messages, unique links | Good |
| 4 | getByPlaceholder | Inputs without labels (fix the label!) | Good |
| 5 | getByTestId | No semantic handle, decorative elements | Good |
| 6 | CSS/XPath | Last resort, always scoped | Fragile |
// Complete Page Object using the preference order
import { Page, Locator } from '@playwright/test';
export class TransferPage {
readonly fromAccount: Locator; // getByLabel (form field)
readonly toAccount: Locator; // getByLabel (form field)
readonly amount: Locator; // getByLabel (form field)
readonly transferButton: Locator; // getByRole (button)
readonly successMessage: Locator; // getByText (content)
readonly balanceWidget: Locator; // getByTestId (no semantic handle)
constructor(private page: Page) {
this.fromAccount = page.getByLabel('From Account');
this.toAccount = page.getByLabel('To Account');
this.amount = page.getByLabel('Amount');
this.transferButton = page.getByRole('button', { name: 'Transfer' });
this.successMessage = page.getByText('Transfer Successful');
this.balanceWidget = page.getByTestId('account-balance');
}
}
How do you scope locators for precision?
When a selector matches multiple elements, scope it within a container.
// Scoped: find the Transfer button within a specific row
const accountRow = page.getByRole('row', { name: 'ACC-001' });
await accountRow.getByRole('button', { name: 'Transfer' }).click();
// Without scoping, 'Transfer' button might match in every row
Why do stable locators improve accessibility?
Writing role-based tests nudges the application toward correct ARIA roles and labels. If a button does not have an accessible name, the test cannot find it, which exposes the accessibility gap.
// This test will fail if the button lacks an accessible name
await page.getByRole('button', { name: 'Submit Transfer' }).click();
// Developer is forced to add aria-label or visible text
// Result: both the test and screen readers work
What are common interview follow-up questions?
- "What if two buttons have the same text?" Scope one within its container using
.filter()or a parent locator. - "Should developers add test IDs to every element?" No. Only where no semantic locator exists. Over-using test IDs bypasses accessibility alignment.
- "How do you handle localized applications?" Use getByRole with ARIA labels that are language-independent, or use getByTestId for localized text.
Visual Overview
Locator Preference Order
────────────────────────
Priority 1: getByRole
┌──────────────────────────────────┐
│ button, heading, link, textbox, │
│ checkbox, alert, navigation │
│ Most reliable + accessible │
└──────────────────────────────────┘
│ if no role fits
▼
Priority 2: getByLabel
┌──────────────────────────────────┐
│ Form fields by their label text │
│ Best for inputs and selects │
└──────────────────────────────────┘
│ if no label
▼
Priority 3: getByText
┌──────────────────────────────────┐
│ Content, messages, unique links │
│ Risk: text changes for marketing │
└──────────────────────────────────┘
│ if no unique text
▼
Priority 4: getByTestId
┌──────────────────────────────────┐
│ Deliberate stable attribute │
│ Contract: devs + testers agree │
└──────────────────────────────────┘
│ last resort
▼
Priority 5: CSS / XPath
┌──────────────────────────────────┐
│ Fragile, scope tightly │
│ Avoid if possible │
└──────────────────────────────────┘
Key Takeaway
When an application generates dynamic IDs and class names, any selector tied to those generated values breaks on every build, so I never pin locators to implementation details. Instead I locate elements by their meaning, which rarely changes. My preference order is: getByRole first because it is the most reliable and accessibility-aligned, then getByLabel for form fields, then getByText for content, then getByTestId as a deliberate stable contract for elements with no semantic handle, and raw CSS or XPath only as a scoped last resort.
Stable locators reduce flakiness because much of what looks like timing flakiness is actually locator rot, a selector that intermittently matches the wrong element or stops matching after a markup change. They reduce maintenance because a UI refactor does not break them as long as the button is still a button named "Transfer." And they improve accessibility by nudging the application toward correct roles and labels.
At the National Bank of India our app regenerated element IDs on every build, and the original suite, pinned to those IDs and nth-child paths, failed constantly. I rewrote the page objects using getByRole and getByLabel, added a small set of agreed test identifiers, and worked with developers to expose proper roles and labels. The flakiness dropped sharply and tests stopped needing edits after each release.
Remember one sentence: locate elements by their meaning with getByRole, getByLabel, and getByText, not by generated IDs, because meaning is stable while markup churns.
---