skip to content

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

level: seniorimportance: should knowfreq 34%

answer

  1. Looks like a promise, is not one
  2. Bluebird, bundled by Cypress
  3. The step waits for what it returns
  4. Constructed off Cypress, never cy
  5. An unhandled rejection leaves it green

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.

solid answer

~40 s

Cypress's queue is not a promise chain — commands are serial steps in a central queue, and `.then()` only borrows the name. But Cypress is promise-aware **inside** a step: if the callback you pass to `.then()` returns a promise, Cypress pauses the drain until that promise settles before starting the next queued command. `Cypress.Promise` is the Bluebird build Cypress bundles for exactly this; you construct one with `new Cypress.Promise((resolve, reject) => …)`, and it must be called off `Cypress`, never `cy`. A native promise behaves the same way. The important limit is the reverse case: a promise created and rejected **outside** any queued step is invisible to the runner. Cypress does not fail a test for an unhandled rejection in test code, so a catalogue-seeding helper that quietly rejects can leave a spec green.

code

javascript · 15 lines
javascript
// stands in for real async work that is not a Cypress command
const indexCatalogue = () =>
  new Cypress.Promise((resolve) => setTimeout(() => resolve(42), 200))

it('borrows a book once the catalogue index is ready', () => {
  cy.visit('/catalogue')

  cy.wrap(null).then(() => {
    // returning the promise makes Cypress hold the queue until it settles
    return indexCatalogue()
  })

  cy.get('[data-cy="catalogue-search"]').type('dune')
  cy.get('[data-cy="results"] .book-row').first().find('[data-cy="borrow"]').click()
})

go deeper

for a junior

Know that Cypress bundles a promise library and exposes it as Cypress.Promise, constructed off Cypress rather than cy. You are unlikely to need it in everyday tests.

for a middle

Be able to say what makes the queue wait: a promise returned from inside a queued step. A promise merely created in the test body settles on its own and the queue never notices it.

for a senior

Expect the false-green scenario. Explain why an unhandled rejection in spec code leaves a test passing, and name a way to surface it — Bluebird's handler on Cypress.Promise, or the browser's unhandledrejection listener.

for a principal

Be ready to rule on how much raw asynchronous work belongs in specs at all. Every promise the queue does not own is a place where a suite can report success without having done the work.

## The queue is not a promise chain The first thing to be clear about is what Cypress's `.then()` is **not**. Cypress commands and queries look promise-like because of the method name, but they are serial steps passed into a central queue and drained by the runner. The documentation says so explicitly, and adds that commands cannot be awaited. `.then()` is a queued step that happens to take a callback. So the queue's ordering is not produced by promise resolution at all. It is produced by the runner walking its list. Any promise you create in the test body settles on its own schedule, and the queue is not watching it. ## What `Cypress.Promise` actually is Cypress bundles the **Bluebird** promise library and exposes it as `Cypress.Promise`. Two things follow from that: - You construct one with `new Cypress.Promise((resolve, reject) => { ... })`. - It lives on the `Cypress` object, not on `cy`. `new cy.Promise(...)` errors, because `cy` carries the chainable command API while `Cypress` carries the utilities and configuration — `Cypress.Promise`, `Cypress.$`, `Cypress._`, `Cypress.config()` and the rest. Because it is Bluebird, it also carries Bluebird's own API surface beyond the promise standard, which matters for the rejection story below. ## A promise returned from inside a queued step Here is the part that does affect ordering. **Cypress is promise-aware inside a step.** If the callback you pass to a queued command like `.then()` returns a promise, Cypress will not start the next queued command until that promise settles. That gives you one legitimate seam for genuine asynchronous work in a spec: - Work that is not a Cypress command — a call into a library, a browser API, a helper in your own test code — can be wrapped in a promise and returned from a step. - The queue then holds at that step, and the following commands stay in their positions behind it. - A native `Promise` behaves the same way; `Cypress.Promise` is simply the build Cypress ships, so you do not have to add one. The distinction that catches people is **where** the promise is returned from. A promise created at the top of the test body is created during the body phase and settles whenever it likes; nothing in the queue defers to it. The same promise returned from inside a step is something the runner is holding, and the drain waits. | where the promise lives | does the queue wait for it? | |---|---| | created in the test body, not returned anywhere | no — it settles on its own | | returned from a `.then()` callback | yes — the next step waits for it to settle | | created inside a step but not returned | no — the step finishes without it | | rejected anywhere outside a step's return value | no — and the test is not failed either | ## Rejections outside the queue do not fail the test This is the operationally important corner, and it is the one that produces a green suite that proves nothing. **If test code has an unhandled rejected promise, Cypress does not automatically fail the test.** A helper that was supposed to prepare the catalogue can reject, do nothing it claimed to do, and leave the spec passing on assertions that happened to hold anyway. There are two documented ways to surface it, and both are registrations you make once rather than per test: 1. For promises built with `Cypress.Promise`, register Bluebird's handler: `Cypress.Promise.onPossiblyUnhandledRejection((error) => { throw error })`. 2. For native promises, add the browser's own listener on the test window: `window.addEventListener('unhandledrejection', (event) => { throw event.reason })`. The second one comes with a wrinkle worth knowing: because it is registered on the test window, such listeners are **not** reset before every test, so you register it a single time — Mocha's `before` hook in the spec file is the usual place — rather than adding a duplicate for each test. ## When to reach for it, and when not to - **Do** use it to bridge one piece of genuinely asynchronous non-Cypress work into the queue's ordering, returned from a step so the runner owns it. - **Do not** use it to rebuild control flow that the command queue deliberately does not offer. Wrapping a chain in a promise does not give you error recovery, and it does not let you run two commands at once. - **Do not** reach for it where a Cypress command already exists. A step the runner understands is visible, timed and reported; a promise you hand-rolled is none of those. - **Be suspicious of raw promises accumulating in a spec.** Every one that the queue does not own is a place where the suite can report success without having done the work.

  • Why does new cy.Promise(...) fail in a Cypress test?
    Because the bundled Bluebird is exposed on the `Cypress` object, not on `cy`. `cy` carries the chainable command API; `Cypress` carries the utilities and configuration, including `Cypress.Promise`, `Cypress.$` and `Cypress._`. There is no such constructor on `cy`, so the call errors.
  • How do you make an unhandled promise rejection in Cypress test code fail the test?
    Register a handler once per spec. For a promise built with `Cypress.Promise`, use Bluebird's `Cypress.Promise.onPossiblyUnhandledRejection((err) => { throw err })`. For native promises, add the browser's `unhandledrejection` listener on the test window. That listener is not reset between tests, so register it a single time rather than once per test.

saying these in an interview costs you the question

  • Writes new cy.Promise instead of new Cypress.Promise
  • Assumes an unhandled rejection fails the Cypress test
  • Thinks Cypress.Promise is a polyfill for native Promise
  • Expects a promise created in the body to delay the queue