Locator Playground
Every Playwright locator and its Cypress equivalent on one page — role, text, label, placeholder, alt text, title, test id, CSS, XPath, filters, iframes and shadow DOM. Every snippet here is run against this page in our test suite.
Cheat sheet
The closest Cypress equivalent for every Playwright locator. Each row is practiced in a section below.
| Find by | Playwright | Cypress |
|---|---|---|
| Role + name | getByRole('button', { name }) | cy.contains('button', text) · cy.findByRole (plugin) |
| Visible text | getByText(text | /regex/) | cy.contains(text | /regex/) |
| Form label | getByLabel(text) | cy.contains('label', text) → input |
| Placeholder | getByPlaceholder(text) | cy.get('[placeholder="…"]') |
| Image alt | getByAltText(text) | cy.get('img[alt="…"]') |
| Title attribute | getByTitle(text) | cy.get('[title="…"]') |
| Test id | getByTestId(id) | cy.get('[data-cy="…"]') |
| CSS | locator('css') | cy.get('css') |
| XPath | locator('//xpath') | Not built in — use CSS / cy.contains |
| Narrow by content | filter({ hasText, has, hasNot, visible }) | .filter() · .not() · :has() · :visible |
| Position | first() · last() · nth(i) | .first() · .last() · .eq(i) |
| Scope | locator.getBy…() chaining | .within() · .find() |
| Walk the DOM | locator('xpath=ancestor::…') · locator('..') | .closest() · .parent() · .siblings() |
| iframe | frameLocator() · contentFrame() | its('0.contentDocument.body') → cy.wrap |
| Shadow DOM | Pierced automatically (not XPath) | .shadow() · { includeShadowDom: true } |
By role & accessible name
The locator users and screen readers “see”: headings, buttons, toggles, links and checkboxes — plus states like pressed, expanded, checked and disabled.
const box = page.getByTestId('loc-role');
await expect(box.getByRole('heading', { level: 3, name: 'Account settings' })).toBeVisible();
// name matches a substring, ignoring case — exact: true stops 'Save' also matching 'Save draft'
await box.getByRole('button', { name: 'Save', exact: true }).click();
await expect(box.getByRole('status')).toHaveText('Saved');
// icon-only button: the name comes from aria-label
await box.getByRole('button', { name: 'Close panel' }).click();
await expect(box.getByRole('status')).toHaveText('Panel closed');
await expect(box.getByRole('button', { name: 'Delete account' })).toBeDisabled();
await box.getByRole('button', { name: 'Bold', pressed: false }).click();
await expect(box.getByRole('button', { name: 'Bold', pressed: true })).toBeVisible();
await box.getByRole('button', { name: 'More options', expanded: false }).click();
await expect(box.getByRole('list', { name: 'More options' })).toBeVisible();
await box.getByRole('checkbox', { name: 'Email me updates' }).check();
await expect(box.getByRole('checkbox', { checked: true })).toHaveCount(1);
await expect(box.getByRole('link', { name: 'Help centre' })).toHaveAttribute('href', '#help-centre');By visible text
Text split across tags, messy whitespace, case, exact matches, regular expressions — and text that is in the DOM but hidden.
Order #1042 has shipped
Welcome back, Asha
Total: ₹1,299.00
Free delivery · Free delivery on orders over ₹499
const box = page.getByTestId('loc-text');
// text split across <strong> still matches, and whitespace is normalised
await expect(box.getByText('Order #1042 has shipped')).toBeVisible();
await expect(box.getByText('Welcome back, Asha')).toBeVisible();
// default: substring, case-insensitive · exact: true: whole string, case-sensitive
await expect(box.getByText('free delivery')).toHaveCount(2);
await expect(box.getByText('Free delivery', { exact: true })).toHaveCount(1);
await expect(box.getByText(/₹\d[\d,]*\.\d{2}/)).toHaveText('Total: ₹1,299.00');
// hidden text is still found — assert on visibility, or keep only visible matches
await expect(box.getByText('Secret promo code')).toBeHidden();
await expect(box.getByText('Secret promo code').filter({ visible: true })).toHaveCount(0);By label
The four ways an input gets its label: <label for>, a wrapping <label>, aria-label and aria-labelledby.
const form = page.getByTestId('loc-labels');
await form.getByLabel('Email address').fill('asha@example.com'); // <label for="…">
await form.getByLabel('Full name').fill('Asha Rao'); // <label> wraps the input
await form.getByLabel('Search products').fill('mouse'); // aria-label
await form.getByLabel('One-time code').fill('123456'); // aria-labelledby
await expect(form.getByLabel('Email address')).toHaveValue('asha@example.com');
await expect(form.getByRole('textbox', { name: 'One-time code' })).toHaveValue('123456');By placeholder, alt text & title
Inputs without a label, images and tooltips — located by the attributes users actually read.
const box = page.getByTestId('loc-attrs');
await box.getByPlaceholder('name@company.com').fill('asha@example.com');
await expect(box.getByAltText('PracticeQAAutomation logo')).toBeVisible();
await expect(box.getByTitle('Open issues')).toHaveText('7');
await expect(box.getByTitle('Quality Assurance')).toHaveText('QA');By test id
Four common conventions on one page: data-testid, data-cy, data-test and data-qa.
const box = page.getByTestId('loc-testids');
await box.getByTestId('pay-now').click(); // data-testid is the default
await expect(box.getByRole('status')).toHaveText('Pay now clicked');
await box.locator('[data-cy="apply-coupon"]').click(); // any other attribute: plain CSS
await expect(box.getByRole('status')).toHaveText('Apply coupon clicked');
await box.locator('[data-test="save-address"]').click();
await expect(box.getByRole('status')).toHaveText('Save address clicked');
// Or make getByTestId read another attribute in playwright.config.ts:
// use: { testIdAttribute: 'data-qa' } → await page.getByTestId('place-order').click();
await box.locator('[data-qa="place-order"]').click();
await expect(box.getByRole('status')).toHaveText('Place order clicked');CSS & XPath
Attribute operators, :has(), :nth-child(), Playwright's :has-text() and XPath — and what Cypress uses instead of XPath.
- Wireless Mouse Sale
- Mechanical Keyboard
- USB-C Hub Sale
- Laptop Stand
const list = page.getByTestId('loc-css');
await expect(list.locator('li.item--sale')).toHaveCount(2);
await expect(list.locator('[id^="sku-1"]')).toHaveCount(2); // ^= starts with, $= ends, *= contains
await expect(list.locator('li:has(.badge)')).toHaveCount(2); // CSS :has()
await expect(list.locator('li:nth-child(2)')).toContainText('Mechanical Keyboard');
await expect(list.locator('li:has-text("USB-C")')).toHaveAttribute('data-price', '1299'); // Playwright pseudo-class
// XPath: start with // (or use the xpath= prefix)
await expect(page.locator('//ul[@data-testid="loc-css"]/li[@data-price > 1000]')).toHaveCount(2);
await expect(list.locator('xpath=./li[not(span)]')).toHaveCount(2);Filter, chain & pick by position
Find the card by its text, by a child, or by what it doesn't contain — then first/last/nth, and()/or(), and walking up and across the DOM.
Mechanical Keyboard
₹3,499Out of stockUSB-C Hub
₹1,299In stockWebcam HD
₹2,199Out of stockLaptop Stand
₹999In stockconst section = page.getByTestId('loc-cards');
const cards = section.getByRole('article'); // role locators skip the hidden card
await expect(cards).toHaveCount(5);
await cards.filter({ hasText: 'USB-C Hub' }).getByRole('button', { name: 'Add to cart' }).click();
await expect(section.getByRole('status')).toHaveText('Added USB-C Hub');
await expect(cards.filter({ has: page.getByRole('button', { disabled: true }) })).toHaveCount(2);
await expect(cards.filter({ hasNotText: 'Out of stock' })).toHaveCount(3);
await expect(cards.filter({ hasNot: page.getByText('Out of stock') })).toHaveCount(3);
await expect(cards.first()).toContainText('Wireless Mouse');
await expect(cards.last()).toContainText('Laptop Stand');
await expect(cards.nth(1)).toContainText('Mechanical Keyboard');
// CSS locators include hidden elements — filter({ visible: true }) drops them
await expect(section.locator('article')).toHaveCount(6);
await expect(section.locator('article').filter({ visible: true })).toHaveCount(5);
// and(): both must match · or(): either one
await expect(cards.getByRole('button').and(page.locator(':enabled'))).toHaveCount(3);
await expect(section.getByText('Sold out').or(section.getByText('Out of stock')).first()).toBeVisible();
// walk up to the card from its heading
await expect(section.getByRole('heading', { name: 'Webcam HD' }).locator('xpath=ancestor::article').getByRole('button')).toBeDisabled();Inside an iframe
A same-origin iframe with its own form. Playwright switches with frameLocator() or contentFrame(); Cypress wraps the iframe's body.
const frame = page.frameLocator('[data-testid="loc-frame"]');
await frame.getByLabel('Your email').fill('asha@example.com');
await frame.getByRole('button', { name: 'Subscribe' }).click();
await expect(frame.getByRole('status')).toHaveText('Subscribed asha@example.com');
// or start from the <iframe> element itself
await expect(page.getByTitle('Newsletter form').contentFrame().getByRole('status')).toHaveText(/Subscribed/);Inside a shadow root
An open shadow root. Playwright's CSS and getBy* locators pierce it automatically (XPath does not); Cypress needs .shadow() or includeShadowDom.
const host = page.getByTestId('loc-shadow-host');
await host.getByLabel('Coupon').fill('SAVE10');
await host.getByRole('button', { name: 'Apply' }).click();
await expect(host.locator('.msg')).toHaveText('Coupon SAVE10 applied');
// XPath does not enter shadow roots
await expect(page.locator('//div[@data-testid="loc-shadow-host"]//button')).toHaveCount(0);