getByRole, when the accessibility tree supports it:
import { test, expect } from '@playwright/test';
test('submits the checkout form with valid card details', async ({ page }) => {
await page.goto('/checkout');
await page.getByRole('textbox', { name: 'Card number' }).fill('4242424242424242');
await page.getByRole('textbox', { name: 'Expiry date' }).fill('12/28');
await page.getByRole('textbox', { name: 'CVC' }).fill('123');
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
});
This only works because the form's inputs and button have correct accessible names, verified in advance, not assumed. If the "Place order" button's visible text changes under an A/B test but its accessible name (via `aria-label`) stays fixed, this locator survives the change. If the accessible name itself is what's under test, add a `data-testid` instead rather than fighting it.
getByTestId, for elements without reliable accessible names:
test('applies a promo code at checkout', async ({ page }) => {
await page.goto('/checkout');
await page.getByTestId('promo-code-input').fill('SAVE10');
await page.getByTestId('promo-code-apply').click();
await expect(page.getByTestId('order-total')).toContainText('$45.00');
});
Adding these three attributes is the retrofit cost referenced above: three lines of JSX changed, one PR, and a review from the team that owns the checkout component, not a QA-only change.
<input data-testid="promo-code-input" ... />
<button data-testid="promo-code-apply" ...>Apply</button>
<span data-testid="order-total">{formattedTotal}</span>
XPath, scoped narrowly, for legacy markup you cannot change:
test('opens the legacy admin record editor', async ({ page }) => {
await page.goto('/admin/records/482');
// Legacy jQuery admin panel, no roles, no test IDs, markup owned by a
// vendor package that predates the test suite. Anchored to a stable
// data attribute the vendor markup happens to already emit, not to
// structural position, so a layout change elsewhere on the page
// doesn't break this.
const editButton = page.locator(
"xpath=//button[@data-action='edit-record' and not(@disabled)]"
);
await editButton.click();
await expect(page.locator("xpath=//div[@id='record-editor-panel']")).toBeVisible();
});
Note what makes this XPath usable rather than a liability: it anchors to a semantic attribute the legacy markup already emits (`data-action`), not to a DOM path like `/html/body/div[4]/div[2]/table/tr[3]`. If the vendor markup emits nothing stable at all, that's the signal to raise the retrofit conversation with whoever owns that surface, rather than writing an increasingly specific structural XPath to compensate.