Selectors & Assertions
Stable data-cy selectors, chaining commands, should() assertions and how Cypress retries automatically.
Choosing selectors that don't break
Most broken Cypress tests are caused by fragile selectors. Cypress's own best-practice ranking, from worst to best:
| Selector | Example | Problem |
|---|---|---|
| Generic tag | cy.get('button').click() |
Matches any button |
| CSS class | cy.get('.btn.btn-primary') |
Classes change with styling |
| ID | cy.get('#main') |
Better, but IDs change too |
| Text | cy.contains('Submit') |
OK for user-facing text, breaks on copy changes or translation |
| Dedicated attribute | cy.get('[data-cy=submit]') |
Exists only for tests — stable |
Ask developers to add data-cy (or data-test) attributes to elements your tests interact with:
<form data-cy="signup-form">
<input data-cy="email" type="email" name="email">
<input data-cy="password" type="password" name="password">
<button data-cy="submit" type="submit">Create account</button>
<p data-cy="error" class="error"></p>
</form>
cy.get('[data-cy=email]').type('ayesha@example.com');
cy.get('[data-cy=password]').type('Secret#2026');
cy.get('[data-cy=submit]').click();
Now designers can restyle the form freely without breaking a single test.
Finding elements
cy.get('[data-cy=todo-list] li'); // CSS selector
cy.get('[data-cy=todo-list]').find('li'); // search inside a previous subject
cy.get('[data-cy=todo-list] li').first(); // first of many
cy.get('[data-cy=todo-list] li').eq(2); // third item (zero-based)
cy.contains('[data-cy=todo-list] li', 'Buy milk');// element containing text
cy.get('[data-cy=todo-list]').within(() => { // scope several commands
cy.get('li').should('have.length', 3);
});
Assertions with should
.should() uses Chai assertions and — crucially — retries until it passes or the timeout (4 seconds by default) expires.
cy.get('[data-cy=error]').should('be.visible');
cy.get('[data-cy=error]').should('have.text', 'Email is already taken');
cy.get('[data-cy=error]').should('contain', 'already taken');
cy.get('[data-cy=submit]').should('be.disabled');
cy.get('[data-cy=email]').should('have.value', 'ayesha@example.com');
cy.get('[data-cy=todo-list] li').should('have.length', 3);
cy.get('[data-cy=banner]').should('have.class', 'success');
cy.get('[data-cy=link]').should('have.attr', 'href', '/dashboard');
cy.url().should('include', '/dashboard');
Chain multiple assertions on the same subject with .and():
cy.get('[data-cy=error]')
.should('be.visible')
.and('contain', 'already taken')
.and('have.css', 'color', 'rgb(220, 38, 38)');
Negative assertions also retry:
cy.get('[data-cy=spinner]').should('not.exist');
cy.get('[data-cy=error]').should('not.be.visible');
How retry-ability works
cy.get('[data-cy=todo-list] li').should('have.length', 3);
If the list renders 0 items, then 2, then 3 over the next second, Cypress keeps re-querying cy.get() and re-checking the assertion until it sees 3. That's why you almost never need cy.wait(2000).
Only queries (get, find, contains, its…) and assertions retry. Actions like .click() and .type() run once — so assert that the element is ready before acting if it might be disabled:
cy.get('[data-cy=submit]').should('be.enabled').click();
A complete example on the demo app
Against Cypress's demo app (baseUrl: 'https://example.cypress.io'):
// cypress/e2e/querying.cy.js
describe('Querying the kitchen sink', () => {
beforeEach(() => cy.visit('/commands/querying'));
it('finds elements by data attribute', () => {
cy.get('[data-test-id="test-example"]').should('have.class', 'example');
});
it('finds elements by text', () => {
cy.contains('ul', 'apples').should('have.class', 'query-list');
cy.contains('.query-button', 'Save Form').should('have.class', 'query-button');
});
it('scopes queries with within()', () => {
cy.get('.query-form').within(() => {
cy.get('input:first').should('have.attr', 'placeholder', 'Email');
cy.get('input:last').should('have.attr', 'placeholder', 'Password');
});
});
});
Explicit assertions with expect
Inside .then() you work with the real value, so use Chai's expect (which does not retry):
cy.get('[data-cy=price]').invoke('text').then((text) => {
const price = Number(text.replace('$', ''));
expect(price).to.be.greaterThan(0);
expect(price).to.be.lessThan(100);
});
Prefer .should() whenever possible — retrying assertions are what make Cypress tests stable. Next: controlling the network with cy.intercept.