Skip to main content

Custom Commands and Reusable Test Utilities

As your test suite grows, you'll repeat the same sequences of actions across tests: login, fill forms, navigate modals, verify notifications. Custom Cypress commands let you encapsulate these sequences into single, readable commands that you reuse everywhere. Building custom commands is one of the most impactful ways to reduce test boilerplate and improve maintainability.

In my experience managing test suites with hundreds of tests across multiple projects, custom commands reduced test code by 40% and made tests more readable. This article covers the patterns that scale.

Creating Your First Custom Command

Custom commands are registered in cypress/support/commands.js (for E2E) or cypress/support/component.js (for component tests). Here's a basic example:

// cypress/support/commands.js
Cypress.Commands.add('login', (email, password) => {
cy.visit('/login')
cy.get('[data-testid="email-input"]').type(email)
cy.get('[data-testid="password-input"]').type(password)
cy.get('[data-testid="login-button"]').click()
cy.url().should('include', '/dashboard')
})

Now in any test, instead of repeating those five lines, you can call:

describe('Dashboard', () => {
it('displays user dashboard', () => {
cy.login('[email protected]', 'password123')
cy.get('[data-testid="dashboard-header"]').should('exist')
})
})

The command is chainable, so you can add Cypress commands after it. The key is: custom commands should encapsulate a complete, logical action.

Command with Options

Custom commands can accept options:

// cypress/support/commands.js
Cypress.Commands.add('login', (email, password, options = {}) => {
const { skipNavigate = false } = options

if (!skipNavigate) {
cy.visit('/login')
}

cy.get('[data-testid="email-input"]').type(email, { delay: 50 })
cy.get('[data-testid="password-input"]').type(password)
cy.get('[data-testid="login-button"]').click()

if (options.expectError) {
cy.get('[data-testid="error-message"]').should('be.visible')
} else {
cy.url().should('include', '/dashboard')
}
})

// Usage with options
cy.login('[email protected]', 'password123', { expectError: true })

Wrapping Existing Commands

Some commands need to wrap a previous command (used with .within()). Use cy.Commands.overwrite():

// Overwrite the built-in click command to add logging
Cypress.Commands.overwrite('click', (originalFn, element, options) => {
cy.log(`Clicking on element: ${element}`)
return originalFn(element, options)
})

// Or overwrite visit to always set auth token
Cypress.Commands.overwrite('visit', (originalFn, url, options = {}) => {
return originalFn(url, options).then(() => {
// Set token after every visit
cy.window().then((win) => {
win.localStorage.setItem('authToken', 'test_token_123')
})
})
})

Building a Mount Command with Providers

For component tests, create a custom mount command that wraps components with providers:

// cypress/support/component.js
import React from 'react'
import { mount } from 'cypress/react'
import { BrowserRouter } from 'react-router-dom'
import { Provider } from 'react-redux'
import { store } from '../../src/store'

Cypress.Commands.add('mountWithRouter', (component, options = {}) => {
const wrapped = <BrowserRouter>{component}</BrowserRouter>
return mount(wrapped, options)
})

Cypress.Commands.add('mountWithRedux', (component, options = {}) => {
const wrapped = <Provider store={store}>{component}</Provider>
return mount(wrapped, options)
})

Cypress.Commands.add(
'mountWithProviders',
(component, { theme = 'light', ...options } = {}) => {
const wrapped = (
<Provider store={store}>
<BrowserRouter>
<ThemeProvider theme={theme}>
{component}
</ThemeProvider>
</BrowserRouter>
</Provider>
)
return mount(wrapped, options)
}
)

Now component tests are simpler:

it('renders button with router', () => {
cy.mountWithRouter(<MyButton />)
cy.get('button').should('exist')
})

it('renders component with all providers', () => {
cy.mountWithProviders(<Dashboard />, { theme: 'dark' })
cy.get('[data-testid="dashboard"]').should('exist')
})

Common Custom Commands Library

Here's a collection of practical custom commands:

// cypress/support/commands.js

// Auth commands
Cypress.Commands.add('loginAsAdmin', () => {
cy.login('[email protected]', 'admin_password')
})

Cypress.Commands.add('logout', () => {
cy.get('[data-testid="user-menu"]').click()
cy.get('[data-testid="logout-button"]').click()
cy.url().should('include', '/login')
})

// Form commands
Cypress.Commands.add('fillForm', (fields) => {
Object.entries(fields).forEach(([testId, value]) => {
if (value === true) {
cy.get(`[data-testid="${testId}"]`).check()
} else if (value === false) {
cy.get(`[data-testid="${testId}"]`).uncheck()
} else {
cy.get(`[data-testid="${testId}"]`).type(value)
}
})
})

// Usage
cy.fillForm({
'first-name': 'John',
'last-name': 'Doe',
'accept-terms': true,
})

// Modal commands
Cypress.Commands.add('openModal', (modalTestId) => {
cy.get(`[data-testid="${modalTestId}"]`).should('be.visible')
})

Cypress.Commands.add('closeModal', () => {
cy.get('[data-testid="modal-close"]').click()
cy.get('[data-testid="modal"]').should('not.exist')
})

// Navigation commands
Cypress.Commands.add('navigateTo', (path) => {
cy.get(`[data-testid="nav-${path}"]`).click()
cy.url().should('include', `/${path}`)
})

// Notification commands
Cypress.Commands.add('expectNotification', (message, type = 'success') => {
cy.get(`[data-testid="notification-${type}"]`).should('contain', message)
})

// Wait for API commands
Cypress.Commands.add('waitForApi', (alias, timeout = 5000) => {
cy.intercept(alias).as('api')
cy.wait('@api', { timeout })
})

// Accessibility commands
Cypress.Commands.add('checkA11y', (selector = 'body') => {
// Requires cypress-axe plugin: npm install --save-dev cypress-axe
cy.get(selector).checkA11y()
})

Fixtures for Reusable Test Data

Store reusable test data in fixtures:

// cypress/fixtures/users.json
{
"admin": {
"email": "[email protected]",
"password": "admin123",
"name": "Admin User"
},
"user": {
"email": "[email protected]",
"password": "password123",
"name": "Regular User"
}
}

Use fixtures in tests:

it('logs in and sees admin features', () => {
cy.fixture('users').then((users) => {
cy.login(users.admin.email, users.admin.password)
cy.get('[data-testid="admin-panel"]').should('exist')
})
})

Or load fixtures automatically in beforeEach:

describe('Admin Features', () => {
let users

beforeEach(() => {
cy.fixture('users').then((data) => {
users = data
})
})

it('admin can manage users', () => {
cy.login(users.admin.email, users.admin.password)
// test admin features
})
})

Page Object Pattern

For E2E tests, organize selectors and actions into page objects:

// cypress/support/pages/LoginPage.js
class LoginPage {
visit() {
cy.visit('/login')
return this
}

fillEmail(email) {
cy.get('[data-testid="email-input"]').type(email)
return this // For chaining
}

fillPassword(password) {
cy.get('[data-testid="password-input"]').type(password)
return this
}

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

expectError(message) {
cy.get('[data-testid="error-message"]').should('contain', message)
return this
}

expectDashboard() {
cy.url().should('include', '/dashboard')
return this
}
}

export default new LoginPage()

Use page objects in tests:

// cypress/e2e/login.cy.js
import LoginPage from '../support/pages/LoginPage'

describe('Login with Page Objects', () => {
it('successfully logs in user', () => {
LoginPage.visit()
.fillEmail('[email protected]')
.fillPassword('password123')
.submit()
.expectDashboard()
})

it('shows error on invalid password', () => {
LoginPage.visit()
.fillEmail('[email protected]')
.fillPassword('wrong')
.submit()
.expectError('Invalid credentials')
})
})

Page objects make tests more readable, and if a selector changes, you update it in one place.

TypeScript Support for Custom Commands

Add type hints to custom commands:

// cypress/support/commands.ts
declare namespace Cypress {
interface Chainable {
login(email: string, password: string): Chainable<void>
fillForm(fields: Record<string, any>): Chainable<void>
navigateTo(path: string): Chainable<void>
}
}

Cypress.Commands.add('login', (email: string, password: string) => {
cy.visit('/login')
cy.get('[data-testid="email-input"]').type(email)
cy.get('[data-testid="password-input"]').type(password)
cy.get('[data-testid="login-button"]').click()
})

// Now TypeScript knows about cy.login()

Key Takeaways

  • Create custom commands to encapsulate repeated sequences of actions and reduce test boilerplate.
  • Use custom mount commands in component tests to automatically apply providers (Redux, Router, Theme).
  • Build a library of common commands (login, fillForm, navigateTo) and reuse across all tests.
  • Use fixtures to store reusable test data (user credentials, sample objects) and load them in tests.
  • Implement the page object pattern to organize selectors and actions into reusable classes.
  • Add TypeScript support to custom commands for better IDE autocomplete and type safety.

Frequently Asked Questions

How do I pass complex objects to custom commands?

Pass them as arguments or store them in fixtures:

Cypress.Commands.add('createProject', (project) => {
cy.visit('/projects/new')
cy.get('[data-testid="name"]').type(project.name)
cy.get('[data-testid="description"]').type(project.description)
cy.get('[data-testid="create"]').click()
})

// Usage
cy.createProject({
name: 'My Project',
description: 'A great project',
})

Can I use async/await in custom commands?

Avoid async/await. Cypress commands are chainable and asynchronous by design. Use the callback pattern:

// Don't do this
Cypress.Commands.add('badCommand', async () => {
const data = await fetch('/api/data')
// ...
})

// Do this
Cypress.Commands.add('goodCommand', () => {
cy.request('/api/data').then((response) => {
// use response
})
})

Should all repeated code be a custom command?

Not all repeated code needs a custom command. Only extract sequences that:

  • Represent a complete logical action (login, fill form, navigate)
  • Are used in 3+ tests
  • Benefit from readable abstraction

Short, simple repetitions sometimes stay as inline code for clarity.

How do I share custom commands across projects?

Create an npm package with your commands and fixtures, then install it in each project. Or use a monorepo structure where tests share a common cypress/support directory.

Further Reading