Every test suite has repetitive multi-step workflows: logging in, adding a product to the cart, filling out a form, navigating to a specific page. Without custom commands, this code is duplicated across dozens of test files. Cypress custom commands let you encapsulate these workflows into single, reusable calls — cy.login('user', 'pass') instead of five lines of type-and-click. They are the Cypress equivalent of Selenium’s Page Object methods, but integrated directly into the cy command chain.
Creating Custom Commands with Cypress.Commands.add
Custom commands are defined in cypress/support/commands.ts and become available globally on the cy object — no imports needed in test files.
// ── cypress/support/commands.ts ──
// ── Custom command: login via UI ──
Cypress.Commands.add('loginUI', (username: string, password: string) => {
cy.visit('/');
cy.get('[data-test="username"]').type(username);
cy.get('[data-test="password"]').type(password);
cy.get('[data-test="login-button"]').click();
cy.url().should('include', '/inventory');
});
// ── Custom command: login via API (faster — bypasses UI) ──
Cypress.Commands.add('loginAPI', (username: string, password: string) => {
cy.request({
method: 'POST',
url: '/api/auth/login',
body: { username, password },
}).then((response) => {
expect(response.status).to.eq(200);
// Set the auth cookie/token from the API response
window.localStorage.setItem('authToken', response.body.token);
});
});
// ── Custom command: add product to cart ──
Cypress.Commands.add('addToCart', (productIndex: number = 0) => {
cy.get('.inventory_item')
.eq(productIndex)
.find('button')
.contains(/add to cart/i)
.click();
});
// ── Custom command: assert cart count ──
Cypress.Commands.add('cartShouldHave', (count: number) => {
if (count === 0) {
cy.get('.shopping_cart_badge').should('not.exist');
} else {
cy.get('.shopping_cart_badge').should('have.text', String(count));
}
});
// ── Custom command: fill address form ──
Cypress.Commands.add('fillAddress', (address: {
firstName: string;
lastName: string;
postcode: string;
}) => {
cy.get('[data-test="firstName"]').clear().type(address.firstName);
cy.get('[data-test="lastName"]').clear().type(address.lastName);
cy.get('[data-test="postalCode"]').clear().type(address.postcode);
});
// ══════════════════════════════════════════
// ── Using custom commands in tests ──
// ══════════════════════════════════════════
// ── cypress/e2e/checkout.cy.ts ──
/*
describe('Checkout Flow', () => {
beforeEach(() => {
cy.loginUI('standard_user', 'secret_sauce');
});
it('should complete a purchase', () => {
cy.addToCart(0);
cy.addToCart(1);
cy.cartShouldHave(2);
cy.get('.shopping_cart_link').click();
cy.get('[data-test="checkout"]').click();
cy.fillAddress({
firstName: 'Alice',
lastName: 'Tester',
postcode: 'SW1A 1AA',
});
cy.get('[data-test="continue"]').click();
cy.get('[data-test="finish"]').click();
cy.contains('Thank you for your order').should('be.visible');
});
});
*/
// ── Compare: without vs with custom commands ──
/*
WITHOUT custom commands (30 lines):
cy.visit('/');
cy.get('[data-test="username"]').type('standard_user');
cy.get('[data-test="password"]').type('secret_sauce');
cy.get('[data-test="login-button"]').click();
cy.url().should('include', '/inventory');
cy.get('.inventory_item').eq(0).find('button').click();
cy.get('.inventory_item').eq(1).find('button').click();
cy.get('.shopping_cart_badge').should('have.text', '2');
...
WITH custom commands (10 lines):
cy.loginUI('standard_user', 'secret_sauce');
cy.addToCart(0);
cy.addToCart(1);
cy.cartShouldHave(2);
...
*/
cypress/support/commands.ts are automatically loaded before every test because the support file (cypress/support/e2e.ts) imports it. You do not need to import commands in individual test files — they are globally available on the cy object. This global availability is convenient but means you should name commands carefully to avoid collisions: use prefixes like cy.loginUI, cy.loginAPI, cy.cartShouldHave rather than generic names like cy.login or cy.check (which conflicts with the built-in .check() for checkboxes).cy.loginAPI() command that authenticates via API request instead of clicking through the login form. API login sets the auth token directly in localStorage or cookies — skipping the UI entirely. This is 5-10x faster than UI login and should be used in beforeEach() for every test that needs an authenticated user. Reserve cy.loginUI() for tests that specifically verify the login form itself.cy.loginUI() command can include cy.url().should('include', '/inventory') because verifying the login succeeded is part of the login workflow. But a cy.addToCart() command should NOT assert the cart count — different tests may add different numbers of items and need different assertions. Keep commands focused on actions; let tests handle assertions.Common Mistakes
Mistake 1 — Duplicating login code in every test instead of using a custom command
❌ Wrong: Five lines of login code copy-pasted into 40 test files — a locator change requires 40 edits.
✅ Correct: cy.loginUI('user', 'pass') — defined once in commands.ts, used everywhere. A locator change requires one edit.
Mistake 2 — Using UI login in beforeEach when API login is available
❌ Wrong: beforeEach(() => { cy.loginUI('user', 'pass'); }) — adds 3-5 seconds per test for form interaction.
✅ Correct: beforeEach(() => { cy.loginAPI('user', 'pass'); cy.visit('/inventory'); }) — authenticates in milliseconds via API.