Cypress Selectors and Interactions — Best Practices for Stable Tests

Stable Cypress tests depend on stable selectors. CSS class selectors ('.submit-btn') break when designers rename classes. Text selectors (cy.contains('Submit')) break when copy is updated. data-cy attributes are the recommended approach — they are specifically for testing and survive both CSS refactoring and copy changes. Angular Testing Library commands (cy.findByRole, cy.findByLabelText) provide the most user-centric selectors, testing what users actually interact with.

Selectors, Interactions and Assertions

// ── Add data-cy attributes to Angular templates ───────────────────────────
// post-card.component.html:
// <article data-cy="post-card">
//   <h2 data-cy="post-title">{{ post.title }}</h2>
//   <button data-cy="read-more-btn">Read More</button>
// </article>

// ── Selector strategies (best to worst) ──────────────────────────────────
// BEST: data-cy attributes (testing-specific, stable)
cy.getByCy('post-card');
cy.get('[data-cy="submit-button"]');

// GOOD: ARIA roles and labels (accessible, user-facing)
cy.findByRole('button', { name: 'Publish Post' });
cy.findByLabelText('Post Title');
cy.findByPlaceholderText('Search posts...');

// OK: text content (user-visible but brittle to copy changes)
cy.contains('No posts found');
cy.contains('button', 'Cancel');

// BAD: CSS classes (breaks on refactor, implementation detail)
cy.get('.submit-btn');
cy.get('.mat-raised-button.primary');

// ── Chainable assertions ──────────────────────────────────────────────────
cy.getByCy('post-card')
  .should('have.length', 10)
  .first()
  .should('be.visible')
  .within(() => {
    cy.getByCy('post-title').should('not.be.empty');
    cy.getByCy('author-name').should('be.visible');
    cy.getByCy('view-count').should('match', /\d+/);
  });

// ── Common interactions ───────────────────────────────────────────────────
// Text input
cy.getByCy('title-input').clear().type('My New Post Title');
cy.getByCy('search-input').type('dotnet{enter}');  // {enter} = press Enter key

// Click
cy.getByCy('publish-button').click();
cy.getByCy('category-filter').click();

// Angular Material mat-select
cy.getByCy('status-select').click();  // open the dropdown
cy.get('.mat-option').contains('Published').click();  // select option

// Checkbox
cy.getByCy('featured-checkbox').check();
cy.getByCy('featured-checkbox').uncheck();

// File upload (drag and drop)
cy.getByCy('upload-zone').selectFile('cypress/fixtures/test-image.jpg', {
  action: 'drag-drop',
});

// Keyboard navigation
cy.getByCy('search-input').type('{ctrl+a}{backspace}');  // select all and delete

// ── Waiting and intercepting ──────────────────────────────────────────────
// Wait for API call before asserting
cy.intercept('GET', '/api/posts*').as('getPosts');
cy.visit('/');
cy.wait('@getPosts').then(interception => {
  expect(interception.response!.statusCode).toBe(200);
});
cy.getByCy('post-card').should('have.length.gt', 0);

// Stub API response
cy.intercept('GET', '/api/posts*', { fixture: 'posts.json' }).as('getPosts');
cy.visit('/');
cy.wait('@getPosts');
cy.getByCy('post-card').should('have.length', 5);  // matches fixture data
Note: Cypress automatically retries assertions — cy.getByCy('post-card').should('have.length', 10) retries until the assertion passes or the timeout (defaultCommandTimeout) is reached. This built-in retry is Cypress’s solution to the async nature of web apps — elements may not appear immediately (Angular’s change detection, HTTP responses). You should almost never need explicit cy.wait(1000) time delays — use assertions that Cypress will retry until satisfied, or intercept network calls with cy.wait('@alias').
Tip: Use cy.intercept() with cy.wait('@alias') instead of arbitrary cy.wait(milliseconds) delays. Waiting for a specific network request ensures the test proceeds exactly when the data is available — not after an arbitrary delay that may be too short (flaky) or too long (slow). cy.intercept('GET', '/api/posts*').as('getPosts'); cy.wait('@getPosts') waits for exactly one GET request matching the pattern and then continues immediately when it completes.
Warning: Angular Material components have complex internal DOM structures with multiple nested elements. cy.get('mat-select').click() may not work because the clickable element is a child div, not the mat-select host element. Inspect the rendered HTML in DevTools to find the actual interactive element, or use cy.findByRole('combobox') to find the accessible select role. The .mat-option class for option selection is relatively stable across Material versions, but always verify against the Material version in use.

Common Mistakes

Mistake 1 — CSS class selectors that break on Material updates or refactoring

❌ Wrong — cy.get('.mdc-button--raised'); changes when Material updates internal class names.

✅ Correct — cy.findByRole('button', { name: 'Publish' }) or cy.getByCy('publish-btn').

Mistake 2 — Arbitrary cy.wait(milliseconds) for async operations

❌ Wrong — cy.wait(2000) after clicking submit; brittle (may be too short on slow CI), wasteful (always takes 2s even when data arrives in 100ms).

✅ Correct — cy.wait('@submitPost') after cy.intercept() setup; waits exactly until the request completes.

🧠 Test Yourself

Cypress: cy.getByCy('post-card').should('have.length', 10). The API returns 10 posts but Angular renders them with a 200ms animation. The test runs at 100ms. Does it pass?