Custom Commands — Building a Reusable Command Library

9 min read

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 existing Chainable interface. Now the compiler knows cy.login exists and what types it expects.
  • The empty export {}; — turns the file into a TypeScript module so declare global actually 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, so void is 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 global next to the implementation. One file per concern: commands.ts if the suite is small; cypress/support/commands/auth.ts, commands/cart.ts, commands/admin.ts if it grows. Re-export from cypress/support/e2e.ts.
  • One action per command. cy.login shouldn't also visit the dashboard and assert the welcome banner. A test that runs cy.login then expects a fresh navigation gets confusing fast. Compose narrow commands in the test body.
  • Avoid hard assertions inside commands. A cy.login that asserts the dashboard URL is fine; a cy.login that 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

Custom commands
  • – 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 global and the empty export {}. Runtime works fine, but the editor flags every cy.login red 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.login that 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) with emailInput = 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.

  1. In cypress/support/types.ts, define User, Product, and CartItem interfaces matching whatever target app you're using (Sauce Demo's flow is fine: a User has username and password; a Product has id, name, price).
  2. 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.
  3. Refactor your cypress/e2e/checkout.cy.ts from the chapter 2 practice task to use the new commands. The spec should drop from 30+ lines to under 15.
  4. Force a type error — call cy.login(123, "secret_sauce") (number for username). The compiler should reject it. Fix and confirm.
  5. Add a yielding commandcy.getRandomProduct(): Chainable<Product> that picks a random card from the inventory and yields its name and price. Type it as Chainable<Product>. Use it in a spec to add a random product and assert the cart contains the right item.
  6. Stretch: wire up Cypress.Commands.overwrite("visit", ...) to log every URL the suite visits. Run npm run cy:run and 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.

// tip to track lessons you complete and pick up where you left off across devices.