Cypress custom commands aren’t just for reducing boilerplate; they’re your primary tool for abstracting away complex interaction patterns and making your test suite more declarative.

Let’s watch one in action. Imagine we’re testing an e-commerce site, and repeatedly logging in is a pain. We’d normally write this out for every test that needs a logged-in user:

describe('Product Listing', () => {
  beforeEach(() => {
    cy.visit('/login');
    cy.get('#username').type('testuser');
    cy.get('#password').type('password123');
    cy.get('button[type="submit"]').click();
    cy.url().should('include', '/dashboard');
  });

  it('displays products', () => {
    cy.get('.product-list').should('be.visible');
  });
});

That’s a lot of repetition. We can encapsulate this entire login flow into a custom command. First, we define it in cypress/support/commands.js:

// cypress/support/commands.js
Cypress.Commands.add('login', (username, password) => {
  cy.visit('/login');
  cy.get('#username').type(username);
  cy.get('#password').type(password);
  cy.get('button[type="submit"]').click();
  cy.url().should('include', '/dashboard');
});

Now, our test becomes incredibly clean:

describe('Product Listing', () => {
  beforeEach(() => {
    cy.login('testuser', 'password123'); // Our custom command!
  });

  it('displays products', () => {
    cy.get('.product-list').should('be.visible');
  });
});

This simple cy.login() command does more than just save typing. It enforces a consistent login process across all your tests. If the login form changes, you only need to update the commands.js file, not every single test file.

The mental model here is about building an API for your test suite. Cypress itself provides commands like cy.get(), cy.click(), cy.type(). Custom commands let you build higher-level abstractions on top of these. Think of them as verbs that describe actions your users take or states your application should be in.

Consider a more complex scenario: adding an item to a shopping cart. This might involve several steps: finding the product, clicking "Add to Cart," and verifying the cart count updates.

// cypress/support/commands.js (continued)
Cypress.Commands.add('addItemToCart', (productName) => {
  cy.contains('.product-item', productName)
    .find('button.add-to-cart')
    .click();
  cy.get('.cart-count').should('contain', '1'); // Basic verification
});

And in a test:

describe('Shopping Cart', () => {
  beforeEach(() => {
    cy.login('testuser', 'password123');
    cy.visit('/products');
  });

  it('adds an item and updates cart count', () => {
    cy.addItemToCart('Awesome T-Shirt');
    // Further assertions about the cart
  });
});

Custom commands can accept arguments, as seen with username and password in cy.login(), and productName in cy.addItemToCart(). They can also return values, although this is less common and often discouraged in favor of chaining further Cypress commands. By default, custom commands return the cy object, allowing for chaining.

When defining custom commands, you have access to the full power of Cypress. You can use cy.request() for API calls, cy.intercept() for network mocking, and even other custom commands within your custom commands. This allows you to build very sophisticated, reusable building blocks.

The real power emerges when you start thinking about state. Many tests require the application to be in a specific state before they can run. Instead of repeating the setup for that state, you create a custom command. This could be as simple as logging in, or as complex as creating a specific user with predefined data, setting up a particular database entry, or navigating through a multi-step wizard.

For instance, imagine a scenario where you need a user with admin privileges and a specific order history.

// cypress/support/commands.js (continued)
Cypress.Commands.add('loginAsAdminWithOrders', () => {
  // Use cy.request to bypass UI for faster setup if possible
  cy.request('POST', '/api/login', {
    username: 'admin',
    password: 'adminpassword'
  }).then(response => {
    expect(response.body.token).to.exist;
    window.localStorage.setItem('authToken', response.body.token);
  });

  // Then, navigate and ensure the UI reflects admin state
  cy.visit('/dashboard');
  cy.get('.admin-panel').should('be.visible');

  // Potentially seed order data via API if needed for specific tests
  // cy.request('POST', '/api/orders', { userId: 'admin', items: [...] });
});

This command sets up a complex state using a combination of API calls and UI interactions, making tests that require this specific setup much cleaner:

describe('Admin Dashboard', () => {
  beforeEach(() => {
    cy.loginAsAdminWithOrders();
  });

  it('shows all users', () => {
    cy.get('.user-list-table').should('be.visible');
  });
});

The most surprising thing about custom commands is how they facilitate a functional testing paradigm. By abstracting away how you get to a certain state and focusing on the fact that you are in that state, your tests become descriptions of desired outcomes rather than step-by-step instructions. This shift makes tests easier to read, maintain, and reason about, as they describe what the system should do, not how to make it do it.

The next step in mastering Cypress is often understanding how to create commands that are truly asynchronous and can handle complex promise-based logic, which leads into advanced patterns for managing state and side effects within your test suite.

Want structured learning?

Take the full Cypress course →