lecture 3 of 160 completed

Custom command design patterns

Parent, child and dual commands, overwriting built-ins, and the judgement call about when a plain function is better.

A suite I reviewed had 63 custom commands. Nobody could tell me what most of them did. Several did the same thing under different names. Two overwrote built-in Cypress commands in ways that made ordinary Cypress documentation wrong for that project.

Custom commands are the easiest abstraction to add and the easiest to get wrong, because nothing stops you. Senior work is knowing the three shapes and choosing deliberately.

Parent commands start a chain.

Cypress.Commands.add('login', (email, password) => {
  cy.session([email], () => {
    cy.visit('/login');
    cy.get('[data-testid="email"]').type(email);
    cy.get('[data-testid="password"]').type(password);
    cy.get('[data-testid="submit"]').click();
    cy.get('[data-testid="welcome"]').should('be.visible');
  });
});

Called as cy.login(...). It ignores whatever came before it in the chain. Most commands are this shape.

Child commands continue a chain, receiving the previous subject:

Cypress.Commands.add('shouldHaveRowCount', { prevSubject: 'element' }, (subject, count) => {
  cy.wrap(subject).find('[data-testid="row"]').should('have.length', count);
});

Called as cy.get('[data-testid="table"]').shouldHaveRowCount(5). The prevSubject: 'element' option is what makes it a child, and subject is what the previous command yielded.

Child commands are where custom commands earn their place, because they compose with everything Cypress already gives you.

Dual commands work either way, with prevSubject: 'optional'. Useful, and use them sparingly: a command that behaves differently depending on context is harder to read than two clearly named commands.

Overwriting built-ins is the sharpest tool here:

Cypress.Commands.overwrite('visit', (originalFn, url, options) => {
  return originalFn(url, { ...options, headers: { 'x-test-run': Cypress.env('runId') } });
});

Every cy.visit in the suite now carries a header identifying the test run. That is very useful for tracing test traffic in server logs.

⚠️ Overwriting a built-in changes what the documentation means.

A new engineer reads the Cypress docs for cy.visit, uses it, and gets behaviour the docs do not describe. They have no reason to suspect your project redefined it.

Overwrite only for cross-cutting concerns that must apply everywhere, such as logging or headers. Never to change what a command fundamentally does. And document it somewhere a new joiner will actually look.

Now the judgement, which is the senior part.

A custom command is the right tool when it needs the Cypress chain. If it runs cy.* commands, it belongs as a command, because the queue handles the sequencing.

A plain function is better when it does not. Building a test data object, formatting a string, computing an expected value: those are functions. Making them commands adds queue overhead, makes them harder to test, and hides them from anyone searching for a function.

// a function, correctly
export const uniqueContact = () => ({ name: `Dana ${Date.now()}`, company: 'FoxLabs' });

// a command, correctly: it drives the browser
Cypress.Commands.add('createContact', (contact) => {
  cy.request('POST', '/api/contacts', contact);
});

Every setup command ends with an assertion. You learned this in the beginner track. At scale it matters more: a silent failure inside cy.login makes twenty tests fail with confusing messages about missing rows. The assertion turns that into "welcome message not visible", once, at the source.

Type your commands if the project uses TypeScript. Without declarations, cy.login() is invisible to the editor and every engineer has to grep the support folder to find out what exists. This is the single highest-value thing you can do for discoverability in a large suite.

Remember this: commands for chain work, functions for everything else, and overwrite built-ins only for concerns that truly apply everywhere.

Reference: Custom commands.