Bhavik Bamania

Programming

Cypress Custom Commands: Write Less, Test More

Bhavik Bamania

9 min read

Share On:

Copy linkShare on FacebookShare on LinkedInShare on ThreadsShare on X
Cypress Custom Commands Banner
Cypress Custom Commands: Write Less, Test More

Stop logging in before every single test. Learn how to cache your login with cy.session(), test protected routes, and check tokens and cookies in Cypress, step by step.

Introduction

By now, our tests work well. But look closely, and you'll see the same lines again and again: type the username, type the password, click Login, mock the API. In this article, we'll pack those repeated steps into custom commands, move test data into fixtures, and keep credentials out of our code with environment variables.

This article is part 6 of our Cypress Tutorial Series, designed to help beginners master Cypress step by step. If you're new here, we recommend reading the previous articles in order:

  1. A Beginner's Guide to Cypress: End-to-End Testing Made Easy
  2. Understanding Cypress Basics: Core Features and Syntax Explained
  3. Cypress Stubs, Spies, and Clocks: Take Control of Your Tests
  4. Mocking Network Requests in Cypress with cy.intercept()
  5. Why Your Cypress Tests Break: Selectors and Test Design Best Practices

We'll keep building on the same LoginForm demo project, including the data-cy attributes from part 5.

Why Custom Commands?

Imagine you have 50 tests that log in. One day, the login form gets a new "Remember me" checkbox. Without custom commands, you update 50 tests. With a cy.login() command, you update one function, and all 50 tests are fixed.

Custom commands give you three things:

  • Less repetition: write the steps once, use them everywhere.
  • Readable tests: cy.login() says what happens; five lines of typing and clicking don't.
  • One place to fix: when the UI changes, you change one command.

Your First Custom Command

Step 1: Find the Support Folder

When you set up Cypress in part 2, it created a cypress/support folder with two files:

  • e2e.ts runs automatically before every test file. It already contains import './commands';.
  • commands.ts is where custom commands live. Right now, it's just comments.

Because e2e.ts imports commands.ts, every command we add there is available in every test. No extra imports needed.

Step 2: Create cy.getByCy()

In part 5, we wrote cy.get('[data-cy="login-button"]') dozens of times. Let's shorten it to cy.getByCy('login-button').

Replace everything in cypress/support/commands.ts with:

/// <reference types="cypress" />

// 1. Tell TypeScript about our new command
declare global {
  namespace Cypress {
    interface Chainable {
      getByCy(
        selector: string,
        options?: Partial<Loggable & Timeoutable>
      ): Chainable<JQuery<HTMLElement>>;
    }
  }
}

// 2. Create the command
Cypress.Commands.add('getByCy', (selector, options) => {
  return cy.get(`[data-cy="${selector}"]`, options);
});

export {};

Let's break it down:

  • declare global { ... } adds our command to Cypress's TypeScript types. Without it, your editor underlines cy.getByCy in red and gives you no autocomplete.
  • Chainable<JQuery<HTMLElement>> says the command returns elements, just like cy.get(). That's why we can chain .click() or .should() after it.
  • options lets us pass a timeout, like cy.getByCy('success-message', { timeout: 10000 }), exactly as we did with cy.get() in part 5.
  • Cypress.Commands.add('getByCy', ...) registers the command. The first argument is its name, the second is what it does.
  • export {}; turns the file into a module. TypeScript only allows declare global inside a module.

Step 3: Use It

Now any test can use it:

cy.getByCy('username-input').type('testuser');
cy.getByCy('login-button').click();
cy.getByCy('success-message').should('have.text', 'Logged in as testuser');

Type cy.getB in your editor, and autocomplete suggests getByCy. That's the TypeScript declaration at work.

Note: Because our command uses cy.get() inside, it keeps all the retry-ability we learned in part 5. A custom command doesn't change how Cypress waits.

Building cy.login() and cy.mockLogin()

Now for the big win. We'll create two commands:

  • cy.login(username, password) fills in the form and clicks Login, like a real user.
  • cy.mockLogin(fixture, statusCode) sets up the fake server response from part 4.

We keep them separate on purpose. This way, the same cy.login() works for success tests and error tests. Only the mock changes.

Step 1: Add the Type Declarations

Add two more lines inside the Chainable interface in commands.ts:

    interface Chainable {
      getByCy(
        selector: string,
        options?: Partial<Loggable & Timeoutable>
      ): Chainable<JQuery<HTMLElement>>;
      login(username: string, password: string): Chainable<void>;
      mockLogin(fixture?: string, statusCode?: number): Chainable<void>;
    }

The ? after fixture and statusCode means they're optional. We'll give them default values next.

Step 2: Create the Commands

Add these below the getByCy command, before export {};:

Cypress.Commands.add('login', (username, password) => {
  cy.getByCy('username-input').type(username);
  cy.getByCy('password-input').type(password, { log: false });
  cy.getByCy('login-button').click();
});

Cypress.Commands.add('mockLogin', (fixture = 'login-success.json', statusCode = 200) => {
  cy.intercept('POST', '/api/login', { statusCode, fixture }).as('login');
});

A few things to notice:

  • Commands can use other commands. login uses our own getByCy.
  • { log: false } hides the typed password from the Command Log. Your tests are often recorded in CI, so never show real passwords there.
  • Default values mean cy.mockLogin() with no arguments mocks a successful login, the most common case.
  • .as('login') stays inside the command, so tests can still use cy.wait('@login').

Step 3: Use Them

Here's a full login test with our new commands:

it('should log in', () => {
  cy.visit('/');
  cy.mockLogin();
  cy.login('testuser', 'password123');

  cy.wait('@login');
  cy.getByCy('success-message').should('have.text', 'Logged in as testuser');
});

Six readable lines instead of ten. Anyone can understand what this test does at a glance.

Keeping Test Data in Fixtures

In part 4, we used a fixture as a fake server response. Fixtures are also great for test data: the usernames and passwords your tests type in. Keeping them in one file means no more random strings scattered across tests.

Step 1: Create the Fixture Files

Create cypress/fixtures/users.json:

{
  "validUser": {
    "username": "testuser",
    "password": "password123"
  },
  "invalidUser": {
    "username": "testuser",
    "password": "wrongpassword"
  }
}

Then create cypress/fixtures/login-invalid.json for the error response:

{
  "message": "Invalid username or password"
}

Step 2: Load a Fixture with cy.fixture()

cy.fixture() reads a file from the fixtures folder. The .json extension is optional:

it('should show an error for invalid credentials', () => {
  cy.visit('/');
  cy.mockLogin('login-invalid.json', 401);

  cy.fixture('users').then((users) => {
    cy.login(users.invalidUser.username, users.invalidUser.password);
  });

  cy.wait('@login');
  cy.getByCy('error-message').should('have.text', 'Invalid username or password');
});

Look how well our commands work together. cy.mockLogin() sets up a 401 error with one line, and cy.login() is exactly the same command we used for the success test.

Note: cy.fixture() is a Cypress command, so its data is only available inside .then(). Writing const users = cy.fixture('users') won't give you the data. This is the same rule we saw with cy.window() in part 3.

Tip: Fixtures are for fake test data. Never put real passwords or API keys in them, because fixture files get committed to Git. For real secrets, use environment variables, which we'll cover next.

Using Environment Variables

In real projects, your tests run against different places: your laptop, a staging server, a CI pipeline. Each one may use a different test account. Environment variables let you change these values without touching your test code.

Cypress.env() Is Deprecated

If you've read older Cypress tutorials, you've seen Cypress.env(). It was deprecated in Cypress 15.10 and will be removed in Cypress 16, because it loaded every value into the browser, including secrets your test never used. It's replaced by two APIs:

API

Use it for

How it works

Cypress.expose('key')

Public values, like a username or feature flag

Synchronous, returns the value right away

cy.env(['key'])

Secrets, like passwords or API keys

A command, so you read the value inside .then()

Check your version with npx cypress --version. If it's below 15.10, update it:

npm install cypress@latest --save-dev

Step 1: Add the Values to the Config

Update cypress.config.ts:

import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:5173',
  },
  expose: {
    username: 'testuser', // Public: safe in the browser
  },
  env: {
    password: 'password123', // Private: read with cy.env()
  },
});

Step 2: Make cy.login() Use Them by Default

Let's make both arguments optional. When a test doesn't pass a username or password, cy.login() uses the configured ones. First, update the declaration:

      login(username?: string, password?: string): Chainable<void>;

Then replace the login command:

Cypress.Commands.add('login', (username = Cypress.expose('username'), password) => {
  cy.env(['password']).then(({ password: envPassword }) => {
    cy.getByCy('username-input').type(username);
    cy.getByCy('password-input').type(password ?? (envPassword as string), { log: false });
    cy.getByCy('login-button').click();
  });
});
  • username = Cypress.expose('username') sets a default value straight away, because Cypress.expose() is synchronous.
  • cy.env(['password']) asks for the secret by name and hands it to .then().
  • password ?? envPassword uses the password the test passed in, or falls back to the configured one.

Now a success test is as short as it gets:

cy.mockLogin();
cy.login();

Step 3: Keep Real Secrets Out of Git

Our demo password is fake, so keeping it in the config is fine. For a real test account, never commit the password. You have two safe options:

On your laptop: create cypress.env.json in the project root and add it to .gitignore:

{
  "password": "my-real-staging-password"
}

In CI: set an environment variable with the CYPRESS_ prefix. Cypress strips the prefix and makes it available to cy.env():

CYPRESS_password=my-real-staging-password npx cypress run

Both override the value in cypress.config.ts, so the config can keep a harmless default for local development.

Practical Example: The Complete Files

Combining everything, here's the final cypress/support/commands.ts:

/// <reference types="cypress" />

declare global {
  namespace Cypress {
    interface Chainable {
      getByCy(
        selector: string,
        options?: Partial<Loggable & Timeoutable>
      ): Chainable<JQuery<HTMLElement>>;
      login(username?: string, password?: string): Chainable<void>;
      mockLogin(fixture?: string, statusCode?: number): Chainable<void>;
    }
  }
}

Cypress.Commands.add('getByCy', (selector, options) => {
  return cy.get(`[data-cy="${selector}"]`, options);
});

Cypress.Commands.add('login', (username = Cypress.expose('username'), password) => {
  cy.env(['password']).then(({ password: envPassword }) => {
    cy.getByCy('username-input').type(username);
    cy.getByCy('password-input').type(password ?? (envPassword as string), { log: false });
    cy.getByCy('login-button').click();
  });
});

Cypress.Commands.add('mockLogin', (fixture = 'login-success.json', statusCode = 200) => {
  cy.intercept('POST', '/api/login', { statusCode, fixture }).as('login');
});

export {};

And a new test file, cypress/e2e/custom-commands.cy.ts:

describe('Login with Custom Commands', () => {
  beforeEach(() => {
    cy.visit('/');
  });

  it('should log in with the configured user', () => {
    cy.mockLogin();
    cy.login();

    cy.wait('@login').its('request.body.username').should('equal', 'testuser');
    cy.getByCy('success-message').should('have.text', 'Logged in as testuser');
  });

  it('should show an error for invalid credentials', () => {
    cy.mockLogin('login-invalid.json', 401);

    cy.fixture('users').then((users) => {
      cy.login(users.invalidUser.username, users.invalidUser.password);
    });

    cy.wait('@login');
    cy.getByCy('error-message').should('have.text', 'Invalid username or password');
  });
});

Run it, and both tests pass. Compare the first test with the ten-line version from part 5. Same coverage, half the code, and much easier to read.

Common Beginner Mistakes

  1. Forgetting the type declaration. The command still runs, but TypeScript shows errors and you lose autocomplete. Every Commands.add() needs a matching line in Chainable.
  2. Forgetting export {};. Without it, TypeScript rejects declare global with a confusing error.
  3. Putting too much in one command. If cy.login() also visits the page and mocks the API, you can't reuse it for error tests. Keep each command focused on one job.
  4. Storing real secrets in fixtures or expose. Both end up in the browser or in Git. Real secrets belong in cypress.env.json or CI variables, read with cy.env().

Conclusion

In this article, we turned repeated test steps into reusable custom commands with full TypeScript support. We built cy.getByCy(), cy.login(), and cy.mockLogin(), moved test data into fixtures, and kept credentials out of our code with Cypress.expose() and the new cy.env().

Our cy.login() still types into the form in every single test. With 50 tests, that's 50 logins, and it adds up fast. In the next article, we'll learn how to log in once and reuse the session across tests with cy.session(), making your test suite much faster. Stay tuned, and happy testing!

CypressTypeScriptTSXEnd-to-End TestingCypress TutorialCustom Commands

Share On:

Copy linkShare on FacebookShare on LinkedInShare on ThreadsShare on X

Written by Bhavik Bamania

Bhavik Bamania is a software engineer and writer exploring frontend engineering, web architecture, technology, history, psychology, astrology, and culture.

This site uses cookies for basic analytics (page views, no ads). You can accept or decline.