skip to content

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

level: middleimportance: must knowfreq 74%

answer

  1. An async function always returns a promise
  2. Which promise does Mocha end up waiting on
  3. Cypress prints something to the browser console
  4. The keyword does not reorder anything
  5. Read values in a callback, not at await

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.

solid answer

~40 s

An `async` body returns a promise. As of Cypress 16, when Cypress sees a test return a promise **and** enqueue cy commands, it warns in the browser console: *"Cypress detected that you returned a promise in a test, but also invoked one or more cy commands inside of that promise."* Behind the warning is a real change. For an ordinary test Cypress returns its own command-queue promise to Mocha, so Mocha waits for the queue to drain. For an `async` body it returns **your** promise instead, so the test's completion is tied to your `await` chain rather than to Cypress's queue. Meanwhile `await` reorders nothing: `cy.get('[data-cy=book-row]')` is a queued instruction, not a native promise, and the commands still run in written order once the body yields.

go deeper

for a junior

Know the headline: Cypress commands are queued instructions, so async and await in a spec do not make them behave like a promise-based API. Recognising the console warning by sight is enough at this level.

for a middle

Explain the swap underneath. An async body returns a promise, so Cypress gives Mocha that promise instead of its own command-queue promise, and the test's completion signal moves with it.

for a senior

Be ready to unpick a suite someone wrote in await style: show which specs still pass by luck, which console warnings you are seeing, and how you convert each await into a chained callback or assertion.

for a principal

Set the expectation for engineers arriving from promise-based runners. Decide whether the mixing warning is treated as a failure in review, and what onboarding material stops the pattern being reinvented every quarter.

"Why can't I just use `async/await`?" is the question the Cypress docs answer with a whole section, because every engineer arriving from a promise-based runner tries it in the first hour. The honest answer has three parts: what an `async` body changes, what it does not change, and what people were actually reaching for. ## What an `async` body changes Cypress wraps every Mocha test function so it can decide what to give back to Mocha. The decision looks roughly like this: 1. Run the test body and capture its return value. 2. If the body returned a **promise-like** value, mark the test as having returned a custom promise, warn if commands were also enqueued, and return **that** promise to Mocha. 3. Otherwise, if commands were enqueued, return **Cypress's own command-queue promise** so Mocha waits for the queue to drain. An `async` function always returns a promise, so step 2 always wins. The consequence is the one that matters: **Mocha is now waiting on your `await` chain, not on Cypress's queue.** Cypress has given up its own end-of-test signal, which is exactly why the runner calls the pattern an anti-pattern rather than a style choice. The warning text is fixed and worth recognising in a console log: > Cypress detected that you returned a promise in a test, but also invoked one or more cy commands inside of that promise. It fires whether the commands were enqueued before the first `await` or after it resumes — as soon as the two are mixed, the warning appears. ## What an `async` body does not change - **Enqueue order is untouched.** `await cy.visit('/catalogue')` followed by `cy.get('[data-cy=book-row]')` runs in exactly the same order as the same two lines without `await`. - **Nothing becomes a real promise.** A Cypress command returns a chainer whose `.then` is itself a queued command, not a native promise reaction. There is no promise identity for `Promise.all` or `Promise.race` to work with. - **Retryability is unaffected by the keyword.** The retrying is done by queries and assertions inside the chain, and `await` neither adds nor removes it. - **The failure surface stays the same.** A failing command still fails the test through Cypress's own error path, not through a rejected promise you can inspect at the `await`. ## Cypress's chain versus a promise-based API | Concern | Cypress chain | What people expect from `await` | |---|---|---| | What a call returns | a chainer, queued for later | a promise settling with a value | | When the work happens | after the spec body yields | at the `await`, in written order | | How you read a value | inside `.then()` or an assertion | assigned straight to a variable | | What Mocha waits on | Cypress's command-queue promise | the test function's own promise | | Composition | more commands appended to the chain | `Promise.all`, `race`, `catch` | The right-hand column is not a missing feature; it is a different design. Cypress runs in the browser alongside the application and re-evaluates queries as the page changes, and a value pulled out into a variable at an `await` boundary is frozen at a single instant. ## Writing what you meant, in the chain Almost every `await` a newcomer writes is one of three intentions, and each has a chain form: - *"Wait for this before continuing"* — you already have it. Commands are serial by construction; delete the `await`. - *"Read this value"* — chain `.then(($el) => { … })` and use the value inside the callback. - *"Assert on this value"* — chain `.should('have.text', '3 copies available')` and let Cypress re-run the query until it holds. ## One place `await` is genuinely fine If a spec's `before` or `beforeEach` calls a plain asynchronous Node-free helper that touches **no** cy commands at all — say an imported function that builds a fixture object in memory — awaiting it is harmless, because no commands are enqueued and the mixing condition never arises. The moment a single `cy.` call joins that function, the warning is back and the queue-versus-promise split is back with it. Keeping those two worlds apart, rather than interleaving them, is the practical rule.

  • Does the warning mean the test is broken, or is it only cosmetic?
    It is a warning, not a failure, and such specs often pass. But Cypress has handed Mocha your promise instead of its queue promise, so the test's completion is no longer tied to the queue draining. Treat it as a design smell to remove rather than noise to filter out.
  • Why can't `Promise.all` run two Cypress chains in parallel?
    A command returns a chainer, not a promise with a settled value, so `Promise.all` has nothing meaningful to combine. Cypress also drains one queue serially by design, so even if the wrapper resolved, the two chains would still run one after the other.

saying these in an interview costs you the question

  • Claims await makes Cypress commands run in order
  • Says cy.get returns an awaitable native promise
  • Treats the console warning as safe to ignore
  • Thinks async in the test body enables Promise.all
  • Believes await adds retrying to a query