Cleanly encapsulating login, cart, and TypeScript typing
Repeating login and cart steps inline in every test file leaves you with a Cypress suite that breaks in dozens of places every time the UI changes. Custom commands wrap recurring flows into reusable, type-safe building blocks, keeping E2E tests for a Magento store readable, robust, and maintainable.
Table of Contents
- 1. Why custom commands are essential in Cypress tests
- 2. Cypress.Commands.add(): parent, child, and dual commands
- 3. Building a login command with cy.session
- 4. An add-to-cart command for a Magento store
- 5. TypeScript typing: index.d.ts and declare global
- 6. Command chaining and yielding subjects correctly
- 7. Overwriting commands and options objects
- 8. Avoiding over-abstraction: when commands hurt readability
- 9. Custom commands compared side by side
- 10. Summary
- 11. FAQ
1. Why custom commands are essential in Cypress tests
In every growing Cypress suite, the same login flow, the same form fill, or the same add-to-cart click eventually shows up identically in a dozen spec files. That's not harmless duplication, it's a growing maintenance burden: if a selector, a field name, or the step order on the login page changes, every copy has to be updated in sync. In practice that rarely happens completely, and the suite quietly starts lying in individual spots because one test is still running the old flow.
Custom commands solve this by defining a flow in exactly one place and exposing it project-wide via Cypress.Commands.add(). The test itself then reads as a sequence of domain steps, cy.login(user), cy.addToCart(sku), instead of a list of CSS selectors and click coordinates. Crucially, custom commands remain expandable in the Cypress Command Log, so the abstraction doesn't make debugging harder, it just removes the repetition.
2. Cypress.Commands.add(): parent, child, and dual commands
When registering a command via Cypress.Commands.add(), Cypress distinguishes three command types controlled by the prevSubject option. A parent command like cy.login() starts a new chain and expects no previous subject, it effectively restarts the chain, similar to cy.visit() or cy.get(). A child command is registered with { prevSubject: true } and operates on the subject of the previous command, for example .getBySel('cart-badge'), which builds on an element already found via cy.get().
A dual command with { prevSubject: 'optional' } works in both roles, depending on whether it sits at the start of a chain or after another command, which suits generic helpers like cy.dataCy(). Choosing the right type isn't a minor detail: a command registered as a parent that was actually meant to process an element breaks the chain and makes subsequent .should() assertions target the wrong subject.
// cypress/support/commands.js
// Parent command: starts a new chain, no previous subject expected
Cypress.Commands.add('login', (email, password) => {
cy.visit('/customer/account/login');
cy.get('#email').type(email);
cy.get('#pass').type(password, { log: false });
cy.get('#send2').click();
});
// Child command: operates on the subject yielded by the previous command
Cypress.Commands.add(
'getBySel',
{ prevSubject: true },
(subject, selector) => {
return cy.wrap(subject).find(`[data-testid="${selector}"]`);
}
);
// Dual command: works both as a chain starter and as a chained step
Cypress.Commands.add(
'dataCy',
{ prevSubject: 'optional' },
(subject, selector) => {
const scope = subject ? cy.wrap(subject) : cy;
return scope.find(`[data-cy="${selector}"]`);
}
);
3. Building a login command with cy.session
A naive login command fills out the form and clicks submit on every single test, then waits for the redirect. Across hundreds of tests that require login, that adds up to minutes of pure waiting per test run. cy.session() fixes this by caching the session state, cookies and local storage, after the first successful login and instantly restoring it on every subsequent call with the same id, without walking through the UI again.
The setup function contains the actual UI login and runs only on the first call, or when the session has become invalid. The optional validate function checks before every reuse whether the cached session is still valid, for example via a lightweight API call against a protected endpoint. With cacheAcrossSpecs: true, the session even survives across multiple spec files, which noticeably cuts runtime for Magento stores with a sluggish customer login.
// cypress/support/commands.js
Cypress.Commands.add('login', (email, password) => {
cy.session(
[email, password],
() => {
// Runs only once per unique session id, real UI login
cy.visit('/customer/account/login');
cy.get('#email').type(email);
cy.get('#pass').type(password, { log: false });
cy.get('#send2').click();
cy.url().should('include', '/customer/account');
},
{
cacheAcrossSpecs: true,
validate() {
// Cheap check to confirm the cached session is still valid
cy.request('/customer/section/load/?sections=customer')
.its('body.customer.firstname')
.should('exist');
},
}
);
cy.visit('/customer/account');
});
4. An add-to-cart command for a Magento store
The cart flow on a Magento store typically involves several sub-steps: open the product page, optionally adjust quantity, pick configuration options, click submit, and wait for the mini-cart badge to update. A cy.addToCart(sku, options) command bundles exactly those steps behind a domain-level signature, without hiding the individual sub-steps from the Command Log, since every nested cy.get() call still shows up as its own log entry.
An options object with sensible defaults, { qty: 1, viaMiniCart: false }, keeps the common call short, cy.addToCart('24-MB01'), while still allowing targeted deviations for edge-case tests. It matters that the command yields a meaningful subject at the end, such as the mini-cart badge element, so the calling test can chain directly, cy.addToCart(sku).should('contain', '1'), instead of searching for the element again manually afterwards.
// cypress/support/commands.js
Cypress.Commands.add('addToCart', (sku, options = {}) => {
const { qty = 1, viaMiniCart = false } = options;
cy.visit(`/catalog/product/view/sku/${sku}`);
cy.get('#qty').clear().type(String(qty));
cy.get('#product-addtocart-button').click();
// Wait for the AJAX add-to-cart response before asserting anything
cy.wait('@addToCartRequest');
if (viaMiniCart) {
cy.get('[data-testid="minicart-icon"]').click();
}
// Yield the mini-cart badge so callers can chain assertions directly
return cy.get('[data-testid="minicart-qty-badge"]');
});
5. TypeScript typing: index.d.ts and declare global
Without explicit typing, TypeScript has no idea what cy.login() or cy.addToCart() are, every call produces a compile error as soon as the project enforces strict type checking. The fix is declaration merging: in a cypress/support/index.d.ts file, the existing Cypress namespace is extended via declare global and declare namespace Cypress with additional methods on the Chainable interface, without overwriting the original type.
Every method should get a JSDoc comment with a short description and an @example block, since these comments show up directly in the IDE's autocomplete preview and effectively replace a separate command documentation page. Generic return types like Chainable for DOM elements or Chainable for pure action commands stop incorrect follow-up calls, like .type() on a command with no DOM subject, from slipping through unnoticed.
// cypress/support/index.d.ts
declare global {
namespace Cypress {
interface Chainable<Subject = any> {
/**
* Logs a customer in via a cached UI session (cy.session).
* @example cy.login('jane@example.com', 'secret123')
*/
login(email: string, password: string): Chainable<void>;
/**
* Adds a product to the cart and yields the mini-cart badge element.
* @example cy.addToCart('24-MB01', { qty: 2 })
*/
addToCart(
sku: string,
options?: { qty?: number; viaMiniCart?: boolean }
): Chainable<JQuery<HTMLElement>>;
/**
* Child command: finds a descendant by data-testid attribute.
* @example cy.get('.cart').getBySel('cart-badge')
*/
getBySel(selector: string): Chainable<JQuery<HTMLElement>>;
}
}
}
export {};
6. Command chaining and yielding subjects correctly
Cypress commands aren't synchronous function calls, they're entries in an internally managed command queue that gets processed asynchronously. A common beginner mistake in custom commands is returning a value via return from inside a then() callback without realizing that Cypress, when there's no explicit return, automatically forwards the subject of the last Cypress command inside the function. Accidentally returning a raw JavaScript value instead breaks the chain and makes subsequent .should() calls unpredictable.
The reliable rule: a custom command should either explicitly return cy.wrap(value) or implicitly forward the result of the last Cypress command inside the function, never a raw promise or an unwrapped value. For commands that run several independent Cypress commands but have no meaningful subject for follow-up calls, Chainable as the return type is more honest than faking a subject that chains into nothing.
7. Overwriting commands and options objects
Cypress.Commands.overwrite() replaces the behavior of an existing command, custom or built-in, while keeping its call signature. A typical use case in Magento projects: overwriting cy.visit() to automatically send a test header on every call that bypasses the Full Page Cache, so individual tests don't have to set that header manually. The overwritten command receives the original implementation as its first parameter and must call it explicitly, otherwise the native behavior is lost entirely.
For custom commands with many variants, a single options object beats a long list of positional parameters, cy.addToCart(sku, { qty, viaMiniCart }) instead of cy.addToCart(sku, qty, viaMiniCart, false, true). Options objects with sensible defaults stay readable even as more flags get added later, while positional parameters put every existing call site syntactically at risk with each extension.
8. Avoiding over-abstraction: when commands hurt readability
The appeal of custom commands tempts you to keep packing more logic into bigger commands, until a single call like cy.checkout() swallows twelve UI steps, three assertions, and two API waits. The problem shows up when debugging a failing test: the Command Log shows that checkout failed, but which of the twelve steps specifically isn't visible without expanding the internal implementation. The test itself also loses its value as a readable specification, because the actual domain logic is hidden away in the support folder instead of being visible in the test case.
The practical rule of thumb: a custom command should represent a single domain-level step or a single technical operation, login, add-to-cart, filling one form field, not an entire multi-step user flow. Multi-step flows belong in the test itself, expressed as a sequence of several custom commands, cy.login(); cy.addToCart(sku); cy.goToCheckout(); cy.fillShippingAddress(data);, so each step stays traceable individually in the Command Log and a test failure immediately points to the right spot.
// BAD: over-abstracted command hides the entire checkout flow
Cypress.Commands.add('checkout', (sku, address, payment) => {
cy.addToCart(sku);
cy.get('[data-testid="checkout-btn"]').click();
cy.get('#firstname').type(address.firstname);
cy.get('#lastname').type(address.lastname);
cy.get('#street').type(address.street);
cy.get('[data-testid="shipping-continue"]').click();
cy.get(`[data-testid="payment-${payment}"]`).click();
cy.get('[data-testid="place-order"]').click();
cy.get('.checkout-success').should('be.visible');
// A failure here gives almost no clue which of the 8 steps broke
});
// GOOD: focused commands, the test itself stays a readable spec
cy.login('jane@example.com', 'secret123');
cy.addToCart('24-MB01', { qty: 2 });
cy.goToCheckout();
cy.fillShippingAddress(shippingFixture);
cy.selectPaymentMethod('checkmo');
cy.placeOrder();
cy.get('.checkout-success').should('be.visible');
9. Custom commands compared side by side
Not every custom command is automatically an improvement. The table below shows typical decisions in custom command design and which variant has proven more maintainable in practice.
| Task | Naive approach | Recommended pattern | Benefit |
|---|---|---|---|
| Login in every test | UI login copied inline in every spec | cy.login() with cy.session | One place to change, no UI login overhead per test |
| Selector duplication | cy.get('[data-testid=x]') duplicated everywhere | Child command cy.getBySel() | Selector strategy centrally swappable |
| Cart steps | PDP, quantity, submit done manually every test | cy.addToCart(sku, options) | Domain-level call, steps stay visible in the log |
| Full checkout | One cy.checkout() with 12 hidden steps | Chain of focused commands in the test | A failure immediately points to the right step |
| Adjusting built-in behavior | cy.visit rebuilt completely from scratch | Cypress.Commands.overwrite('visit', ...) | Native behavior stays intact, only extended precisely |
Mironsoft
E2E test automation and Cypress setups for Magento and Hyvä stores
Ready for a Cypress suite your team can actually trust?
We build maintainable custom command libraries with clean TypeScript typing, cy.session login, and clearly scoped commands for your Magento store, instead of a black box of over-abstracted test steps.
Command library audit
Reviewing existing custom commands for granularity, typing, and chaining
TypeScript setup
Setting up index.d.ts, declaration merging, and JSDoc autocomplete
CI integration
Setting up cy.session caching, parallelization, and stable pipelines
10. Summary
Cypress custom commands address the core problem of every growing E2E suite: recurring flows like login and add-to-cart need to be defined in exactly one place, not copied across dozens of spec files. Cypress.Commands.add() distinguishes parent, child, and dual commands via the prevSubject option, cy.session() caches login state and saves runtime, and a clean index.d.ts with declaration merging makes commands fully type-safe and autocomplete-friendly in TypeScript.
The biggest pitfall isn't the technology, it's getting the granularity right: a custom command should encapsulate a single domain-level step, not an entire multi-step flow. Hiding twelve UI steps inside a single cy.checkout() trades short-term brevity for long-term unreadable, hard-to-debug tests. Focused commands, correct subject yielding, and Cypress.Commands.overwrite() for targeted adjustments to built-in commands together form the foundation of a test suite that grows with the project instead of working against it.
Cypress Custom Commands, The Essentials at a Glance
Command types
Parent starts the chain, child processes a subject, dual works in both roles via prevSubject.
Login caching
cy.session() caches cookies and storage, skips UI login per test, validate() checks validity.
TypeScript typing
index.d.ts with declare global and declaration merging for full autocomplete support.
Correct granularity
One command, one domain step. Multi-step flows stay visible as a command chain in the test.