Most flaky UI tests do not fail because the business flow is complicated.
They fail because the locator strategy is fragile.
At the beginning, locator code usually feels easy. You inspect the page, copy a selector, maybe tweak a bit of XPath or CSS, and the test passes. For a while, that feels good enough.
Then the UI changes.
- A designer wraps a button in another container.
- A frontend refactor renames a class.
- A modal introduces a second “Confirm” button.
- A list gets reordered and
nth(1)clicks the wrong row.
Now the test still looks “correct,” but it is no longer pointing at the thing you meant.
That is the real problem.
The issue is not whether XPath is better than CSS, or whether text selectors are more readable than test IDs. The issue is that many test suites still treat a locator as a single string, when in practice it should be treated as a decision: a structured, ordered strategy for finding the intended element in a changing UI.
That is what this post is about.
I want to show a practical way to move from one-off selectors to a layered locator contract in Playwright: a small abstraction that captures how an element should be found, in what order, with what fallbacks, and under what scope.
Why “just write a selector” stops working at scale
Playwright’s locator model is built around resilience. Locators are the core of Playwright’s auto-waiting and retryability, and the docs recommend using built-in locators such as getByRole, getByLabel, getByText, getByPlaceholder, getByAltText, getByTitle, and getByTestId rather than jumping straight to CSS or XPath. The best-practices guide also explicitly recommends testing user-visible behavior instead of implementation details.
That recommendation reflects a deeper idea:
You usually do not want to find “the second button under this div.”
You want to find “the Submit Order button inside the Order Confirmation dialog.”
Those are not the same thing.
The first description is tied to implementation details.
The second is tied to user-facing meaning.
The more your locator depends on DOM structure, index, or styling, the more likely it is to break when the UI evolves. The more your locator reflects what the user actually sees and interacts with, the more likely it is to survive refactors. Playwright’s documentation makes that tradeoff pretty explicit, especially in its guidance around user-visible locators and its caution around CSS-based selectors and other implementation-coupled approaches.
A locator should be a contract, not a string
The shift I find most useful is this:
A locator is not a selector string.
A locator is a contract.
By “contract”, I mean a small piece of structured data that answers four questions:
- What is this element, in business terms?
- What scope does it live in?
- What is the preferred way to locate it?
- If that fails, how should the strategy degrade?
This changes the mental model completely.
Instead of storing a single answer, you store the reasoning behind the answer.
That matters because resilient UI automation is rarely about finding one perfect selector. It is about choosing the most meaningful locator first, then degrading in a controlled way only when necessary.
Playwright already points us toward a priority order
Playwright’s locator APIs are not all equal.
The docs recommend getByRole() as the primary way to locate interactive elements through explicit and implicit accessibility attributes, and the examples consistently pair role with accessible name. They also recommend getByLabel() for form controls, getByPlaceholder() for inputs, getByAltText() for images, getByTitle() where appropriate, and getByText() for visible text. At the same time, the “other locators” guide explicitly warns that CSS selectors are more tightly coupled to implementation details and can break when the page changes.
That suggests a real ordering principle.
A good locator strategy should prefer:
- user-facing semantics over DOM structure
- uniqueness over ambiguity
- stable meaning over transient layout
- explicit scope over global guessing
- controlled fallback over accidental matching
Once you see locators through that lens, the usual “XPath vs CSS vs text” debate becomes much less interesting.
A practical five-level locator strategy
Here is the hierarchy I use in Playwright.
It is not arbitrary. It follows the same logic Playwright’s own guidance points toward.
Level 1: Role-based, user-facing semantics
This is the highest-confidence layer.
Use getByRole() with an accessible name whenever possible, and narrow the scope if the page has more than one matching area.
await page.getByRole('button', { name: 'Submit Order' }).click();
Or inside a dialog:
await page
.getByRole('dialog', { name: 'Order Confirmation' })
.getByRole('button', { name: 'Submit Order' })
.click();
This is usually the most resilient form of locator because it expresses what the element is and what it is called from the user’s perspective. That is exactly the style Playwright recommends for many common interactions.
Level 2: Explicit control semantics
When role/name is not the best fit, the next layer is element-specific semantics:
getByLabel()getByPlaceholder()getByTitle()getByAltText()
These APIs map well to form fields, labeled controls, images, and descriptive attributes.
await page.getByLabel('Departure City').fill('Shanghai');
await page.getByPlaceholder('Enter your phone number').fill('13800000000');
await page.getByTitle('Close').click();
Playwright explicitly recommends these built-in locators for those cases.
Level 3: Visible text
Text is useful, but it is not always strong enough on its own.
await page.getByText('Payment Successful').click();
That can be fine for non-interactive content, banners, headings, or result messages. But for interactive controls such as buttons and links, text alone is often too weak because it lacks type information and is frequently duplicated.
This is why getByText() belongs below role-based and label-based strategies in a resilient hierarchy, even though it is still much better than raw DOM selectors in many cases. Playwright includes getByText() among its recommended locators, but its broader testing philosophy still favors user-visible meaning and interaction semantics over implementation detail.
Level 4: Scoped and relative locators
When a page contains repeated labels or repeated controls, you often need to add context.
This is where chaining and filtering become powerful. Playwright supports narrowing locators with filter({ hasText }) and filter({ has }), which is especially useful for lists, cards, repeated groups, and local regions of the page.
For example:
const row = page
.getByRole('listitem')
.filter({ hasText: 'MU5137' });
await row.getByRole('button', { name: 'Book' }).click();
Or:
const dialog = page.getByRole('dialog', { name: 'Payment Confirmation' });
await dialog.getByRole('button', { name: 'Confirm Payment' }).click();
This layer is still semantic, but it begins to rely on structural relationships between parent and child elements. That makes it slightly less stable than the first three layers, but still far better than a long XPath tied to the current DOM shape.
Level 5: Implementation-detail fallback
Only at the bottom should you fall back to:
- CSS selectors
- XPath
-
nth(),first(),last()
Examples:
await page.locator('.dialog-footer .primary-btn').click();
await page.locator('//div[@class="dialog-footer"]//button[2]').click();
await page.getByRole('button').nth(1).click();
Playwright’s docs are quite clear here: CSS selectors tied to implementation details are more brittle, and index-based disambiguation like nth() is risky because the page can change while the selector still technically matches something.
That does not mean you must never use these approaches. It means they should be treated as explicit fallback, not the primary expression of intent.
Where does getByTestId() fit?
getByTestId() deserves special treatment.
It does not fit neatly into the same ladder because it is not really “user-facing semantics,” but it is often one of the most stable locator mechanisms in real-world test suites. Playwright includes it among its recommended built-in locators, and it supports custom test ID attributes as well.
In practice, I treat test IDs as a parallel primary channel.
That means a robust locator contract can have two top-level strategies:
- a semantic primary path, based on role, label, or text
- an execution primary path, based on stable test IDs
This gives you the best of both worlds:
- human-readable intent
- engineering-grade stability
If your app exposes well-designed test IDs, there is no reason not to use them. Just do not confuse them with user-visible semantics. They serve a different purpose.
Why strict uniqueness matters
A good locator contract should aim for a single intended match.
Playwright’s locator model is intentionally strict in many contexts, and frame locators are strict as well. The docs explicitly note that frame locators throw if more than one frame matches, unless you narrow the selection yourself. That is not a limitation — it is a feature. It pushes your test code toward precision.
The important takeaway is this:
A resilient locator should not merely “find something.”
It should find the one thing you actually meant.
That is why scope comes before fallback, and why relative matching should be explicit instead of accidental.
From idea to code: a LocatorContract in TypeScript
Here is a practical implementation in Playwright and TypeScript.
Step 1: define the contract shape
// locator-contract.ts
import { expect, Page, Locator, FrameLocator } from '@playwright/test';
type Root = Page | Locator | FrameLocator;
type ScopeDef =
| { kind: 'role'; role: string; name?: string | RegExp }
| { kind: 'testId'; value: string }
| { kind: 'css'; value: string };
type StrategyDef =
| { level: 1; kind: 'role'; role: string; name: string | RegExp }
| { level: 1; kind: 'testId'; value: string }
| { level: 2; kind: 'label'; value: string | RegExp }
| { level: 2; kind: 'placeholder'; value: string | RegExp }
| { level: 2; kind: 'title'; value: string | RegExp }
| { level: 2; kind: 'alt'; value: string | RegExp }
| { level: 3; kind: 'text'; value: string | RegExp; exact?: boolean }
| {
level: 4;
kind: 'scopedRole';
containerRole: string;
containerName?: string | RegExp;
targetRole: string;
targetName: string | RegExp;
}
| { level: 5; kind: 'css'; value: string }
| { level: 5; kind: 'xpath'; value: string };
export interface LocatorContract {
name: string;
frame?: string;
scope?: ScopeDef[];
strategies: StrategyDef[];
}
The important part is not the syntax. It is the shape of the idea:
- scope is modeled explicitly
- locator strategies are layered
- fallback is part of the design, not an afterthought
Step 2: resolve the contract into a Playwright locator
// locator-contract.ts
function applyScope(root: Root, scope: ScopeDef): Root {
switch (scope.kind) {
case 'role':
return root.getByRole(scope.role as any, scope.name ? { name: scope.name } : {});
case 'testId':
return root.getByTestId(scope.value);
case 'css':
return root.locator(scope.value);
}
}
function buildCandidate(root: Root, strategy: StrategyDef): Locator {
switch (strategy.kind) {
case 'role':
return root.getByRole(strategy.role as any, { name: strategy.name });
case 'testId':
return root.getByTestId(strategy.value);
case 'label':
return root.getByLabel(strategy.value);
case 'placeholder':
return root.getByPlaceholder(strategy.value);
case 'title':
return root.getByTitle(strategy.value);
case 'alt':
return root.getByAltText(strategy.value);
case 'text':
return root.getByText(strategy.value, { exact: strategy.exact ?? false });
case 'scopedRole': {
const container = root.getByRole(
strategy.containerRole as any,
strategy.containerName ? { name: strategy.containerName } : {}
);
return container.getByRole(strategy.targetRole as any, { name: strategy.targetName });
}
case 'css':
return root.locator(strategy.value);
case 'xpath':
return root.locator(`xpath=${strategy.value}`);
}
}
export async function resolveLocator(
page: Page,
contract: LocatorContract
): Promise<Locator> {
let root: Root = page;
if (contract.frame) {
root = page.locator(contract.frame).contentFrame();
}
for (const scope of contract.scope ?? []) {
root = applyScope(root, scope);
}
const ordered = [...contract.strategies].sort((a, b) => a.level - b.level);
for (const strategy of ordered) {
const candidate = buildCandidate(root, strategy);
const count = await candidate.count();
if (count === 1) {
await expect(candidate).toBeVisible();
return candidate;
}
}
throw new Error(`No unique locator matched contract: ${contract.name}`);
}
This resolver does three things in order:
- narrow to the right frame if needed
- narrow to the correct scope
- try strategies from highest confidence to lowest confidence
That sequencing matters.
You do not want fallback to compensate for missing context.
You want context first, fallback second.
A concrete example
Suppose you want to target the Submit Order button inside an Order Confirmation dialog.
Define the contract
// contracts.ts
import type { LocatorContract } from './locator-contract';
export const submitOrderButtonContract: LocatorContract = {
name: 'Submit Order button',
scope: [
{ kind: 'role', role: 'dialog', name: 'Order Confirmation' },
],
strategies: [
{ level: 1, kind: 'role', role: 'button', name: 'Submit Order' },
{ level: 1, kind: 'testId', value: 'submit-order' },
{ level: 2, kind: 'title', value: 'Submit Order' },
{ level: 3, kind: 'text', value: 'Submit Order', exact: true },
{
level: 4,
kind: 'scopedRole',
containerRole: 'group',
containerName: 'Order Details',
targetRole: 'button',
targetName: 'Submit Order',
},
{ level: 5, kind: 'css', value: '.dialog-footer .primary-btn' },
{ level: 5, kind: 'xpath', value: '//div[contains(@class,"dialog-footer")]//button[last()]' },
],
};
Use it in a test
// order-confirm.spec.ts
import { test, expect } from '@playwright/test';
import { resolveLocator } from './locator-contract';
import { submitOrderButtonContract } from './contracts';
test('submit order', async ({ page }) => {
await page.goto('https://example.com/order/confirm');
const submitButton = await resolveLocator(page, submitOrderButtonContract);
await submitButton.click();
await expect(page.getByText('Order submitted successfully')).toBeVisible();
});
The test reads like a business action.
The contract captures the locator logic.
Those two concerns are now separated.
That separation is where a lot of maintainability comes from.
Why this is better than “a really good XPath”
Because XPath only answers one narrow question:
How do I select it right now?
A locator contract answers a more useful set of questions:
- What is this element supposed to represent?
- What is the strongest semantic signal available?
- What context should narrow the search?
- How should the strategy degrade if the preferred path is unavailable?
- Which fallback is acceptable, and which one is just emergency-only?
In other words, you are no longer encoding a selector.
You are encoding intent.
That is the difference between test code that survives UI evolution and test code that constantly needs patching.
Treat locator contracts as test assets
Once you adopt this pattern, do not leave the contracts scattered across spec files.
Treat them as a first-class layer in your test codebase.
A practical structure might look like this:
- page-level contract modules
- one named contract per important control
- spec files that consume contracts instead of hand-writing selectors
- locator maintenance separated from business-flow maintenance
This naturally creates two layers:
business action layer
What the test is trying to do
locator contract layer
How the intended element should be found, with scope and fallback
That boundary is healthy. It keeps tests readable and keeps locator decisions centralized.
Final thought
The biggest mistake in UI automation is not choosing the wrong selector syntax.
It is treating element location as a one-line implementation detail.
In Playwright, a better model is to treat element location as a structured decision:
- prefer user-facing semantics
- prefer uniqueness
- prefer explicit scope
- use test IDs as a strong parallel contract
- reserve CSS, XPath, and index-based selection for controlled fallback
Once you make that shift, locator code becomes far less brittle — and much easier to reason about.
The point is not to eliminate fallback.
The point is to make fallback intentional.
That is what a locator contract gives you.
You can find the open-source implementation of the code examples in this article at:
https://github.com/bohewei123/playwright-locator-contract
If you find this project helpful, please give me a star! Thanks :-)
Top comments (0)