Cypress Custom Commands: Build a Reusable Test Library

Cypress Custom Commands: Build a Reusable Test Library

Cypress custom commands let you extract repeated test logic into named, reusable commands that read like domain language: cy.login(), cy.createOrder(), cy.assertProductCard(). They live in cypress/support/commands.js, are available in all test files automatically, and accept parameters like regular functions. Well-designed custom commands reduce test duplication, make tests readable for non-engineers, and isolate implementation details so UI changes only require updating commands, not every test.

Key Takeaways

Custom commands are the Cypress equivalent of page objects. Instead of new LoginPage().fill(username, password).submit(), you write cy.login(username, password). Same abstraction, better integration with Cypress's command queue.

Add TypeScript declarations to get autocomplete. Without cypress/support/index.d.ts, custom commands are invisible to TypeScript and you lose IDE support entirely.

Commands can be synchronous or asynchronous. Cypress commands are always added to the command queue, but the callback can be synchronous (for simple DOM operations) or yield Cypress commands (for complex interactions).

Overwriting built-in commands to add logging is powerful. Wrapping cy.visit() to always log the URL, or wrapping cy.click() to always wait for network idle, standardizes behavior across all tests.

Avoid commands that do too much. A command named cy.setupTestEnvironment() that logs in, seeds a database, and opens a specific page is too broad. Split into cy.login(), cy.seedData(), and let tests navigate themselves.

Command Registration

Custom commands are registered in cypress/support/commands.js (or commands.ts):

// cypress/support/commands.js

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

// Command with default options
Cypress.Commands.add('createUser', (overrides = {}) => {
  const defaults = {
    name: 'Test User',
    email: `test-${Date.now()}@example.com`,
    role: 'member',
  };
  return cy.request('POST', '/api/users', { ...defaults, ...overrides });
});

Import commands in cypress/support/e2e.js:

// cypress/support/e2e.js
import './commands';

TypeScript Declarations

Without declarations, TypeScript doesn't know your custom commands exist:

// cypress/support/index.d.ts
declare namespace Cypress {
  interface Chainable {
    /**
     * Log in via the UI with the given credentials.
     * @example cy.login('user@example.com', 'password123')
     */
    login(email: string, password: string): Chainable<void>;

    /**
     * Create a user via API. Returns the created user object.
     * @example cy.createUser({ role: 'admin' }).then(user => ...)
     */
    createUser(overrides?: Partial<User>): Chainable<User>;

    /**
     * Assert that an error toast with the given message is visible.
     * @example cy.assertErrorToast('Invalid email address')
     */
    assertErrorToast(message: string): Chainable<void>;
  }
}

Command Patterns

Query Commands (Return DOM Elements)

// Get a table row by its data-id attribute
Cypress.Commands.add('getRow', (id) => {
  return cy.get(`[data-row-id="${id}"]`);
});

// Usage:
cy.getRow('user-123').find('[data-testid="status"]').should('have.text', 'active');

Action Commands (Perform Interactions)

// Fill and submit a form
Cypress.Commands.add('fillContactForm', (data) => {
  cy.get('[data-testid="contact-name"]').clear().type(data.name);
  cy.get('[data-testid="contact-email"]').clear().type(data.email);
  cy.get('[data-testid="contact-message"]').clear().type(data.message);
  cy.get('[data-testid="contact-submit"]').click();
});

// Select an option from a custom dropdown (not a native select)
Cypress.Commands.add('selectDropdown', (triggerSelector, optionText) => {
  cy.get(triggerSelector).click();
  cy.get('[role="listbox"] [role="option"]').contains(optionText).click();
  cy.get('[role="listbox"]').should('not.exist');
});

Assertion Commands

// Assert product card contents
Cypress.Commands.add('assertProductCard', (product) => {
  cy.get(`[data-testid="product-${product.id}"]`).within(() => {
    cy.get('[data-testid="product-name"]').should('have.text', product.name);
    cy.get('[data-testid="product-price"]').should('have.text', `$${product.price}`);
    if (product.badge) {
      cy.get('[data-testid="product-badge"]').should('have.text', product.badge);
    }
  });
});

API Commands (Skip UI for Setup)

// Log in via API (much faster than UI login)
Cypress.Commands.add('loginViaApi', (email, password) => {
  cy.request({
    method: 'POST',
    url: '/api/auth/login',
    body: { email, password },
  }).then((response) => {
    // Store the token in local storage
    window.localStorage.setItem('auth_token', response.body.token);
  });
});

// Seed test data via API
Cypress.Commands.add('seedOrders', (count = 5) => {
  Cypress._.times(count, (i) => {
    cy.request('POST', '/api/orders', {
      product: `Product ${i}`,
      quantity: i + 1,
      price: (i + 1) * 10,
    });
  });
});

Overwriting Built-in Commands

Use Cypress.Commands.overwrite() to modify existing commands:

// Add network idle wait after every click
Cypress.Commands.overwrite('click', (originalFn, element, options) => {
  return originalFn(element, options).then(() => {
    // Wait for any pending network requests to complete
    cy.wait(0); // Flushes the queue; replace with specific intercept if needed
  });
});

// Log URL on every visit
Cypress.Commands.overwrite('visit', (originalFn, url, options) => {
  cy.log(`Visiting: ${url}`);
  return originalFn(url, options);
});

// Add retry logic to type
Cypress.Commands.overwrite('type', (originalFn, element, text, options) => {
  return originalFn(element, text, { delay: 0, ...options });
});

Handling Asynchronous Operations

Using .then() to Chain

Cypress.Commands.add('getAuthToken', () => {
  return cy.request('POST', '/api/auth/token', {
    grant_type: 'client_credentials',
    client_id: Cypress.env('CLIENT_ID'),
    client_secret: Cypress.env('CLIENT_SECRET'),
  }).then((response) => {
    return response.body.access_token;
  });
});

// Usage:
cy.getAuthToken().then((token) => {
  cy.request({
    method: 'GET',
    url: '/api/protected',
    headers: { Authorization: `Bearer ${token}` },
  });
});

Wrapping Promises

Cypress.Commands.add('generateTestData', () => {
  return cy.wrap(
    fetch('/api/test-data/generate', { method: 'POST' })
      .then(res => res.json())
  );
});

Organizing a Command Library

As your command library grows, organize it by domain:

cypress/support/
├── commands/
│   ├── auth.commands.js       # cy.login, cy.logout, cy.loginViaApi
│   ├── api.commands.js        # cy.apiRequest, cy.seedDatabase
│   ├── form.commands.js       # cy.fillForm, cy.selectDropdown
│   ├── assertions.commands.js # cy.assertToast, cy.assertTableRow
│   └── navigation.commands.js # cy.gotoPage, cy.waitForNavigation
├── commands.js                # Imports all command files
└── e2e.js                     # Imports commands.js
// cypress/support/commands.js
import './commands/auth.commands';
import './commands/api.commands';
import './commands/form.commands';
import './commands/assertions.commands';
import './commands/navigation.commands';

Real-World Example: E-Commerce Test Suite

// cypress/support/commands/auth.commands.js
Cypress.Commands.add('loginAsAdmin', () => {
  cy.loginViaApi(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
  cy.visit('/admin');
  cy.get('[data-testid="admin-dashboard"]').should('be.visible');
});

// cypress/support/commands/shop.commands.js
Cypress.Commands.add('addToCart', (productId, quantity = 1) => {
  cy.visit(`/products/${productId}`);
  cy.get('[data-testid="quantity-input"]').clear().type(String(quantity));
  cy.get('[data-testid="add-to-cart"]').click();
  cy.get('[data-testid="cart-count"]').should('be.visible');
});

Cypress.Commands.add('checkout', (paymentDetails) => {
  cy.visit('/checkout');
  cy.get('[data-testid="card-number"]').type(paymentDetails.cardNumber);
  cy.get('[data-testid="card-expiry"]').type(paymentDetails.expiry);
  cy.get('[data-testid="card-cvv"]').type(paymentDetails.cvv);
  cy.get('[data-testid="place-order"]').click();
  cy.url().should('include', '/order-confirmation');
});
// cypress/e2e/purchase-flow.cy.js
describe('Purchase flow', () => {
  beforeEach(() => {
    cy.loginViaApi('customer@example.com', 'password');
  });

  it('completes a purchase', () => {
    cy.addToCart('prod-123', 2);
    cy.checkout({
      cardNumber: '4242424242424242',
      expiry: '12/28',
      cvv: '123',
    });
    cy.get('[data-testid="order-number"]').should('be.visible');
  });
});

Notice how readable this is—the test reads like a user story, with all implementation details hidden in commands.

Anti-Patterns to Avoid

Anti-pattern: Commands that mix concerns

// Bad: too much in one command
Cypress.Commands.add('setupAndRunTest', () => {
  cy.login('admin', 'pass');
  cy.seedDatabase();
  cy.visit('/dashboard');
  cy.get('[data-testid="widget"]').should('be.visible');
  // This command does setup, navigation, AND assertion
});

Anti-pattern: Hardcoded selectors repeated in commands

// Bad: same selector in multiple commands
Cypress.Commands.add('clickSubmit', () => {
  cy.get('.submit-btn').click(); // What if this class changes?
});

// Good: use data-testid
Cypress.Commands.add('clickSubmit', () => {
  cy.get('[data-testid="submit"]').click();
});

Anti-pattern: Commands that return DOM elements across domain boundaries

// Confusing: what does this return? What's its type?
Cypress.Commands.add('getAndClickUser', (id) => {
  return cy.get(`[data-id="${id}"]`).click();
});

// Better: separate query and action
Cypress.Commands.add('getUser', (id) => cy.get(`[data-id="${id}"]`));

Summary

Custom commands are the primary mechanism for building maintainable Cypress test suites. Invest in:

  1. Commands for authentication (fast API-based login)
  2. Commands for data seeding (API requests instead of UI flows)
  3. Commands for common interactions (forms, dropdowns, modals)
  4. Commands for assertions (complex component state checks)
  5. TypeScript declarations for IDE support

Well-designed commands mean that when the UI changes, you update one command file—not 50 test files.

Read more

Start now free