Custom Cypress commands that actually pay off
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 wrappers — cy.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 wrappers — cy.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 chains — cy.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 steps — cy.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
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.
cy.intercept the right way: aliases, stubs, and the bug it usually catches
cy.intercept is the most powerful command in Cypress and the one teams most often misuse. Here's the playbook: when to alias, when to stub, when to spy, and the race-condition-shaped bug that intercepts usually catch.