Cypress Selectors and Command Chaining: Writing Maintainable Tests

Shunku

Selecting elements correctly is fundamental to writing reliable Cypress tests. This guide covers selector strategies, command chaining, and creating custom commands for reusable test logic.

Selector Strategy

Selector Priority

Choose selectors based on resilience to change:

flowchart TD
    A[Best: data-testid] --> B[Good: data-cy, data-test]
    B --> C[OK: aria-label, role]
    C --> D[Avoid: class, tag]
    D --> E[Worst: nth-child, CSS path]

    style A fill:#10b981,color:#fff
    style B fill:#22c55e,color:#fff
    style C fill:#f59e0b,color:#fff
    style D fill:#ef4444,color:#fff
    style E fill:#dc2626,color:#fff
Selector Type Example Resilience
data-testid [data-testid="submit"] High
data-cy [data-cy="login-button"] High
Role + name button with text Medium
Class .submit-btn Low
Tag + index button:first Very Low

Recommended Selectors

// Best: Test-specific attributes
cy.get('[data-testid="submit-button"]');
cy.get('[data-cy="login-form"]');
cy.get('[data-test="user-name"]');

// Good: Semantic selectors
cy.contains('button', 'Submit');
cy.get('input[name="email"]');
cy.get('input[type="submit"]');

// Avoid: Brittle selectors
cy.get('.btn-primary'); // Class may change
cy.get('div > div > button'); // Structure may change
cy.get(':nth-child(3)'); // Order may change

Adding Test Attributes

Add data-testid or data-cy attributes to your components:

// React component
function LoginForm() {
  return (
    <form data-testid="login-form">
      <input data-testid="email-input" type="email" />
      <input data-testid="password-input" type="password" />
      <button data-testid="submit-button">Login</button>
    </form>
  );
}

Core Selector Commands

cy.get()

Select elements by CSS selector:

// By ID
cy.get('#email');

// By class
cy.get('.form-control');

// By attribute
cy.get('[data-testid="submit"]');
cy.get('[name="email"]');
cy.get('[type="checkbox"]');

// By tag
cy.get('button');
cy.get('input');

// Combined selectors
cy.get('input[type="email"]');
cy.get('button.primary[disabled]');
cy.get('[data-testid="item"]:first');
cy.get('[data-testid="item"]:last');
cy.get('[data-testid="item"]:eq(2)'); // Third item

cy.contains()

Select elements by text content:

// Find element containing text
cy.contains('Submit');
cy.contains('Welcome back');

// Find specific element type containing text
cy.contains('button', 'Submit');
cy.contains('a', 'Learn more');
cy.contains('h1', 'Dashboard');

// Find within a container
cy.contains('.sidebar', 'Settings');

// With regex
cy.contains(/welcome/i); // Case-insensitive
cy.contains(/item \d+/); // Pattern matching

cy.find()

Find elements within a parent:

// Find child elements
cy.get('.user-card').find('.username');
cy.get('form').find('input');
cy.get('[data-testid="nav"]').find('a');

// Chain multiple finds
cy.get('.page')
  .find('.content')
  .find('.article')
  .find('h2');

Traversing the DOM

// Parent/child relationships
cy.get('.child').parent();
cy.get('.parent').children();
cy.get('.item').siblings();

// First/last/index
cy.get('.items').first();
cy.get('.items').last();
cy.get('.items').eq(2); // Third item (0-indexed)

// Filtering
cy.get('button').filter('.primary');
cy.get('.item').filter(':visible');
cy.get('input').not('[disabled]');

// Closest ancestor
cy.get('.nested-element').closest('.container');

// Next/previous siblings
cy.get('.current').next();
cy.get('.current').prev();
cy.get('.current').nextAll();
cy.get('.current').prevAll();

Command Chaining

Cypress commands are chainable, with each command yielding a subject to the next:

flowchart LR
    A[cy.get] --> B[.find]
    B --> C[.first]
    C --> D[.click]
    D --> E[.should]

    style A fill:#3b82f6,color:#fff
    style E fill:#10b981,color:#fff

Basic Chaining

cy.get('[data-testid="form"]')
  .find('input[name="email"]')
  .type('user@example.com')
  .should('have.value', 'user@example.com');

cy.get('.nav')
  .contains('Settings')
  .click()
  .url()
  .should('include', '/settings');

Subject Management

// Commands yield new subjects
cy.get('form')           // yields <form>
  .find('input')         // yields <input> (changes subject)
  .type('hello')         // yields <input>
  .should('have.value', 'hello');

// Some commands yield the same subject
cy.get('input')
  .type('hello')         // yields same <input>
  .clear()               // yields same <input>
  .type('world');        // yields same <input>

// .within() scopes commands to an element
cy.get('[data-testid="user-card"]').within(() => {
  cy.get('.name').should('contain', 'John');
  cy.get('.email').should('contain', 'john@example.com');
  cy.get('button').click();
});

Retry-ability

Cypress automatically retries commands until they pass or timeout:

// This will retry until element is visible or timeout
cy.get('[data-testid="toast"]').should('be.visible');

// Retries the whole chain
cy.get('.items')
  .find('.item')
  .should('have.length', 5);

Using .within() for Scoped Queries

// All queries scoped to the form
cy.get('[data-testid="registration-form"]').within(() => {
  cy.get('[name="firstName"]').type('John');
  cy.get('[name="lastName"]').type('Doe');
  cy.get('[name="email"]').type('john@example.com');
  cy.get('[type="submit"]').click();
});

// Testing multiple cards
cy.get('[data-testid="user-card"]').each(($card) => {
  cy.wrap($card).within(() => {
    cy.get('.name').should('exist');
    cy.get('.avatar').should('be.visible');
  });
});

Using .each() for Iteration

// Iterate over elements
cy.get('[data-testid="item"]').each(($item, index) => {
  cy.wrap($item).should('contain', `Item ${index + 1}`);
});

// Perform actions on each element
cy.get('.checkbox').each(($checkbox) => {
  cy.wrap($checkbox).check();
});

// Complex iteration
cy.get('.product-card').each(($card) => {
  cy.wrap($card).within(() => {
    cy.get('.price')
      .invoke('text')
      .then((price) => {
        const numPrice = parseFloat(price.replace('$', ''));
        expect(numPrice).to.be.greaterThan(0);
      });
  });
});

Custom Commands

Creating Custom Commands

// cypress/support/commands.js

// Simple command
Cypress.Commands.add('login', (email, password) => {
  cy.visit('/login');
  cy.get('[data-testid="email"]').type(email);
  cy.get('[data-testid="password"]').type(password);
  cy.get('[data-testid="submit"]').click();
  cy.url().should('include', '/dashboard');
});

// Command with options
Cypress.Commands.add('login', (email, password, options = {}) => {
  const { redirect = '/dashboard' } = options;

  cy.visit('/login');
  cy.get('[data-testid="email"]').type(email);
  cy.get('[data-testid="password"]').type(password);
  cy.get('[data-testid="submit"]').click();

  if (redirect) {
    cy.url().should('include', redirect);
  }
});

// Child command (chains off a subject)
Cypress.Commands.add('shouldBeVisible', { prevSubject: true }, (subject) => {
  cy.wrap(subject).should('be.visible');
  return cy.wrap(subject);
});

// Dual command (works with or without subject)
Cypress.Commands.add('clickIfVisible', { prevSubject: 'optional' }, (subject, selector) => {
  if (subject) {
    cy.wrap(subject).find(selector).click();
  } else {
    cy.get(selector).click();
  }
});

Using Custom Commands

// cypress/e2e/dashboard.cy.js
describe('Dashboard', () => {
  beforeEach(() => {
    cy.login('user@example.com', 'password123');
  });

  it('shows user profile', () => {
    cy.get('[data-testid="profile"]')
      .shouldBeVisible()
      .and('contain', 'user@example.com');
  });
});

Common Custom Commands

// cypress/support/commands.js

// Login via API (faster than UI)
Cypress.Commands.add('loginByApi', (email, password) => {
  cy.request('POST', '/api/login', { email, password })
    .then((response) => {
      window.localStorage.setItem('token', response.body.token);
    });
});

// Drag and drop
Cypress.Commands.add('dragTo', { prevSubject: true }, (subject, targetSelector) => {
  cy.wrap(subject).trigger('dragstart');
  cy.get(targetSelector).trigger('drop');
  cy.wrap(subject).trigger('dragend');
});

// Get by test ID
Cypress.Commands.add('getByTestId', (testId) => {
  return cy.get(`[data-testid="${testId}"]`);
});

// Assert toast message
Cypress.Commands.add('shouldShowToast', (message) => {
  cy.get('[data-testid="toast"]')
    .should('be.visible')
    .and('contain', message);
});

// Fill form fields
Cypress.Commands.add('fillForm', (formData) => {
  Object.entries(formData).forEach(([name, value]) => {
    cy.get(`[name="${name}"]`).clear().type(value);
  });
});

// Usage
cy.fillForm({
  firstName: 'John',
  lastName: 'Doe',
  email: 'john@example.com',
});

TypeScript Support

// cypress/support/index.d.ts
declare namespace Cypress {
  interface Chainable {
    login(email: string, password: string): Chainable<void>;
    getByTestId(testId: string): Chainable<JQuery<HTMLElement>>;
    shouldShowToast(message: string): Chainable<void>;
    fillForm(formData: Record<string, string>): Chainable<void>;
  }
}

Best Practices

1. Use Specific Selectors

// Bad: Too generic
cy.get('button').click();

// Good: Specific
cy.get('[data-testid="submit-button"]').click();
cy.contains('button', 'Submit').click();

2. Avoid Conditional Logic When Possible

// Bad: Conditional based on element existence
cy.get('body').then(($body) => {
  if ($body.find('.modal').length) {
    cy.get('.modal .close').click();
  }
});

// Better: Control state in test setup
beforeEach(() => {
  cy.clearCookies();
  cy.visit('/');
});

3. Keep Commands DRY with Custom Commands

// Instead of repeating this everywhere
cy.get('[data-testid="email"]').type('user@example.com');
cy.get('[data-testid="password"]').type('password');
cy.get('[data-testid="submit"]').click();

// Create a custom command
cy.login('user@example.com', 'password');

4. Use Aliases for Repeated Selections

// Bad: Repeated selection
cy.get('[data-testid="user-list"]').should('be.visible');
cy.get('[data-testid="user-list"]').find('.user').should('have.length', 5);
cy.get('[data-testid="user-list"]').find('.user').first().click();

// Good: Use alias
cy.get('[data-testid="user-list"]').as('userList');
cy.get('@userList').should('be.visible');
cy.get('@userList').find('.user').should('have.length', 5);
cy.get('@userList').find('.user').first().click();

Summary

Command Purpose
cy.get() Select by CSS selector
cy.contains() Select by text content
.find() Find within element
.within() Scope queries to element
.each() Iterate over elements
.first()/.last()/.eq() Select by position
Cypress.Commands.add() Create custom command

Key takeaways:

  • Use data-testid or data-cy attributes for reliable selectors
  • Chain commands for cleaner, more readable tests
  • Use .within() to scope queries to a container
  • Create custom commands for reusable test logic
  • Cypress automatically retries commands until they pass
  • Avoid brittle selectors like classes or complex CSS paths

Good selector strategy and custom commands make tests maintainable and resilient to UI changes.

References