How to Write Playwright Locators That Survive a UI Refactor

A Playwright locator is more likely to survive a UI refactor when it identifies an element by what it means to the user, rather than how the DOM happens to be structured. Prefer locators such as getByRole(), getByLabel(), and stable test IDs over long CSS/XPath chains, generated classes, or element position.

For example, instead of:

await page
  .locator('.btn-primary.save-button')
  .click();

prefer:

await page
  .getByRole('button', { name: 'Save' })
  .click();

That helps with good locator design, but when a refactor does invalidate a locator, you still have to diagnose and repair the affected test yourself.

Leapwork Play reduces that maintenance work by recording the Playwright interaction with additional locator information and using self-healing when an interaction can no longer find the intended element.

Try Leapwork Play →

How does Leapwork Play handle locator changes?

When Play records an interaction, the test uses a normal Playwright locator for the element.

For example:

await page
  .getByRole('button', {
    name: 'Continue to payment'
  })
  .click();

The Play step can also retain additional locator information, including a relativeXpath:

await leapwork.step(
  'Click Continue to payment',
  async () => {
    await page
      .getByRole('button', {
        name: 'Continue to payment'
      })
      .click();
  },
  {
    action: 'click',
    relativeXpath: '...'
  }
);

If the primary locator can no longer find the intended element, Play can use the additional locator information as part of its recovery workflow instead of immediately requiring the test author to rewrite the interaction.

If the element still cannot be found, Self-healing can attempt to recover the intended interaction.

How to make locators survive a UI refactor

Use locators that describe the action the user sees, not the CSS class or DOM structure behind it.

Step 1: Identify the implementation-based locator

For example, the page may contain this button:

<button class="btn btn-primary save-button">
  Save
</button>

A test that depends on those CSS classes can break after a redesign:

await page
  .locator('.btn-primary.save-button')
  .click();

Step 2: Check what changed for the user

After the redesign, the implementation might change while the visible action stays the same:

<button class="action-button primary">
  Save
</button>

The CSS locator breaks, even though the user still sees the same Save action.

Step 3: Replace it with a semantic locator

Use a locator that targets the user-facing role and name instead:

await page
  .getByRole('button', { name: 'Save' })
  .click();

Tip: Locate an element by what it means to the user, not by how it happens to be implemented.

Which Playwright locators are more resilient?

You don't need a long locator hierarchy for most tests.

Element contract

Prefer

Accessible role and name

getByRole()

Form label

getByLabel()

Meaningful visible text

getByText()

Explicit automation contract

getByTestId()

Stable attribute with no better semantic option

locator()

Avoid tying tests unnecessarily to:

  • generated CSS classes;

  • long DOM paths;

  • element position;

  • dynamic IDs;

  • complex XPath expressions.

The goal is not to make the locator survive every UI change. It should survive implementation changes that do not change the behavior the test is supposed to verify.

How Play helps maintain Playwright locators through UI changes

With Playwright

With Leapwork Play

Choose and maintain the locator in test code

Record the interaction and generate the underlying Playwright locator

Update affected locators when UI changes break them

Play can attempt locator recovery before manual repair is required

Define alternative locator strategies when needed

Play retains additional locator information for recorded interactions

Diagnose and repair locator failures in code

Work with the interaction as a Play test step while keeping Playwright underneath

Play builds on Playwright's locator model by adding recording and locator-recovery capabilities that can reduce manual maintenance when the UI changes.

When should self-healing stop?

Self-healing should help when the same user action is still available but the locator broke because of an implementation change. It should not make a test pass when the product behaviour has changed.

Rule of thumb: recover the intended element, but do not hide a genuine change in what the user can do.

Example: do not self-heal to a different action

Before

After

What it means

Button: Save order

Link: Preview order

These are different user interactions. The test should fail and be updated intentionally.

If the behaviour the test validates has genuinely changed, review the test and update it deliberately instead of letting self-healing choose a nearby element.

How to handle dynamic IDs

Dynamic IDs are a common reason locators break. If an ID changes between renders, do not use it as the element contract.

Avoid generated IDs

await page
  .locator('#input-83921')
  .fill('user@example.com');

In this example, 83921 is generated and may change the next time the page renders.

Use a user-facing locator instead

await page
  .getByLabel('Email')
  .fill('user@example.com');

This keeps the locator tied to the field the user recognises, rather than to an implementation detail. For repeated dynamic-ID problems, Play’s recorded locator and recovery workflow can reduce manual locator maintenance.

Best practices

  • Use Play's recorded locator and self-healing workflow to reduce repeated locator repair after UI changes.

  • Prefer getByRole() and getByLabel() for meaningful user-facing elements.

  • Use getByTestId() when the application needs an explicit, stable automation contract.

  • Avoid long CSS/XPath chains that depend on DOM structure.

  • Avoid nth() unless element position genuinely matters.

  • Treat generated IDs and classes as unstable unless they are intentionally part of the application's contract.

  • Let tests fail when the actual user-facing behavior has changed.

Troubleshooting

Problem

What to do

CSS classes changed after a redesign

Prefer a role, label, text, or stable test ID instead of presentation classes.

The element is still present but the locator no longer finds it

Check whether the locator depended on an implementation detail. In Play, check the recovery/self-healing result before manually rewriting the interaction.

A locator now matches multiple elements

Add meaningful scope instead of using nth() just to silence the ambiguity.

A dynamic ID changed

Replace it with a semantic locator or stable test contract.

The UI behavior genuinely changed

Update the test intentionally rather than expecting locator recovery to hide the change.

Frequently asked questions

How does Leapwork Play help when a locator breaks?

Play records the interaction with its Playwright locator and additional locator information. When an interaction can no longer find the intended element, Play's recovery/self-healing workflow can attempt to recover it rather than immediately requiring the author to rewrite the locator.

What makes a Playwright locator resilient?

A resilient locator identifies an element using a stable contract such as its role, accessible name, label, visible text, or explicit test ID rather than temporary DOM structure or styling.

What is the best locator to use in Playwright?

There is no single best locator for every element. getByRole() and getByLabel() are strong defaults when the application's accessible semantics identify the target clearly.

Can self-healing replace good locator design?

No. Start with a locator that expresses the intended user interaction. Self-healing is useful when implementation changes break that locator; it should not conceal genuine changes to the behavior being tested.

Should a test survive every UI refactor?

No. A test should survive irrelevant implementation changes. If the user-facing behavior or contract changes, the test should fail so the change can be reviewed intentionally.