How to Choose and Use Selectors in Cypress Tests
Choose Cypress selectors by what the test should protect: stable data attributes for interactions, visible text for copy requirements, and accessible roles or labels for user-facing behavior.
Choose a Cypress selector according to what the test is meant to protect. Use a dedicated data-* attribute such as data-cy for a stable interaction hook; use visible text when the wording itself is a requirement; and use an accessible role or label when that semantic name is the user-facing contract. Scope the query to the relevant container and make the selector’s intent clear.
Cypress’s official guidance recommends data-* attributes for selectors insulated from CSS and JavaScript changes. A selector should express the test’s purpose, not merely happen to match the current markup. Cypress best practices.
1. Choose a selector that matches the test’s intent
| Selector choice | Use it when | Trade-off |
|---|---|---|
data-cy or another dedicated data-* hook |
The test needs to find an element reliably for an interaction or assertion. | Requires an intentional attribute in the application markup, but avoids coupling the test to styling or incidental copy. |
Visible text with cy.contains() |
The exact wording or visible content is part of the behavior being tested. | A copy change can fail the test. That is useful when the wording is contractual and noisy when it is incidental. |
| Accessible role or label | The test should locate a control by the semantic role or accessible name a user relies on. | Expresses user-facing meaning, but a role or label query alone is not a complete accessibility audit. |
| CSS class, tag, or implementation ID | The styling or implementation detail is itself relevant, or no better contract is available. | May change during refactoring or redesign even when the user-visible behavior does not. |
Ask what should make the test fail
- If changing a button’s text from “Submit” to “Save” should fail the test, select by that text.
- If the test is about submitting the form regardless of copy, interact through a test hook and assert the resulting behavior separately.
- If the role or accessible name is what users should encounter, query that semantic contract.
Keep test hooks specific and meaningful. For example, data-cy="profile-save" communicates more than a broad class such as button-primary. The hook does not replace assertions about the result: a test should still verify that the expected status, navigation, or data change occurred.
2. Add stable hooks to the markup
Put the test attribute on the element the test should operate on. For example:
<form data-cy="profile-form">
<label for="display-name">Display name</label>
<input id="display-name" name="displayName" data-cy="display-name" />
<button type="submit" data-cy="save-profile">Save</button>
<p role="status" data-cy="save-status"></p>
</form>
The attribute is a locator contract between the UI and its tests. Keep it attached to the interactive or asserted element, rather than a nearby wrapper that happens to contain it. If the element is repeated, add scope or a distinguishing hook so the query expresses which one is intended.
3. Use Cypress queries and scope deliberately
cy.get() for a selector from the document
cy.get(selector) searches from the document root, except while inside a .within() callback, where it uses that context. It can also retrieve an alias. Cypress re-queries aliased DOM elements by default, which helps reflect the current page after updates.
cy.get('[data-cy="save-profile"]').click()
cy.get('[data-cy="save-status"]').should('contain', 'Saved')
.within() and .find() for local scope
When a page has repeated controls, start from the relevant container. .within() changes the scope for commands inside its callback. .find(selector) searches descendants of the current DOM subject, at any depth.
cy.get('[data-cy="profile-form"]').within(() => {
cy.get('[data-cy="save-profile"]').click()
cy.get('[data-cy="save-status"]').should('contain', 'Saved')
})
cy.get('[data-cy="profile-form"]')
.find('input[name="displayName"]')
.should('be.visible')
Use .within() when several commands should share the same local context. Use .find() when one query should descend from an already selected element. Both make the relationship between the container and its control explicit.
cy.contains() for meaningful text
cy.contains(text) may start at cy or be chained from a yielded element. It yields at most one matching element. Passing a selector narrows the candidate elements and can make a button query more precise. Text matching and Cypress’s element preference behavior matter when nested elements contain the same words.
// Make the button wording part of the test contract
cy.contains('button', 'Submit').click()
// Search for meaningful text within one panel
cy.get('[data-cy="checkout-panel"]')
.contains('button', 'Place order')
.click()
For repeated matches, prefer selecting the intended container first. Do not assume cy.contains() returns every match; use a selector query when the task is to work with a collection.
.filter() to narrow an existing collection
.filter(selector) narrows a DOM subject. It also supports text matching in Cypress’s query interface. Use it when you already have a meaningful collection and want to retain only the matching elements.
cy.get('[data-cy="result-row"]')
.filter(':contains("Invoice 1042")')
.find('[data-cy="open-result"]')
.click()
For complex text or nested repeated content, a dedicated row hook or a more explicit callback-based query may be easier to maintain than a long selector. Keep the selector readable enough that a future maintainer can tell which record the test targets.
4. Query by role or accessible label when semantics are the contract
Cypress Testing Library adds queries such as findByRole and findByLabelText. Use them when the intended control is best described by its accessible role or label. The following assumes the Cypress Testing Library package and its commands have been installed and registered in the project.
// The accessible role and name are the intended contract
cy.findByRole('button', { name: 'Save' }).click()
// Locate a form control by its associated label
cy.findByLabelText('Display name').clear().type('Ada')
A passing role or label query shows that the query could find an element with that semantic information; it does not by itself test the full accessibility of the page. For more, see Cypress’s Testing Library examples in the Playwright migration guide and accessibility guidance in its best practices.
5. Handle repeated elements and positional selection
Repeated controls need a clear selection rule. Prefer narrowing to a container or record first, then finding the desired descendant. If position is genuinely the intended rule, use readable chain methods such as .first() or .eq(index).
// Select a particular repeated row, then its action
cy.get('[data-cy="invoice-row"][data-invoice-id="1042"]')
.find('[data-cy="open-invoice"]')
.click()
// Use position only when order is part of the test contract
cy.get('[data-cy="tab"]').eq(1).click()
Position can become misleading if the list is reordered or gains an item. If the test cares about a specific record, select that record by stable identifying data. If order is itself what you are testing, assert the order and then use the position intentionally.
6. Let Cypress retry queries instead of adding arbitrary waits
Cypress queries are retried while Cypress waits for elements and chained assertions. .find() and .filter() are queries, and their chains can retry until the requested elements exist and assertions pass. Prefer a query and an assertion that state the actual condition over a fixed delay added to hide a selector problem.
// Retry until the expected state is present
cy.get('[data-cy="save-status"]').should('contain', 'Saved')
// Query a descendant and assert its state
cy.get('[data-cy="profile-form"]')
.find('[data-cy="save-profile"]')
.should('be.enabled')
A retryable query cannot make a wrong locator correct. If the element never appears, check the selector, the application state that should reveal it, and whether the test has reached the right page before increasing timeouts.
7. Configure generated selector priorities with care
Cypress.ElementSelector.defaults() can configure selector priorities used by Cypress tools including Cypress Studio and cy.prompt(). Cypress attempts the configured priorities while still aiming for a unique selector, and may skip or combine lower-priority choices as needed. This config is version-sensitive: Cypress marks selectorPriority as under active development and subject to change.
// cypress/support/e2e.js
Cypress.ElementSelector.defaults({
selectorPriority: ['attribute:data-cy', 'attribute:role', 'tag', 'nth-child'],
})
Use this to shape generated selectors for your project, then review what the tool produces. A generated selector can still be unique but express the wrong test intent. Confirm that it targets a stable contract rather than an incidental structure.
See the Cypress.ElementSelector API for the current API and version-specific details.
8. Complete example: test a profile form
This example uses test hooks for stable interaction and result assertions, while a separate test makes visible wording part of the contract. The role and label example above is an alternative when accessible semantics are the intended locator.
describe('profile form', () => {
it('saves a profile and shows confirmation', () => {
cy.visit('/profile')
cy.get('[data-cy="profile-form"]').within(() => {
cy.get('[data-cy="display-name"]')
.clear()
.type('Ada Lovelace')
cy.get('[data-cy="save-profile"]').click()
cy.get('[data-cy="save-status"]')
.should('be.visible')
.and('contain', 'Saved')
})
})
it('keeps the required submit wording', () => {
cy.visit('/profile')
cy.contains('button', 'Submit').should('be.visible')
})
})
The first test treats the hook as the locator contract and the status as the outcome. The second intentionally fails if the required button wording changes.
9. Troubleshoot selector failures
| Symptom | Likely cause | Fix |
|---|---|---|
cy.get() finds no element |
The hook is missing or misspelled, the page is not in the expected state, or the query is running before the relevant UI appears. | Inspect the rendered DOM and exact attribute value; confirm navigation and state; use a retryable query plus the assertion that describes readiness. |
| A query matches several controls | The selector is too broad, or repeated items have no distinguishing context. | Scope to a form, panel, or row with .within(), .find(), or a specific ancestor selector. Add a stable identifier if the app has one. |
cy.contains() clicks the unexpected element |
Nested elements or several elements contain the same text; contains yields at most one match and applies element preference rules. |
Pass an element selector such as 'button', scope to the relevant container, or use a dedicated hook if wording is not the contract. |
| A test breaks after a redesign | It depended on a style class, DOM nesting, or generated structure that changed with presentation. | Replace the locator with a dedicated data-* hook unless presentation or semantics are what the test is meant to protect. |
| A text-based test breaks after copy editing | The copy was incidental, but the test selected it as a locator. | Use a stable hook for interaction and assert the visible text only where it is a requirement. |
| A role or label query fails | The rendered element may lack the expected role, accessible name, or associated label; Testing Library commands may also be unavailable. | Inspect the accessible semantics and markup, install and register Cypress Testing Library if needed, and use the actual intended accessible name. |
| An element appears too late for the assertion | The application has not reached the expected state, or the test is using a fixed delay instead of waiting on the condition. | Use a retryable query and assertion for the state; verify the action and application response. Increase a timeout only when the condition is correct and the expected duration warrants it. |
| Generated selector changes between versions | Selector priority behavior is under active development. | Review generated selectors after Cypress upgrades and treat the configured priority list as version-sensitive. |
.find() returns nothing |
The current subject is not the intended container or the target is not a descendant of it. | Check the preceding query’s subject and DOM relationship; use cy.get() from the root or correct the container selector. |
10. Reliability, maintainability, and runtime cost
Reliability
Selector reliability comes from choosing a locator aligned with a deliberate contract, then letting Cypress retry the query while the application reaches that state. A stable hook can still select the wrong element if it is duplicated or attached to an unexpected node, so check scope and uniqueness. A semantic query can better represent user-facing behavior but depends on correct accessible markup. Text queries intentionally couple the test to content.
Maintainability
Prefer selectors a teammate can understand without reverse-engineering styles. Dedicated attributes make intent visible in both markup and tests. Use text, roles, and labels when their meaning is the requirement. Avoid baking incidental class names, deep DOM paths, or changing positions into routine interaction tests.
Runtime and timeouts
These selector choices do not imply a measured speed difference. Queries and assertions participate in Cypress’s retry model, so a fixed wait adds elapsed time without identifying the condition. Keep queries scoped and avoid repeatedly searching broad collections when a specific container is available. Change timeouts only for a known slow condition, and preserve an assertion that proves the expected state.
Cost
The selector patterns shown use Cypress commands and markup conventions; they do not require a screenshot API. If a workflow also needs screenshots of pages outside the test runner, a screenshot API can avoid maintaining separate browser capture setup.
11. Screenshot a page without managing browser setup
For screenshot API requests, ScreenshotNeo is a website screenshot API and MCP server from Yorker Media. It returns PNG, JPEG, WebP, or PDF from one GET request. For Cypress-related documentation or visual review, target a public page you are authorized to capture. See the ScreenshotNeo site and API documentation.
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://stripe.com \
-o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://stripe.com',
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);
These calls use the API’s documented endpoint and basic URL parameter. Review the ScreenshotNeo docs for available formats and capture options. Keep the API key out of browser-side code and public repositories.
Or skip the browser setup
ScreenshotNeo accepts cookie and consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each step can be turned off. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the page verdict and billing status in headers. Its MCP server gives AI agents tools to take screenshots, get page information, and capture PDFs. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 screenshots.
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://stripe.com \
-o shot.webp
Create a free ScreenshotNeo account for 1,000 screenshots a month with no card.
12. Frequently asked questions
Should I use data-cy or data-testid?
Use a dedicated data-* hook that your team consistently adopts. Cypress’s examples use data-cy; the key property is that the test attribute is independent of styling and incidental copy.
Is selecting by role always better than a test hook?
Choose the locator that matches the contract under test. Role and label queries are useful for semantic user-facing behavior; a test hook is direct for a stable interaction whose accessible name is not the requirement.
Does a passing selector prove the page is accessible?
No. It can confirm that a query found an element with a role or label, but accessibility requires broader checks of the experience and markup.
When should I use .first() or .eq()?
Use them when position is intentionally part of the test. When you mean a particular record or control, select it by identity or scope first.
Should I add a fixed wait when Cypress cannot find an element?
First verify that the selector and expected application state are correct. Express readiness as a retryable query and assertion; a fixed delay does not explain what the test is waiting for.


