skip to content

In Cypress, why must a `Cypress.Commands.addQuery()` callback return another function?

level: seniorimportance: nice to knowfreq 18%

answer

  1. One callback, but two lifetimes inside it
  2. Setup happens once, querying happens often
  3. Cypress decides how often the second runs
  4. Synchronous, retriable, idempotent
  5. The outer one needs its own this

basics

~20 s

Because the two halves run at different rates. The outer function runs once, for setup; the inner one takes the previous subject and returns the new one, and Cypress invokes it repeatedly, so it must be synchronous and side-effect free.

solid answer

~50 s

`Cypress.Commands.addQuery(name, callbackFn)` splits a query into two layers. The outer `callbackFn` runs once per use: it validates the arguments the test passed, creates a `Cypress.log()` entry, and sets the timeout with `this.set('timeout', options.timeout)`. It then returns the inner function, which takes the previous subject and returns the new subject. Cypress invokes that inner function again and again — first until it and any chained assertions succeed or the timeout expires, and later whenever a downstream command needs the subject resolved again. That split is why the documented rules for queries are **synchronous, retriable and idempotent**: never return or await a promise, throw to signal failure and let Cypress try again, and never change the application from inside the inner function. The outer callback must be a `function`, not an arrow, because the API reaches for `this`.

code

javascript · 23 lines
javascript
// cypress/support/commands.js
Cypress.Commands.addQuery('expenseRow', function expenseRow(reportId, options = {}) {
  const log = options.log !== false && Cypress.log({ timeout: options.timeout })

  // outer half: setup, runs once
  this.set('timeout', options.timeout)

  return (subject) => {
    // inner half: runs on every retry, must stay side-effect free
    Cypress.ensure.isElement(subject, 'expenseRow', cy)

    const $row = subject.find(`[data-report-id="${reportId}"]`) // jQuery's .find()

    log && log.set({ $el: $row, consoleProps: () => ({ Yielded: $row[0] }) })

    return $row
  }
})

// cypress/e2e/reimbursement.cy.js
cy.get('[data-cy=expense-table]')
  .expenseRow('R-91')
  .should('contain', 'Reimbursed')

go deeper

for a junior

Know that Cypress has a second registration API for steps that need to keep re-checking the page, and that its callback is written differently from a normal custom command's.

for a middle

Explain which work belongs in the outer half and which in the inner, and why the inner one has to be synchronous rather than returning a promise.

for a senior

Show that you would catch a non-idempotent query in review, and describe how such a bug presents in practice: green locally, intermittently wrong under load in CI.

for a principal

An interviewer at this level expects a view on when a suite should own query-level extensions at all, given each one couples the team's helpers to a runner internal.

## Two functions, two lifetimes `Cypress.Commands.addQuery()` looks odd until you see what the shape buys. You pass one callback; Cypress calls it once and keeps what it returns: ```javascript Cypress.Commands.addQuery('expenseRow', function expenseRow(reportId, options = {}) { // outer: runs once, when the test reaches this step this.set('timeout', options.timeout) return (subject) => { // inner: runs on every retry, and again later if the subject is needed return subject.find(`[data-report-id="${reportId}"]`) // jQuery's .find() } }) ``` The outer half is **setup**. It sees the arguments the test wrote, so it is the place to validate them, to create the `Cypress.log()` entry the Command Log will show, and to set the query's timeout. Doing any of that inside the inner half would repeat it on every retry and flood the log. The inner half is **the query itself**: previous subject in, new subject out. Cypress owns its schedule. It calls it until the inner function stops throwing and any chained assertions pass, or until the timeout runs out — and it can call it again afterwards when a later command needs the subject resolved once more. ## The three rules that follow from the shape 1. **Synchronous.** The inner function must return a subject, never a promise, and must not await anything. Cypress has no way to retry something it must wait on. 2. **Retriable.** Failure is signalled by throwing. You do not catch, wait and try again yourself; you throw, and Cypress schedules the next attempt. Any error type works — Cypress catches it and renders it in the Command Log. 3. **Idempotent.** Calling the inner function twenty times must leave the application exactly as one call would. Anything that mutates state — clicking, seeding a report over the network, writing to storage — belongs in a command created with `Cypress.Commands.add()`, not in a query. The third rule is the one that bites in production. A query that quietly performs a side effect works on the happy path, where the first attempt succeeds, and misbehaves only when something is slow enough to trigger retries — which is to say, only in CI, only sometimes, and never on the machine of the person trying to reproduce it. ## What belongs in which half | Work | Outer half (once) | Inner half (every retry) | |---|---|---| | Validating the test's arguments | yes | no | | Creating the `Cypress.log()` entry | yes | no | | `this.set('timeout', options.timeout)` | yes | no | | Reading the DOM or app state | no | yes | | Validating the previous subject | no | yes | | Updating the log with the yielded value | no | yes | ## Validating the subject Cypress performs no validation on what it passes the inner function — the previous subject can be any value at all, including `undefined`. There is no `prevSubject` option on `addQuery()`, so the check is yours to make. `Cypress.ensure` exposes the same helpers the built-ins use, including `Cypress.ensure.isType`, `Cypress.ensure.isElement`, `Cypress.ensure.isWindow`, `Cypress.ensure.isDocument`, `Cypress.ensure.isAttached`, `Cypress.ensure.isVisible` and `Cypress.ensure.isNotDisabled`. Each simply throws when the check fails, which is exactly the retry contract, so calling one at the top of the inner function costs nothing and produces the same error text a built-in would. ## Why an arrow function breaks it The outer callback must be written as `function () {}`. The query API sets a query's timeout through `this`, as in `this.set('timeout', options.timeout)`, and an arrow function has no `this` of its own to bind. Omit the call, or pass `null` or `undefined` to it, and the query falls back to the default timeout — which is why a query that seems to ignore the `timeout` option it was handed is almost always an arrow function in disguise. The same rule explains the calling convention when you extend an existing query with `Cypress.Commands.overwriteQuery()`: reach for `originalFn.call(this, ...)` or `originalFn.apply(this, args)` rather than invoking it bare. ## When a query is the wrong choice The two registration APIs answer different questions, so the choice is usually quick: - **Use `Cypress.Commands.add()`** when the step must be asynchronous, must run exactly once, or changes the application — approving a report, uploading a receipt, seeding a month of expenses over the network. - **Use `Cypress.Commands.addQuery()`** when the step is a way of *locating* something on an expense-report page that the rest of the chain then acts on, and re-locating it later is harmless. - **Use neither** when the logic touches no Cypress state at all: an ordinary JavaScript function imported by the spec needs no registration and no type declaration.

  • What does `this.set('timeout', options.timeout)` do inside a Cypress custom query?
    It tells Cypress how long to keep re-invoking the inner function before giving up, so the query honours a `timeout` option the caller passed. Skip the call, or pass `null` or `undefined`, and the query falls back to the default command timeout. It lives in the outer half because the timeout is decided once, not renegotiated on each retry.
  • How does a Cypress custom query validate the previous subject, given `addQuery()` has no `prevSubject` option?
    It validates by hand at the top of the inner function, usually with a `Cypress.ensure` helper such as `Cypress.ensure.isElement(subject, 'expenseRow', cy)`. Those helpers throw on a mismatch and produce the same wording built-in commands use. Throwing is the correct signal, because Cypress treats a thrown error inside a query as a failed attempt and simply tries again until the timeout expires.

The outer function is setting up the camera and the inner function is pressing the shutter: you frame the shot once, then fire as many times as it takes to get a clear picture.

saying these in an interview costs you the question

  • Returns a promise from the inner query function
  • Creates the Cypress.log entry inside the inner function
  • Writes the outer callback as an arrow function
  • Performs a click or a seed inside a query