Back to Blog
On this page6 sections

// tutorial

Custom Cypress commands that actually pay off

qa.codesqa.codes · 10 May 2026 · 7 min read
Intermediate
cypresstypescriptpatterns

After three years of writing Cypress custom commands the wrong way, I worked out which four are worth it on every project — and which ones I now refuse to write. Here's the cleanup we did, and why our spec files got shorter.

Why most custom commands are a mistake

The Cypress documentation makes custom commands look easy, which is part of the problem. You add a line to cypress/support/commands.ts, write a function, and suddenly you have cy.doThing(). The power is intoxicating. Within a week many teams have thirty commands — cy.clickButton(), cy.assertPageTitle(), cy.navigateToSettings() — and a support file that nobody can follow.

The issue is that most of these commands don't reduce duplication; they create indirection. When a test fails on cy.clickButton('Submit'), you have to go look up what that does. The test stops being readable in isolation. You've added a layer of abstraction that cost comprehension and bought nothing.

The rule I use: a custom command is worth writing only if it does something Cypress can't express cleanly in three lines or less, or if it owns significant state (session, seed data, network stubs). By that measure, exactly four commands earn their place on every project.

The login command

Every test suite has authentication. Done naively, you log in via the UI on every test — which is slow, brittle, and exactly the kind of thing that makes CI flaky when the login page has a one-second animation.

The right pattern uses cy.session to cache the authenticated state and replay it from disk on subsequent runs:

// cypress/support/commands.ts
declare global {
  namespace Cypress {
    interface Chainable {
      login(email?: string, password?: string): void;
    }
  }
}
 
Cypress.Commands.add(
  'login',
  (
    email = Cypress.env('TEST_USER_EMAIL'),
    password = Cypress.env('TEST_USER_PASSWORD'),
  ) => {
    cy.session(
      [email],
      () => {
        cy.request({
          method: 'POST',
          url: '/api/auth/login',
          body: { email, password },
        }).then(({ body }) => {
          window.localStorage.setItem('token', body.token);
        });
      },
      {
        validate: () => {
          expect(window.localStorage.getItem('token')).to.not.be.null;
        },
        cacheAcrossSpecs: true,
      },
    );
  },
);

Calling cy.login() at the top of a spec now takes milliseconds on the second run. No UI, no network round-trip for the login form, no flake from animation timing.

The seed command

Tests that depend on database state are the hardest to keep stable. The temptation is to share state across tests — one user record everyone reads from — but that makes tests order-dependent and hard to run in isolation.

cy.task lets you run Node.js code server-side from inside a test. Use it to seed a fresh record at the start of each test:

// cypress/support/commands.ts
Cypress.Commands.add('seed', (fixture: string, overrides: Record<string, unknown> = {}) => {
  return cy.task('seed', { fixture, overrides });
});
 
// cypress.config.ts
import { defineConfig } from 'cypress';
import { seedFixture } from './cypress/tasks/seed';
 
export default defineConfig({
  e2e: {
    setupNodeEvents(on) {
      on('task', {
        seed: ({ fixture, overrides }) => seedFixture(fixture, overrides),
      });
    },
  },
});
// cypress/tasks/seed.ts
import { db } from '../../src/lib/db';
 
export async function seedFixture(
  fixture: string,
  overrides: Record<string, unknown>,
) {
  const base = await import(`../fixtures/${fixture}.json`);
  const record = { ...base.default, ...overrides };
  await db.insert(record);
  return record;
}

In a test: cy.seed('user', { role: 'admin' }).as('user'). You get a real record, your test owns it, and teardown is just deleting rows with that ID.

The intercept command

Most apps have a set of API calls that appear in almost every test — the current user endpoint, feature flags, navigation data. Mocking them inline with cy.intercept() on each test is repetitive and inconsistent. A thin wrapper centralises the aliases:

// cypress/support/commands.ts
const STUBS = {
  currentUser: { method: 'GET', url: '/api/me' },
  featureFlags: { method: 'GET', url: '/api/flags' },
  navigation: { method: 'GET', url: '/api/nav' },
} as const;
 
type StubKey = keyof typeof STUBS;
 
Cypress.Commands.add('stubApi', (key: StubKey, response: unknown) => {
  const { method, url } = STUBS[key];
  return cy.intercept(method, url, { body: response }).as(key);
});

Use it as cy.stubApi('currentUser', { id: 1, role: 'admin' }). Tests wait on cy.wait('@currentUser') and you get a consistent alias name everywhere. When /api/me gets renamed to /api/user/me, you fix it in one place.

The visit command

The built-in cy.visit() doesn't know about authentication. If a test hits a protected route before cy.login() has run, it redirects to /login and the whole spec fails confusingly.

An auth-aware wrapper solves this:

// cypress/support/commands.ts
Cypress.Commands.add(
  'visitAuthenticated',
  (url: string, options?: Partial<Cypress.VisitOptions>) => {
    cy.login();
    cy.visit(url, options);
  },
);

For most tests, cy.visitAuthenticated('/dashboard') replaces cy.login(); cy.visit('/dashboard'). The intent is clearer, and you can't accidentally forget the login step. For tests that explicitly test the unauthenticated state, use plain cy.visit() — the wrapper is opt-in, not mandatory.

Commands I no longer write

Four types that seemed useful and weren't:

Assertion wrapperscy.assertText('.title', 'Welcome') instead of cy.get('.title').should('have.text', 'Welcome'). The built-in form is already short; the wrapper hides what's being asserted and breaks autocomplete.

Click wrapperscy.clickSubmit() wrapping cy.get('[data-testid="submit"]').click(). One line with a clear selector is more readable than a method call you have to look up.

Navigation chainscy.goToSettings() that clicks through a multi-step UI path. These break constantly because the navigation changes. A direct cy.visit('/settings') is faster and more stable.

BDD-style stepscy.whenUserClicksSubmit().thenFormShouldShowError(). This style comes from Gherkin and makes sense in a Gherkin world. In plain Cypress it's ceremony that makes diffs harder to read and adds no documentation value that a comment couldn't provide.

The pattern to remember: custom commands own state and side effects. For everything else, trust the built-in Cypress API — it's already expressive enough.


// related

Opinions·24 April 2026 · 6 min read

You probably don't need a Page Object Model

POM was a Selenium-era solution to a Selenium-era problem. In modern Cypress and Playwright, custom commands and locator helpers cover 90% of what POM was supposed to give you.

patternspage-object-modelcypress