Cypress Best Practices: Custom Commands, Page Objects, and Anti-Patterns

Cypress Best Practices: Custom Commands, Page Objects, and Anti-Patterns

A Cypress test suite that starts clean can become a maintenance nightmare if it grows without structure. Duplicated login flows, fragile selectors, and tests that share state all contribute to slow, flaky, hard-to-debug pipelines. This guide covers the patterns that keep Cypress test suites maintainable as they grow.

Use data-cy Attributes for Selectors

The most common source of test brittleness is selectors tied to implementation details — CSS classes, element order, or visible text.

Fragile selectors:

cy.get('.btn-primary').click();                    // breaks when CSS changes
cy.get('ul > li:nth-child(2) > button').click();  // breaks when markup changes
cy.contains('Submit').click();                    // breaks when copy changes

Resilient selectors:

cy.get('[data-cy="submit-order"]').click();

Add data-cy attributes to your HTML specifically for testing:

<button data-cy="submit-order" class="btn btn-primary">
  Place Order
</button>

These attributes:

  • Are invisible to users
  • Don't affect styling or behavior
  • Signal testing intent to developers
  • Never break due to CSS or copy changes

Configure data-cy in the Cypress selector playground for even faster authoring:

// cypress.config.ts
export default defineConfig({
  e2e: {
    selectorPriority: ['data-cy', 'data-testid', 'aria-label'],
  },
});

Custom Commands

Custom commands let you encapsulate complex, repeated interactions as reusable single-line calls.

Add to cypress/support/commands.ts:

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

// API-based login (faster)
Cypress.Commands.add('loginViaApi', (email: string, password: string) => {
  cy.request('POST', '/api/auth/login', { email, password })
    .its('body.token')
    .then((token) => {
      localStorage.setItem('auth_token', token);
    });
});

// Create test data
Cypress.Commands.add('createTestUser', (overrides = {}) => {
  const user = {
    name: 'Test User',
    email: `test-${Date.now()}@example.com`,
    role: 'member',
    ...overrides,
  };
  return cy.request('POST', '/api/test/users', user).its('body');
});

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

Declare types in cypress/support/index.d.ts:

declare namespace Cypress {
  interface Chainable {
    login(email: string, password: string): Chainable<void>;
    loginViaApi(email: string, password: string): Chainable<void>;
    createTestUser(overrides?: Partial<User>): Chainable<User>;
    dragTo(target: string): Chainable<void>;
  }
}

Use in tests:

before(() => {
  cy.login('admin@example.com', 'password');
});

it('admin can manage users', () => {
  cy.createTestUser({ role: 'member' }).then((user) => {
    cy.visit(`/admin/users/${user.id}`);
    cy.get('[data-cy="promote-admin"]').click();
    cy.get('[data-cy="user-role"]').should('contain', 'admin');
  });
});

Page Objects

Page Objects encapsulate the structure and interactions of a page. They make tests read like user stories and keep selector changes isolated to one place.

// cypress/pages/LoginPage.ts
export class LoginPage {
  visit() {
    cy.visit('/login');
    return this;
  }

  fillEmail(email: string) {
    cy.get('[data-cy="email"]').clear().type(email);
    return this;
  }

  fillPassword(password: string) {
    cy.get('[data-cy="password"]').clear().type(password);
    return this;
  }

  submit() {
    cy.get('[data-cy="submit"]').click();
    return this;
  }

  expectError(message: string) {
    cy.get('[data-cy="error-message"]').should('contain', message);
    return this;
  }
}
// cypress/e2e/auth.cy.ts
import { LoginPage } from '../pages/LoginPage';

const loginPage = new LoginPage();

describe('Login', () => {
  it('redirects to dashboard on success', () => {
    loginPage
      .visit()
      .fillEmail('user@example.com')
      .fillPassword('correctpassword')
      .submit();

    cy.url().should('include', '/dashboard');
  });

  it('shows error for wrong password', () => {
    loginPage
      .visit()
      .fillEmail('user@example.com')
      .fillPassword('wrongpassword')
      .submit()
      .expectError('Invalid credentials');
  });
});

When to use Page Objects:

  • Pages that appear in many tests (login, navigation)
  • Pages with many interactive elements
  • When the same interactions are repeated across 3+ tests

When not to use Page Objects:

  • Simple one-off tests
  • Tests that only visit a page once
  • When the abstraction adds more complexity than it removes

Fixtures for Test Data

Keep test data in cypress/fixtures/ instead of inline in tests:

// cypress/fixtures/product.json
{
  "id": "prod-1",
  "name": "Pro Plan",
  "price": 99.00,
  "currency": "USD",
  "features": ["unlimited tests", "priority support"]
}
it('shows product details', () => {
  cy.fixture('product.json').then((product) => {
    cy.intercept('GET', '/api/products/prod-1', { body: product });
    cy.visit('/products/prod-1');
    cy.get('[data-cy="product-name"]').should('contain', product.name);
    cy.get('[data-cy="product-price"]').should('contain', product.price);
  });
});

For dynamic fixtures with timestamps or unique IDs:

// cypress/fixtures/factories.ts
export const makeUser = (overrides = {}) => ({
  id: `user-${Date.now()}`,
  name: 'Test User',
  email: `test-${Date.now()}@example.com`,
  createdAt: new Date().toISOString(),
  ...overrides,
});

Managing Authentication State

Every test that requires authentication should reuse a saved session. Re-running the login flow for each test is the single biggest cause of slow Cypress suites.

// cypress/support/commands.ts
Cypress.Commands.add('loginAs', (role: 'admin' | 'member' | 'viewer') => {
  const credentials = {
    admin: { email: 'admin@example.com', password: 'adminpass' },
    member: { email: 'member@example.com', password: 'memberpass' },
    viewer: { email: 'viewer@example.com', password: 'viewerpass' },
  };

  const { email, password } = credentials[role];

  cy.session(role, () => {
    cy.request('POST', '/api/auth/login', { email, password })
      .then(({ body }) => {
        localStorage.setItem('auth_token', body.token);
        document.cookie = `session=${body.sessionId}`;
      });
  });
});

Test Isolation

Each test should set up its own state and leave no side effects for the next test. Shared state between tests is the primary cause of non-deterministic failures.

describe('Orders', () => {
  let orderId: string;

  beforeEach(() => {
    cy.loginAs('member');
    
    // Create fresh data for each test
    cy.request('POST', '/api/test/orders', {
      items: [{ productId: 'prod-1', quantity: 1 }],
    }).then(({ body }) => {
      orderId = body.id;
    });
  });

  afterEach(() => {
    // Clean up after each test
    cy.request('DELETE', `/api/test/orders/${orderId}`);
  });

  it('displays order details', () => {
    cy.visit(`/orders/${orderId}`);
    cy.get('[data-cy="order-status"]').should('contain', 'pending');
  });
});

Anti-Patterns to Avoid

Using cy.wait() with fixed timeouts:

// Bad — brittle and slow
cy.wait(3000);

// Good — waits for actual condition
cy.get('[data-cy="spinner"]').should('not.exist');
cy.wait('@loadUsers'); // wait for a specific request

Chaining too many then() callbacks:

// Bad — callback hell
cy.get('.list')
  .then(($list) => {
    cy.get('.item', { withinSubject: $list })
      .then(($item) => {
        cy.wrap($item).click().then(() => {
          cy.get('.modal').should('be.visible');
        });
      });
  });

// Good — let Cypress handle the chain
cy.get('.list').within(() => {
  cy.get('.item').first().click();
});
cy.get('.modal').should('be.visible');

Testing implementation details:

// Bad — tests internal state
cy.window().its('__store__.state.user.isLoggedIn').should('be.true');

// Good — tests visible behavior
cy.get('[data-cy="user-avatar"]').should('be.visible');
cy.get('[data-cy="login-button"]').should('not.exist');

Sharing test data across tests:

// Bad — tests depend on each other's side effects
it('creates a user', () => {
  cy.get('[data-cy="create-user"]').click();
  // This leaves a user in the DB
});

it('deletes the user', () => {
  // Depends on the previous test having run first
  cy.get('[data-cy="delete-user"]').click();
});

// Good — each test is self-contained
it('creates and deletes a user', () => {
  cy.createTestUser().then((user) => {
    cy.visit(`/admin/users/${user.id}`);
    cy.get('[data-cy="delete-user"]').click();
    cy.get('[data-cy="confirm-delete"]').click();
    cy.get('[data-cy="success-toast"]').should('be.visible');
  });
});

Test Organization

cypress/
  e2e/
    auth/
      login.cy.ts
      signup.cy.ts
      password-reset.cy.ts
    checkout/
      cart.cy.ts
      payment.cy.ts
      confirmation.cy.ts
    admin/
      users.cy.ts
      settings.cy.ts
  pages/
    LoginPage.ts
    CheckoutPage.ts
    AdminPanel.ts
  support/
    commands.ts        ← custom commands
    component.tsx      ← component test setup
    e2e.ts            ← E2E test setup
    index.d.ts        ← TypeScript types
  fixtures/
    users.json
    products.json
    orders.json

Summary

The patterns that matter most for maintainability:

  1. data-cy attributes — decouple tests from CSS and copy
  2. Custom commands — encapsulate repeated interactions (especially auth)
  3. cy.session() — run login once per test group, not per test
  4. Page Objects — isolate selector changes to one file per page
  5. Test isolation — create and destroy test data per test
  6. Fixtures — keep test data out of test files

The anti-patterns that hurt most:

  • cy.wait(3000) — brittle, slow, unnecessary
  • Shared state between tests — non-deterministic failures
  • CSS selectors — break on every style refactor
  • Testing implementation details — breaks on internal refactors

A well-structured Cypress suite should read like user stories, not implementation specs.

Read more