By the time a Cypress suite has fifty specs, the same five-line login dance, ten-line cart-seeding ritual, and three-line cleanup helper appear in every file. Custom commands are how you stop repeating yourself — register a function once on Cypress.Commands, type the signature, and the rest of your suite calls it as cy.login(...) with full autocomplete. This lesson takes you from a blank commands.ts to a five-command typed library covering an e-commerce app's most-repeated flows.
What a custom command is
A custom command is a function attached to the global cy chainable. After registration, every spec in the project can call it like a built-in:
// cypress/support/commands.ts
Cypress.Commands.add("login", (email: string, password: string) => {
cy.get("[data-testid='email']").type(email);
cy.get("[data-testid='password']").type(password);
cy.get("[data-testid='submit']").click();
cy.url().should("include", "/dashboard");
});// any spec
cy.login("alice@test.com", "Sup3rS3cret!");Cypress.Commands.add(name, fn) registers fn under cy.<name>. The function body uses regular cy.* commands — your custom command is just a wrapper that calls a few of them in sequence.
cypress/support/commands.ts is loaded automatically before every spec. Drop your registrations there and they're globally available.
Typing custom commands — the part nobody can skip
Without a TypeScript declaration, cy.login(...) works at runtime but the editor flags it red and you lose autocomplete on every argument. The fix is interface declaration merging:
declare global {
namespace Cypress {
interface Chainable {
login(email: string, password: string): Chainable<void>;
}
}
}
Cypress.Commands.add("login", (email: string, password: string) => {
cy.get("[data-testid='email']").type(email);
cy.get("[data-testid='password']").type(password);
cy.get("[data-testid='submit']").click();
});
export {};Three lines that catch every TypeScript user out at first:
declare global { namespace Cypress { interface Chainable { ... } } }— interface declaration merging adds your method to Cypress's existingChainableinterface. Now the compiler knowscy.loginexists and what types it expects.- The empty
export {};— turns the file into a TypeScript module sodeclare globalactually applies. Without it, the declaration stays local to the file and autocomplete still misses. - Return type
Chainable<void>— most commands don't yield a value, sovoidis fine. Commands that return data type the inner generic (Chainable<User>,Chainable<string>).
Once those are in place, every cy.login typo or wrong-argument call is caught at compile time.
API-based commands — the speed lever
UI login takes four seconds. API login takes 200 milliseconds. A 200-spec suite that logs in once per test saves over twelve minutes by switching:
declare global {
namespace Cypress {
interface Chainable {
loginViaApi(email: string, password: string): Chainable<void>;
}
}
}
Cypress.Commands.add("loginViaApi", (email: string, password: string) => {
cy.request("POST", "/api/login", { email, password }).then((response) => {
window.localStorage.setItem("authToken", response.body.token);
});
});
export {};The pattern: cy.request to call the auth endpoint directly, then store the token wherever the app expects it (local storage, session cookie, an in-memory store seeded via cy.window). Subsequent cy.visit calls land already-authenticated.
Chapter 6 covers the full login-strategy spectrum (UI, API, cy.session caching). For now, knowing the API form exists is enough — most teams adopt it for all tests except their dedicated login spec.
Commands that yield values
Commands can return a chainable that yields a value the test can use. The trick is to return the chain:
interface User { id: number; email: string; role: "admin" | "tester" }
declare global {
namespace Cypress {
interface Chainable {
createUser(user: Partial<User>): Chainable<User>;
}
}
}
Cypress.Commands.add("createUser", (user: Partial<User>) => {
return cy
.request("POST", "/api/test/users", { name: "Test User", ...user })
.its("body");
});
export {};cy.createUser({ role: "admin" }).then((user) => {
cy.visit(`/admin/users/${user.id}`);
cy.get("[data-testid='user-email']").should("contain", user.email);
});Chainable<User> tells TypeScript the next .then callback receives a User. The callback parameter is fully typed — autocomplete on user.id, user.email, user.role works exactly as if the value came from a typed cy.request.
Whenever a custom command builds something the test will read, type it as Chainable<T>. Whenever it just performs an action with no useful return, Chainable<void> is correct.
Overwriting an existing command
Sometimes you want to replace a built-in. Cypress.Commands.overwrite is the override hook:
Cypress.Commands.overwrite(
"visit",
(originalFn, url: string, options?: Partial<Cypress.VisitOptions>) => {
cy.log(`Visiting ${url}`);
return originalFn(url, options);
},
);The first callback argument is the original implementation; you can call it, log, return modified options, or skip it. Typical uses: adding instrumentation (Sentry start/stop), prefixing every visit with a tenant prefix, validating that cy.visit is never called with a hardcoded URL.
Use overwrite sparingly — it changes a globally-known command's behaviour for every test in the project. Two engineers debugging the same suite will be confused if cy.visit does something unexpected. Prefer a new command (cy.tenantVisit) when the override isn't strictly necessary.
Naming and discipline
A small style guide that pays off as the library grows:
- Verb + noun.
loginViaApi,createProduct,addToCart,seedDatabase. Avoid bare nouns (product) or bare verbs (create). - Place
declare globalnext to the implementation. One file per concern:commands.tsif the suite is small;cypress/support/commands/auth.ts,commands/cart.ts,commands/admin.tsif it grows. Re-export fromcypress/support/e2e.ts. - One action per command.
cy.loginshouldn't also visit the dashboard and assert the welcome banner. A test that runscy.loginthen expects a fresh navigation gets confusing fast. Compose narrow commands in the test body. - Avoid hard assertions inside commands. A
cy.loginthat asserts the dashboard URL is fine; acy.loginthat asserts "the cart is empty" hides intent. Let tests own their assertions; commands own the action.
A typed five-command e-commerce library
Pulling everything together — a real commands.ts for an e-commerce app:
// cypress/support/commands.ts
import type { CartItem, Product, User } from "./types";
declare global {
namespace Cypress {
interface Chainable {
login(email: string, password: string): Chainable<void>;
loginViaApi(email: string, password: string): Chainable<void>;
createProduct(data: Partial<Product>): Chainable<Product>;
addToCart(productId: number, quantity?: number): Chainable<void>;
checkout(card: { number: string; expiry: string; cvc: string }): Chainable<void>;
}
}
}
Cypress.Commands.add("login", (email, password) => {
cy.get("[data-testid='email']").type(email);
cy.get("[data-testid='password']").type(password);
cy.get("[data-testid='submit']").click();
});
Cypress.Commands.add("loginViaApi", (email, password) => {
cy.request("POST", "/api/login", { email, password })
.its("body.token")
.then((token) => window.localStorage.setItem("authToken", token));
});
Cypress.Commands.add("createProduct", (data) => {
return cy
.request<Product>("POST", "/api/test/products", {
name: "Test Product",
price: 9.99,
...data,
})
.its("body");
});
Cypress.Commands.add("addToCart", (productId, quantity = 1) => {
cy.request("POST", "/api/cart/items", { productId, quantity });
});
Cypress.Commands.add("checkout", (card) => {
cy.get("[data-testid='card-number']").type(card.number);
cy.get("[data-testid='card-expiry']").type(card.expiry);
cy.get("[data-testid='card-cvc']").type(card.cvc);
cy.get("[data-testid='pay-btn']").click();
});
export {};A spec that uses every one of them stays remarkably short:
it("places an order from the API-seeded cart", () => {
cy.loginViaApi("alice@test.com", "Sup3rS3cret!");
cy.createProduct({ name: "Test Headphones", price: 49.99 }).then((product) => {
cy.addToCart(product.id);
cy.visit("/checkout");
cy.checkout({ number: "4242424242424242", expiry: "12/29", cvc: "123" });
cy.contains("Thank you for your order").should("be.visible");
});
});Eight lines. No login dance, no cart-seeding boilerplate, no card-fill repetition. The framework writes the framework.
A custom-command library at a glance
- – Args: email, password
- – UI flow — same as a real user
- – Used by: 1–2 dedicated login specs
- – Args: email, password
- – API call + localStorage token
- – Used by: every other spec for speed
- – Args: Partial<Product>
- – Returns Chainable<Product>
- – Sets up server-side test data
- Args: productId, quantity? –
- Server-side cart seeding –
- Skips browse → click → add UI –
- Args: { number, expiry, cvc } –
- Fills the payment form –
- Reused by every checkout spec –
⚠️ Common mistakes
- Forgetting
declare globaland the emptyexport {}. Runtime works fine, but the editor flags everycy.loginred and the compiler doesn't catch wrong-argument calls. Both lines are required for the type-merging to happen. - Putting full assertion chains inside commands. A
cy.loginthat asserts "URL contains /dashboard, welcome banner is visible, no error toast" hides intent — when a test fails, the assertion is in the command, not in the test body where the reader expects it. Keep commands small; let the test do the asserting. - Caching elements as command properties.
Cypress.Commands.add("getEmailInput", () => emailInput)withemailInput = cy.get(...)cached at module load time grabs the element once and never re-queries. Always return the chain (() => cy.get(...)) — that's how Cypress's auto-retry stays alive.
🎯 Practice task
Build a typed five-command library in your scaffolded project. 25-35 minutes.
- In
cypress/support/types.ts, defineUser,Product, andCartIteminterfaces matching whatever target app you're using (Sauce Demo's flow is fine: aUserhasusernameandpassword; aProducthasid,name,price). - In
cypress/support/commands.ts, register five typed commands following the pattern in the lesson:cy.login(username, password)— UI flow.cy.loginViaApi(username, password)— bypass the UI.cy.addProductToCart(productName)— search by visible text, click Add to cart.cy.openCart()— click the cart icon.cy.completeCheckout({ firstName, lastName, postalCode })— fill the checkout form and click Finish.
- Refactor your
cypress/e2e/checkout.cy.tsfrom the chapter 2 practice task to use the new commands. The spec should drop from 30+ lines to under 15. - Force a type error — call
cy.login(123, "secret_sauce")(number for username). The compiler should reject it. Fix and confirm. - Add a yielding command —
cy.getRandomProduct(): Chainable<Product>that picks a random card from the inventory and yields its name and price. Type it asChainable<Product>. Use it in a spec to add a random product and assert the cart contains the right item. - Stretch: wire up
Cypress.Commands.overwrite("visit", ...)to log every URL the suite visits. Runnpm run cy:runand confirm the log shows all visits. This is the same pattern teams use for tenancy-prefixing or test instrumentation.
Custom commands are the smallest unit of reuse. The next lesson takes the pattern up a level — Page Object Model — for the kind of interactions that don't cleanly fit a single command.