skip to content

In Cypress, when should a repeated step be a plain function instead of a custom command?

level: juniorimportance: must knowfreq 72%

answer

  1. Not everything needs to be global
  2. Ask whether a function would do
  3. One spec file, one plain helper
  4. Wrapping two commands buys nothing
  5. Zero assertions inside the command

basics

~20 s

Make it a custom command only when the behaviour is wanted across all your specs, such as signing in or seeding an expense report. A step that one spec file repeats should stay a plain JavaScript function in that file.

solid answer

~40 s

`Cypress.Commands.add()` installs a name on the global `cy` chain for every spec in the run, so reserve it for behaviour that is genuinely universal — `cy.signInAs()`, `cy.seedReport()`. Anything only `receipts.cy.js` needs is better as an exported JavaScript function: it is imported where it is used, editors can jump to it, and it adds no name every other author has to know about. Cypress's own guidance is blunt about the two failure modes: don't wrap one or two commands (`cy.clickApproveButton()` buys nothing over `cy.get(sel).click()`, and `.shouldBeVisible()` is longer than `.should('be.visible')`), and don't bury assertions in a command, because that makes it rigid and forces callers to pass options to turn behaviour off. A function that returns a `cy` chain is chainable too, so you give up almost nothing.

go deeper

for a junior

Be ready to name the dividing line out loud: used by every spec means a custom command, used by one spec means a function in that spec. Interviewers ask this to see whether you have organised a suite yourself.

for a middle

Explain the mechanics behind the rule: the support file is evaluated before every spec, so a registration is global, needs a hand-written TypeScript entry, and has no import for a reader to follow.

for a senior

Show the maintenance angle. Talk about a suite where the command list outgrew what anyone remembers, and how you decided which commands to keep, which to demote to functions, and which to delete.

for a principal

Own the standard. Say what your team's bar is for adding a name to the global chain, who reviews it, and how you keep a shared command package from becoming an unversioned dependency for every spec.

## The rule Cypress states about its own API Cypress's guidance on custom commands opens with **"Don't make everything a custom command."** `Cypress.Commands.add()` is meant for behaviour that is desirable across **all of your tests** — signing a reviewer in, seeding an expense report through the API, putting the app into an "awaiting approval" state before a spec starts. Everything else is ordinary JavaScript, and a Cypress spec is allowed to contain ordinary JavaScript. The question to ask before every registration is the one the docs put in italics: *can this be written as a function?* The answer is usually yes. ## Why the global registration is the real cost A custom command is not a local convenience. `Cypress.Commands.add()` installs a name on the `cy` chain, and because the support file (`cypress/support/e2e.js` by default) is loaded before **every spec in the run**, that name exists in every spec whether the spec wants it or not. Three things follow: - **Discovery gets worse, not better.** `cy.approveTopReceipt()` has no import to follow. A reader who has never opened the support file has to go hunting for where it came from. - **The namespace is shared and unversioned.** Every command you add is a name nobody else can use for anything different, and nothing warns you when two people pick the same one. - **The type surface grows.** In TypeScript each command needs a hand-written entry in the `Chainable` interface inside the `Cypress` namespace — real upkeep for a helper one spec calls twice. A plain function has none of this. `import { seedReport } from './reports'` is followed with one keystroke in any editor, it is scoped to the files that import it, and its types are inferred. ## Three signals you have gone too far 1. **The command wraps one or two commands.** `cy.clickApproveButton(sel)` is `cy.get(sel).click()` with extra steps. Follow that road and you end up writing dozens of commands to cover every element-and-action pair. `.shouldBeVisible()` is worse — it is *longer* than the `.should('be.visible')` it replaces, and it hides which Cypress assertion actually ran. 2. **Only one spec calls it.** A helper used exclusively by `reimbursement.cy.js` has no business being visible to `receipts.cy.js`. 3. **It carries its own assertions.** Assertions inside a command make it rigid. The next caller who wants the same setup *without* the assertion has to add an option to switch it off, and the option list grows from there. Cypress's advice is zero assertions, or as few as possible, and to let the calling test decide what to assert. ## Custom command or plain function | | `Cypress.Commands.add()` | exported function | |---|---|---| | Scope | every spec in the run | the files that import it | | Discovery | no import; grep the support file | go-to-definition | | TypeScript | hand-written `Chainable` entry | inferred | | Chainable | yes | yes, if it returns the `cy` chain | | Command Log | inner commands only, unlabelled | inner commands only, unlabelled | | Right for | sign-in, seeding, app-wide state | steps one spec repeats | Two rows carry most of the argument: - **Chainable** is the one people get wrong. A function that returns a `cy` chain chains exactly like a command, so "I need to chain off it" is almost never a reason to register one. - **Command Log** is identical either way, because Cypress logs the commands a helper *runs*, never the helper itself. A custom command does not buy you a named step in the log. Which leaves scope and discovery as the only real differences — and both of them argue *against* registering a helper that one spec uses. ```js const openReport = (id) => cy.visit(`/reports/${id}`).get('[data-cy=report-total]') openReport('exp-4417').should('contain', '$412.90') ``` ## What a healthy expense-report suite looks like Keep a small set of commands that describe **application-level** behaviour every spec plausibly needs — `cy.signInAs('approver')`, `cy.seedReport({ status: 'draft', total: 412.9 })`, `cy.setPolicyLimit(250)`. Those earn a global name because they are the vocabulary of the product, and because skipping the UI for setup is exactly what a command is good at: they replace a slow click-through with a `cy.request()` and they are used by nearly every spec. Then, inside `receipts.cy.js`, write functions for what only `receipts.cy.js` does: ```js const attachReceipt = (file) => cy.get('[data-cy=receipt-drop]').selectFile(`cypress/fixtures/${file}`) ``` ## The property you are actually optimising for Test code serves a different purpose from application code, and it does not have to be as dry as application code. The property worth protecting is that a stranger can read one spec top to bottom and know what it did, and that a failure points at a line they can find. Every layer of indirection between the test and `cy.get()` trades a little of that away. Sometimes the trade is clearly worth it — a sign-in that would otherwise be forty lines in every spec. Often it is not, and the suite ends up with a hundred commands, no two of which anybody remembers. A useful check before you promote a helper: if you deleted the command and inlined it into the two specs that use it, would either spec get *harder* to read? If not, it was never a command.

  • If a helper is a plain function, can a Cypress test still chain off it?
    Yes. Return the `cy` chain from the function and the caller can chain onto it: `openReport('exp-4417').should('contain', '$412.90')`. Chaining is a property of the returned chain, not of how the helper was registered, which removes the most common argument for reaching for `Cypress.Commands.add()`.
  • Where should a Cypress custom command live so every spec can call it?
    In the support file for that testing type, or a file it imports — `cypress/support/e2e.js` for end-to-end and `cypress/support/component.js` for component tests, overridable with the `supportFile` option. Cypress evaluates the support file before each spec, which is exactly why a command registered there is global.

A custom command is a word you add to the team's dictionary; a helper function is a word you use in one paragraph. Dictionaries should be small enough that people can still read them.

saying these in an interview costs you the question

  • Says every repeated step should become a custom command
  • Writes cy.clickApproveButton() instead of cy.get(sel).click()
  • Claims a plain function cannot be chained off a cy chain
  • Buries assertions in a command so callers cannot choose
  • Applies application-code DRY rules to test code without thinking