PRACTICAL GUIDE / cypress custom commands
Cypress Custom Commands: Reusable Test Actions
Cypress custom commands guide for reusable login, API setup, selectors, TypeScript typing, cleanup, command design, and hidden logic risks in CI.
In this guide9 sections
- Decide Whether a Command Is the Right Abstraction
- Add a Small Command Correctly
- Type the Chainable Contract
- Prefer API Login for Unrelated Tests
- Respect the Cypress Command Queue
- Use Parent, Child, and Dual Commands Deliberately
- Keep Assertions and Retries Honest
- Review Commands as Public Test APIs
- Refactor One Repetition at a Time
What you will learn
- Decide Whether a Command Is the Right Abstraction
- Add a Small Command Correctly
- Type the Chainable Contract
- Prefer API Login for Unrelated Tests
Custom commands usually arrive after the third copy of a login flow. The team extracts the steps, the specs shrink, and the suite feels cleaner. Six months later, cy.login() may contain a UI visit, an API call, a cookie workaround, two retries, and an assertion that hides why a test failed.
Reuse is valuable only when the command creates a clear testing vocabulary. A good command removes repeated mechanics while leaving the scenario's important behavior visible. This guide shows how to decide what deserves a command, type it correctly, and keep Cypress's retry model working for you.
Decide Whether a Command Is the Right Abstraction
Before adding to Cypress.Commands, ask how widely the behavior is reused and whether chain syntax improves the test. Not every helper needs to become global.
| Need | Prefer | Reason |
|---|---|---|
| Login used across many domains | Custom command | Stable suite-wide capability |
| Calculate expected tax | Plain function | Synchronous business calculation |
| Create an order through an API | Custom command or API client | Cypress request chain is useful |
| Actions for one checkout page | Page/domain function | Avoid polluting the global namespace |
| Build test data object | Factory function | No browser state or command queue needed |
| One spec's unusual setup | Local helper | Global reuse is not established |
A command should have a name that reads naturally in the spec: cy.loginByApi(user), cy.createOrder(data), or cy.getBySel('checkout'). Names such as cy.setup() and cy.doCheckout() conceal too many decisions.
Do not extract code only to reduce line count. If a test is the sole place where a critical sequence occurs, keeping those steps visible may make review and failure triage easier.
Add a Small Command Correctly
Cypress loads support code before spec files. A typical TypeScript project imports command registrations from cypress/support/e2e.ts:
// cypress/support/e2e.ts
import './commands'Define a selector helper in cypress/support/commands.ts:
Cypress.Commands.add('getBySel', (value: string) => {
return cy.get(`[data-cy="${value}"]`)
})Then a spec can use the same selector contract everywhere:
cy.getBySel('email').type('qa@example.com')
cy.getBySel('submit').click()Keep command registration free of side effects. Importing commands.ts should register functions, not visit a page, create users, or read mutable environment state. Otherwise every spec acquires invisible setup before its tests begin.
For larger projects, split implementations by domain, such as commands/auth.ts and commands/orders.ts, then import them from the central support file. Registration remains explicit while individual files stay reviewable.
Type the Chainable Contract
Without a declaration, TypeScript cannot offer autocomplete or validate arguments. Extend the Cypress namespace in a file included by your Cypress TypeScript configuration:
type LoginUser = {
email: string
password: string
}
type OrderInput = {
item: string
}
type Order = {
id: string
item: string
}
declare global {
namespace Cypress {
interface Chainable {
getBySel(value: string): Chainable<JQuery<HTMLElement>>
loginByApi(user: LoginUser): Chainable<void>
createOrder(input: OrderInput): Chainable<Order>
}
}
}
export {}The return type is part of the design. A query that yields an element should say so. A setup command that exposes no useful subject can return Chainable<void>. A data command should yield the created record if later steps need its ID.
Keep the implementation and declaration close enough that reviewers update both. A declaration that promises Chainable<Order> while the command yields an HTTP response gives reassuring autocomplete and incorrect runtime behavior.
Do not use any merely to make custom commands compile. It removes the main benefit of declaring them. Define small domain types for command inputs and outputs, particularly for data creation.
Prefer API Login for Unrelated Tests
Most tests need an authenticated starting state but do not need to verify the login form. Driving the UI in every beforeEach makes the suite slower and couples unrelated checks to authentication rendering.
Cypress.Commands.add('loginByApi', (user: LoginUser) => {
cy.request('POST', '/api/auth/login', user)
.its('status')
.should('eq', 200)
cy.visit('/dashboard')
})That status assertion belongs in the command because it proves setup succeeded. The product assertion does not. A test for the orders page should still state cy.contains('h1', 'Orders').should('be.visible') or another outcome relevant to that scenario.
If authentication uses cookies or local storage, prefer the real application contract. Do not manufacture a cookie value unless that is an intentionally supported test seam. cy.session() can cache validated login state across tests:
Cypress.Commands.add('loginByApi', (user: LoginUser) => {
cy.session([user.email], () => {
cy.request('POST', '/api/auth/login', user)
.its('status')
.should('eq', 200)
}, {
validate() {
cy.request('/api/auth/me').its('status').should('eq', 200)
},
})
})Include every identity-changing value in the session key. Caching only under 'user' can restore the wrong role and create order-dependent failures.
Respect the Cypress Command Queue
Cypress commands are queued and resolved later. They are not ordinary promises, and their return values cannot be captured synchronously.
This does not work:
const order = cy.createOrder({ item: 'Keyboard' })
// order is a Cypress chain, not the created order objectYield the value and continue the chain:
Cypress.Commands.add('createOrder', (input: OrderInput) => {
return cy.request<Order>('POST', '/api/orders', input)
.its('body')
})
cy.createOrder({ item: 'Keyboard' }).then((order) => {
cy.visit(`/orders/${order.id}`)
})Return the Cypress chain from the command. Starting a chain without returning it can make the yielded subject surprising and can let later refactors break ordering.
Avoid native async functions around Cypress commands. Cypress already controls sequencing and retries. Mixing two scheduling models commonly produces tests that finish before external promises or that attempt to enqueue Cypress work after the test ended.
Use Parent, Child, and Dual Commands Deliberately
Most application commands should be parent commands that begin with cy. A child command receives the previous subject and declares prevSubject.
Cypress.Commands.add(
'shouldHaveTrimmedText',
{ prevSubject: 'element' },
(subject, expected: string) => {
cy.wrap(subject).invoke('text').then((text) => {
expect(text.trim()).to.equal(expected)
})
},
)Usage remains readable:
cy.getBySel('order-status').shouldHaveTrimmedText('Dispatched')Child commands are useful when the operation is genuinely about a yielded subject. Do not force every helper into fluent syntax. A normal assertion or function is often simpler.
Dual commands, which may start a chain or receive a subject, add two contracts to test and document. Use them sparingly. Query commands also have specialized retry semantics. Reach for addQuery only when building a reusable query that must participate in Cypress's retry loop, not for ordinary actions.
Keep Assertions and Retries Honest
Cypress retries queries and linked assertions until they pass or time out. It does not automatically rerun arbitrary side effects. A custom command that clicks repeatedly inside its own manual loop can submit a payment twice. A command that catches every failure and tries a fallback can pass on an unintended screen.
Put assertions inside a command when they establish its postcondition. Examples include confirming an API-created user has an ID or verifying a cached session is valid. Keep the test's central expectation in the spec so the report states what behavior failed.
Avoid fixed waits:
// Brittle: the request may take 100 ms or 4 seconds
cy.wait(2000)Wait for the actual boundary instead:
cy.intercept('GET', '/api/orders/*').as('getOrder')
cy.visit('/orders/42')
cy.wait('@getOrder').its('response.statusCode').should('eq', 200)
cy.getBySel('order-status').should('be.visible')Do not bury that intercept in a generic navigation command unless every caller depends on the same request. Hidden aliases create collisions and make specs harder to follow.
Review Commands as Public Test APIs
Once dozens of specs call a command, changing it is an API migration. Review additions with a short checklist:
- Does the name state one recognizable capability?
- Is global scope justified by reuse across multiple specs or domains?
- Are inputs and yielded values typed without
any? - Does the implementation return its Cypress chain?
- Are setup assertions distinct from product assertions?
- Is state isolated for parallel CI execution?
- Does the command avoid hard waits, silent catches, and broad retries?
- Would the failure log reveal which operation failed?
Also search call sites before changing behavior. Adding an automatic cy.visit() to an established login command may reset the route for tests that deliberately log in from another page.
Refactor One Repetition at a Time
Begin with an inventory, not a new command library. Find a repeated sequence that already behaves consistently, extract the smallest stable capability, add its TypeScript contract, and migrate two or three specs. Compare command logs and failure messages before rolling it across the suite.
The best first candidates are usually API authentication, predictable test-data creation, and a shared selector convention. Leave complex end-to-end workflows in the specs until their boundaries are understood. A concise test is useful, but a test whose business intent is obvious during a failed CI run is the real target.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01
- 02Cypress best practices
Cypress
Canonical recommendations for selectors, state, test isolation, and assertions.
- 03
FAQ / QUICK ANSWERS
Questions testers ask
What are Cypress custom commands?
Cypress custom commands are reusable commands added to the Cypress command chain. Teams use them for repeated actions such as login, data setup, selecting stable elements, API helpers, and common workflows that appear across many specs.
Where do Cypress custom commands go?
Most projects define custom commands in cypress/support/commands.js or cypress/support/commands.ts and load them from the support file. Larger projects may split commands by domain and import them from the central support entry.
Should I put assertions in Cypress custom commands?
Put small setup assertions in commands when they prove the command succeeded, but avoid hiding the main test assertion. The spec should still clearly show the behavior being verified so failures are easy to understand.
How do I type Cypress custom commands in TypeScript?
Add declarations for the Cypress.Chainable interface, usually in a support type file. The command implementation and the type declaration should agree on parameters and return subject so autocomplete and compile checks stay useful.
Can custom commands make Cypress tests flaky?
Yes, if they hide waits, share state, depend on unstable selectors, or combine too many actions. Good commands reduce flakiness by standardizing reliable setup. Poor commands make failures harder to trace.
RELATED GUIDES
Continue the learning route
GUIDE 01
Cypress Best Practices
Cypress best practices for stable E2E tests: selectors, waits, isolation, Page Objects, network stubs, CI tips, and common mistakes to avoid in real projects.
GUIDE 02
Cypress vs Playwright 2026: Which Tool Should You Choose?
Cypress vs Playwright 2026 comparison for QA teams choosing E2E tools, covering speed, browsers, debugging, API testing, CI, and overall team fit.
GUIDE 03
How to Build a Test Automation Framework from Scratch
Learn how to build a test automation framework from scratch with layers, design patterns, reporting, CI/CD hooks, and a practical starter architecture.
GUIDE 04
Flaky Tests: Causes and How to Fix Them
Learn how to fix flaky tests with root cause analysis, stable waits, quarantine strategy, CI retries policy, and practical Playwright examples.