skip to content

Chain Construction

How a chain is actually built: the spec body only queues steps, each step hands a subject to the next, and none of it is a promise you can race or await.

on this pageshow

explore

questions

15

In Cypress, what does `const rows = cy.get('[data-cy=book-row]')` put into `rows`?

level: juniorimportance: must knowfreq 88%

answer

  1. The line records, it does not run
  2. What type comes back from a command
  3. Cypress commands on it, no jQuery methods
  4. Reading length gives you undefined
  5. Chain an assertion or a then callback

basics

~20 s

A Cypress chainer object: a handle you use to append more commands to that chain. It holds no element and no jQuery data, because at the moment the assignment runs cy.get() has only been queued, not executed.

solid answer

~40 s

`cy.get('[data-cy=book-row]')` hands back a Cypress chainer, and that is what lands in `rows`. Its prototype carries the Cypress commands — `.click()`, `.should()`, `.then()`, `.as()` — and nothing you can read a value from. So `rows.length` is `undefined`, `rows.text()` throws because `text()` is a jQuery method, and `if (rows)` is always true because a plain object is truthy. `rows.click()` does not click at that line either; it appends a click command to the same chain. The reason is timing: the spec body runs to the end before Cypress executes anything, so when the assignment evaluates, the catalogue page may not even have loaded. To reach the twelve real rows you either chain an assertion — `.should('have.length', 12)` — or chain `.then(($rows) => …)` and let Cypress hand the collection in.

code

javascript · 19 lines
javascript
it('lists the borrowable copies', () => {
  cy.visit('/catalogue?q=dune')

  const rows = cy.get('[data-cy=book-row]')

  // None of these read the page:
  //   rows.length  -> undefined
  //   rows.text()  -> TypeError, text() is a jQuery method
  //   if (rows)    -> always true, it is a plain object

  // This works only because it appends another command:
  rows.should('have.length', 12)

  // This is how you actually get the elements:
  cy.get('[data-cy=book-row]').then(($rows) => {
    expect($rows).to.have.length(12)
    expect($rows.eq(0).text()).to.contain('Dune')
  })
})

go deeper

for a junior

Be ready to say in one sentence what a Cypress command returns: a chainer you append more commands to, never the element. Interviewers open with this and listen for whether you have actually run a spec yourself.

for a middle

Explain the mechanics, not just the rule. The chainer is a plain object whose prototype holds the Cypress commands, so jQuery methods and length are simply absent, and the query has not run when the assignment evaluates.

for a senior

Show how you spot this in someone else's spec — a stored command result, a length read, a truthiness guard — and how you steer the fix toward a chained assertion rather than yet another callback.

for a principal

Own the guardrail. Decide whether the suite tolerates stored command results at all, and wire the eslint-plugin-cypress no-assigning-return-values rule into the pipeline so the pattern cannot spread through a large spec base.

Cypress's API looks synchronous and is not, and the first place that bites everybody is a `const`. Understanding exactly what the variable receives explains almost every "why is this `undefined`" question people ask about the tool. ## What `rows` actually contains Every Cypress chain is built out of an internal chainer object. When you call `cy.get('[data-cy=book-row]')`, Cypress creates a chainer, records a `get` command on its queue, and returns the chainer. **That chainer is what lands in `rows`.** Its prototype carries the Cypress commands and Cypress bookkeeping — nothing else. There is no element in it, no jQuery collection, no `length`, no `text()`, and no promise machinery you could `await`. The consequences are easy to reproduce in a spec: - `typeof rows` is `'object'`, so `if (rows)` is **always** true — the guard tells you nothing about the catalogue. - `rows.length` is `undefined` — not `0`, and not `12`. - `rows.text()` throws a `TypeError`, because `text()` is a **jQuery** method and a chainer is not a jQuery object. - `rows.click()` clicks nothing at that line: it appends a `click` command to the same chain and returns the chainer. - Passing `rows` into a helper that expects an element quietly hands that helper the chainer, so the failure surfaces far away from the assignment. ## Why the assignment could not hold an element even in principle The spec body runs top to bottom first, recording commands rather than executing them. At the instant `const rows = …` evaluates, a `cy.visit('/catalogue?q=dune')` written above it has not navigated anywhere. There is no results page, no `<tr>`, and no count to capture. A runner that returned live elements here would be returning them from the wrong moment in time. There is a second reason, and it is the one Cypress's design leans on: **the DOM is mutable**. A catalogue row that exists at assignment time can be thrown away when the search results re-render. A variable is a snapshot; a chain is a description Cypress can re-evaluate as the page settles. ## The mistakes, side by side | What you write | What you expect | What actually happens | |---|---|---| | `const rows = cy.get('[data-cy=book-row]')` | twelve `<tr>` elements | a chainer object for that chain | | `rows.length` | `12` | `undefined` | | `rows.text()` | the row text | `TypeError: rows.text is not a function` | | `if (rows.length > 0)` | a presence check | the comparison is false, so the `else` branch always runs | | `rows.click()` | an immediate click | a click command appended to the chain | ## What the TypeScript signature is telling you In TypeScript, `cy.get()` is declared as returning `Chainable<JQuery<HTMLElement>>`. Read that carefully: the **outer** type is the object you hold, and the **type parameter** describes the subject the chain will eventually yield. `JQuery<HTMLElement>` never reaches your variable; it reaches the parameter of a `.then()` callback or an assertion callback. That is why `rows.text()` fails to compile as well as fail to run — the type system is describing the same split the runtime enforces. ## The two supported ways to reach the real value 1. **Chain an assertion.** `cy.get('[data-cy=book-row]').should('have.length', 12)` deliberately never puts the collection in your hands, which lets Cypress re-run the query until the assertion holds. 2. **Chain `.then()`.** `cy.get('[data-cy=book-row]').then(($rows) => { … })` runs your callback once the query has resolved, with the real jQuery collection passed in as `$rows`. Both keep the value inside the chain, where Cypress controls the moment it is read. ## Catching it before review does The community `eslint-plugin-cypress` package ships a `cypress/no-assigning-return-values` rule that flags assigning a command's return value to `const`, `let` or `var` at lint time, and Cypress's own best-practices guide labels the assignment an anti-pattern outright. Turning the rule on is cheaper than catching it in review, because the symptom — an `undefined`, a branch that always takes the same path, a helper handed the wrong object — never points back at the assignment line that caused it. One narrow exception is worth stating plainly, because candidates over-correct on it: assigning a value you read **inside** a callback is completely normal. `const count = Number($rows.text())` within a `.then()` is a plain JavaScript variable holding a plain JavaScript value, and Cypress has no opinion about it at all. The rule is about a command's return value, not about the `const` keyword.

  • Does `rows.click()` on that stored chainer click anything at the line where you write it?
    No. It appends a `click` command to the same chain and returns the chainer, so the click happens later, when Cypress runs the queue. The line only records intent; nothing touches the catalogue page while the spec body is still executing.
  • Is it ever legitimate to use `const` in a Cypress spec?
    Yes — inside a callback. `cy.get('[data-cy=copies]').then(($el) => { const before = Number($el.text()) })` stores a plain JavaScript number that has already been read from the page. The anti-pattern is assigning the command's return value, not the keyword itself.

The chainer is the order slip, not the meal. Holding the slip tells you nothing about what will arrive, because the kitchen has not started cooking yet.

saying these in an interview costs you the question

  • Says cy.get returns a jQuery object you can read
  • Thinks the variable holds a promise you can await
  • Reads .length off a stored Cypress chainer
  • Assumes an if on a chainer tests element presence
  • Believes assigning cy.get makes the query run early
open as a page

In a Cypress test, when do the cy commands in the test body actually run?

level: juniorimportance: must knowfreq 88%

basics

~20 s

Cypress commands do not run when you call them. Each cy call only appends a step to an internal command queue and returns immediately; Cypress drains that queue, one step at a time, after the test body function has returned.

open as a page

In Cypress, what does cy.wrap() do, and when do you need it?

level: juniorimportance: must knowfreq 70%

basics

~20 s

cy.wrap() starts a Cypress chain whose subject is the value you hand it - a plain object, a number, a jQuery element or a promise - so queries, .should() assertions, .its() and .invoke() can run against it.

open as a page

In Cypress, what happens when you make a spec's `it()` body an `async` function?

level: middleimportance: must knowfreq 74%

basics

~20 s

Cypress warns in the browser console, then hands Mocha your async function's promise instead of its own command-queue promise. Commands still run in written order once the body yields, so await buys nothing and loosens the test's completion signal.

open as a page

In Cypress, what does a .then() callback's return value do to the next command's subject?

level: middleimportance: must knowfreq 60%

basics

~20 s

A returned value becomes the subject of the next Cypress command. Returning nothing leaves the previous subject in place - unless the callback issued Cypress commands, in which case the last of those commands' yield carries on instead.

open as a page

When a Cypress command fails, what happens to the commands still queued behind it?

level: seniorimportance: must knowfreq 56%

basics

~20 s

They never run. A failed Cypress command ends the test — everything still queued behind it is abandoned and the test is reported failed. Cypress offers no catch handler on a command, so a step cannot recover and carry on.

open as a page

Why does a Cypress `.then()` that queues a command and returns a value throw?

level: middleimportance: should knowfreq 46%

basics

~20 s

Because Cypress reads it as mixing async and sync code: the callback queued commands that have not run yet, then handed back a finished value. Cypress throws with the message that cy.then() failed because you are mixing up async and sync code.

open as a page

Why does Cypress drain its command queue serially instead of running commands in parallel?

level: middleimportance: should knowfreq 52%

basics

~20 s

Most Cypress commands mutate browser state — they navigate, set cookies, fire clicks. Running them at once would make a result depend on timing, so Cypress drains its queue one step at a time, each finishing before the next begins.

open as a page

In Cypress, when do you reach for .its() and when for .invoke()?

level: middleimportance: should knowfreq 52%

basics

~20 s

Cypress's .its(path) reads a property off the yielded subject and yields its value; .invoke(name, args) calls a method of that name and yields what it returns. A property holding a function comes back from .its() uncalled.

open as a page

In Cypress, what happens if a .then() callback returns a Cypress.Promise?

level: seniorimportance: should knowfreq 34%

basics

~20 s

Cypress bundles Bluebird and exposes it as Cypress.Promise. When a queued step's callback returns one, Cypress holds the drain and does not start the next queued command until that promise settles, so real async work fits inside the queue's order.

open as a page

Why can a Cypress .invoke() call the method more than once, and how do you stop it?

level: seniorimportance: should knowfreq 38%

basics

~20 s

As of Cypress 16, .invoke() is a query: it re-runs whenever a command chained after it retries, so the method fires again on every attempt. End the chain at .invoke(), or call the method yourself inside a .then() callback.

open as a page

How far should a Cypress suite go to work around not being able to store command results?

level: principalimportance: should knowfreq 38%

basics

~20 s

Only as far as the chain naturally goes. Treat deep callback nesting as a signal that the spec is discovering what it should already know, and move that knowledge into seeded data or a retried assertion.

open as a page

In Cypress 16, what replaces the removed cy.end() command?

level: middleimportance: nice to knowfreq 19%

basics

~20 s

Nothing replaces it. cy.end() ended a chain by yielding null, which was never needed, because a Cypress chain is already terminated as soon as the next cy command starts a new one. In Cypress 16 you simply delete the calls.

open as a page

In Cypress, why does a `while` loop around `cy.get()` hang the spec instead of retrying?

level: seniorimportance: nice to knowfreq 31%

basics

~20 s

Because the loop never gives control back. Cypress only starts running commands once the spec body finishes, so every iteration piles more commands onto the queue, the exit condition can never flip, and the browser eventually gives out.

open as a page

In Cypress, why does a value returned from a .each() callback never reach the next command?

level: seniorimportance: nice to knowfreq 24%

basics

~20 s

Cypress's .each() always yields back the array-like subject it was given, whatever the callback returns, and it deliberately breaks the subject link to any chain the callback started. Only returning exactly false means something: it ends the loop early.

open as a page