skip to content

Porting a suite to Cypress, what replaces its XPath locators?

level: juniorimportance: should knowfreq 58%

answer

  1. Ask what cy.get() actually accepts
  2. Two buckets: text and structure
  3. Text predicates get their own command
  4. Axes map onto traversal commands
  5. count() is an assertion, not a locator

basics

~10 s

Cypress has no first-class XPath support: cy.get() takes a CSS selector. Text-based XPath becomes cy.contains(), structural XPath becomes a CSS selector, and XPath axes become traversal commands such as .find(), .parent(), .next() and .eq().

solid answer

~40 s

Cypress ships no XPath command — `cy.get()` queries the way jQuery's `$(...)` does, so its argument is a CSS selector. Porting is a rewrite rather than a translation, and most XPath falls into two buckets. **Text-based XPath** such as `//button[text()='Re-run failed']` becomes `cy.contains('button', 'Re-run failed')`, which usually reads better than the original. **Structural XPath** such as `//div[@class='suite-card']//span` becomes the CSS selector `.suite-card span`, with `.find()`, `.within()`, `.parent()`, `.closest()`, `.next()` and `.siblings()` covering the axes and `.eq()` covering positional predicates — remembering that `.eq()` is zero-based. A `count(...)` predicate stops being part of the locator and becomes `.should('have.length', 5)`. Since every locator is being rewritten anyway, this is the cheapest moment to add `data-cy` attributes to the application and stop selecting on markup shape.

code

javascript · 11 lines
javascript
// //div[@data-cy='run-summary']//span[@class='total']
cy.get('[data-cy=run-summary] .total').should('have.text', '12 specs')

// //button[text()='Re-run failed']
cy.contains('button', 'Re-run failed').click()

// //tr[@data-cy='spec-row'][3]/td[2]
cy.get('[data-cy=spec-row]').eq(2).find('td').eq(1).should('contain', 'failed')

// count(//tr[@data-cy='spec-row']) = 12
cy.get('[data-cy=spec-row]').should('have.length', 12)

go deeper

for a junior

Be ready to say plainly that Cypress has no XPath command and that cy.get() takes a CSS selector. Knowing the cy.contains() rewrite for a text predicate is usually enough at this level.

for a middle

Explain both buckets and name the traversal commands that stand in for XPath axes — .find(), .parent(), .closest(), .next(), .siblings() and .eq(). Mention that .eq() is zero-based.

for a senior

Show that you would size the rewrite before starting and would not port brittle locators one for one. Talk about landing data-cy attributes in the application as part of the same change.

for a principal

Own what the rewritten locators anchor on and who in the application team maintains those attributes once a test suite depends on them. Be ready to say how the convention is enforced rather than merely agreed.

## Why an XPath locator has nothing to translate into Cypress queries the DOM through `cy.get()`, whose querying behaviour the documentation describes as similar to jQuery's `$(...)`. The argument is therefore a **CSS selector**, and Cypress 16 ships no XPath command at all — there is no `cy.xpath()` on the command list and no configuration key that turns XPath on. So the honest planning statement for a port is that **every XPath locator in the old suite is a rewrite, not a mapping**. On an XPath-heavy suite that rewrite is the largest single line-count in the migration, and it is worth budgeting for up front rather than discovering halfway through. The good news is that XPath locators are not spread evenly across the expressive power of the language. In practice they fall into two buckets, and both have a short Cypress form. ## Bucket one: text-based XPath becomes cy.contains() Anything shaped like `//button[text()='Re-run failed']` or `//a[contains(., 'Latest run')]` is really a text query wearing structural clothing. In Cypress that is `cy.contains()`: - `cy.contains('Re-run failed')` yields the first element in the document containing that text. - `cy.contains('button', 'Re-run failed')` filters to a selector first, which is what you want when ancestors also contain the string — Cypress otherwise applies its own preference order over the matches and can hand you a wrapper element. - `.contains()` chains as well, so `cy.get('[data-cy=spec-row]').contains('failed')` scopes the text query to one subtree. These rewrites usually come out shorter and more readable than the XPath they replace, which makes them the easy half of the job. ## Bucket two: structural XPath becomes CSS plus traversal `//div[@class='suite-card']//span` is a descendant expression, and CSS says the same thing as `.suite-card span`. Where CSS runs out — axes, positional predicates, counting — Cypress's traversal commands take over. | XPath idiom | Cypress form | |---|---| | `//div[@id='runs']//tr` | `cy.get('#runs tr')` | | `//tr[@data-cy='spec-row']/td[2]` | `cy.get('[data-cy=spec-row] td').eq(1)` | | `parent::` / `ancestor::` | `.parent()`, `.parents()`, `.closest()` | | `following-sibling::` | `.next()`, `.nextAll()`, `.siblings()` | | `child::` | `.children()`, `.find()` | | `[position()=3]` | `.eq(2)` | | `count(//tr) = 5` | `cy.get('tr').should('have.length', 5)` | | several lookups scoped to one card | `.within(() => { ... })` | Two rows deserve a note. `.eq()` is **zero-based** where XPath positions start at one, which is a reliable source of off-by-one bugs in a hand-port. And `count(...)` is not a locator at all in Cypress — it is an assertion, so it moves out of the selector and into a retrying `.should('have.length', n)`, which is strictly better because it waits for the rows to render instead of counting whatever happens to be there. ## The decision the rewrite forces on you Because a port touches every locator anyway, it is the cheapest moment the suite will ever have to change what it selects on: 1. **Agree the convention before translating anything.** Rewriting six hundred XPath expressions into six hundred equally brittle CSS expressions spends the whole budget and buys nothing. 2. **Prefer an attribute the application owns.** Cypress's own guidance is to add `data-cy` (or `data-testid`) attributes and select with `cy.get('[data-cy=spec-row]')`, so markup refactors stop breaking specs. 3. **Take role and label ergonomics from a library if you want them.** `@testing-library/cypress` adds `cy.findByRole()` and friends to `cy`, which is often a closer match to what a text-based XPath actually meant. ## Traps in a mechanical port - **Reaching for a third-party XPath plugin to skip the rewrite.** It preserves the locators you were migrating away from and leaves the suite coupled to markup shape. - **Translating `text()='...'` into a CSS attribute selector.** CSS cannot match on text content at all; that is precisely what `cy.contains()` is for. - **Assuming a query yields one element.** `cy.get('[data-cy=spec-row]')` yields every match, and an action on a multi-element subject errors — put the XPath's predicate back with `.eq()`, `.first()` or `.filter()`. - **Inheriting XPath's blind spots silently.** XPath does not cross a shadow boundary and a plain `cy.get()` does not either; a project that needs those elements opts in through `.shadow()` or the `includeShadowDom` configuration option. - **Leaving the count in the selector.** A predicate that asserted rather than located belongs in `.should()`, where it retries.

  • The old locator used [position()=2]. What is the Cypress form, and what is the catch?
    `.eq(1)`. Cypress traversal indices are zero-based where XPath positions start at one, so every positional predicate shifts by one in a hand-port. `.first()` and `.last()` cover the common ends, and where the predicate was really a condition rather than a position, `.filter()` expresses it better than an index.
  • Which XPath expressions have no clean Cypress form at all?
    The ones that compute rather than locate — string functions, arithmetic, or an axis walked backwards from a computed node set. Those become JavaScript in Cypress: query the container, then use `.filter()`, `.each()` or a `.should()` callback over the elements. The logic belongs in code, not in a selector string.

saying these in an interview costs you the question

  • Says Cypress supports XPath through a cy.xpath() command
  • Installs an XPath plugin to avoid rewriting the locators
  • Tries to match element text with a CSS attribute selector
  • Ports XPath into equally brittle CSS tied to markup nesting
  • Assumes a Cypress query yields one element when several match