ACT 1: EXPOSITION
A lot has been said about the asynchronous nature of the Cypress test framework. This asynchrony is often blamed as the primary cause of flakiness in Cypress tests. And, I would say… yeah, I agree. Asynchrony is the main reason of flakiness, but that’s because it’s not used correctly, which stems from it not being properly understood.
Why is that? I can’t pinpoint the exact reason, but this is my suspicious, and I’m really not trying to be judgmental:
The proper documentation on Cypress’s async behavior is often skipped or not fully read before people jump into creating more complex tests.
It’s very common for people to mix synchronous JavaScript code with asynchronous Cypress commands and queries in their tests, and treat them the same way.
And, well… asynchronous behavior doesn’t come naturally to our brains. We think synchronously by default, and grasping asynchronous concepts requires extensive hands-on usage, often coupled with making a lot (and I mean a lot) of mistakes.
The goal of this blog is not to lecture you on general theories of asynchrony and race conditions, but to offer a clearer yet detailed explanation of how asynchrony works specifically in Cypress when running tests in a browser.
I decided to tackle the subject with a different approach than how it is usually discussed and explained. This approach will be more visually oriented, as the human brain processes images and diagrams more effectively than large amounts of text, especially when dealing with complex concepts.
To lay it all out, I’ve coined a term: “The Two Timelines in Your Cypress Tests” or, to add a little fun, “The Cypress Dual-Verse”! 😄
ACT 2: CONFRONTATION
The Asynchronous Nature of Cypress
But before we explore the details (and the fun graphical part) of how the two timelines behave and coexist when running your Cypress tests, I want to take a moment to touch on and discuss some core aspects of how Cypress works in this context. If you’re looking for more specifics, I highly recommend reading the official Cypress.io documentation on Understanding the Asynchronous nature of Cypress.
Here are a few essential points to note:
Cypress commands operate asynchronously and are executed later in a queued sequence (grasping this is essential!).
Cypress commands do not return their subjects directly; instead, they yield their subjects. As a result, you cannot assign their output to a variable.
Cypress commands do not return JavaScript Promises; rather, they return what are commonly referred to as Chainables. Although the behavior of Chainables is somewhat similar to Promises, you cannot use
async/await
in your Cypress tests.
-
Cypress commands are inherently asynchronous and yield chainable objects, which enables the next command in the chain to execute after the previous one completes.
Example of chained commands in Cypress:
// Visit the '/home' web page cy.visit('/home'); // Wait until the // verify it contains the text "Click me!", and then click it cy.get('button') .should('contain', 'Click me!') .click();
In this example, the
element retrieved by the
.get()
command is yielded to the.should()
command, which makes an assertion over it, and in turn is yielded to the.click()
command.
There are two Cypress commands that help streamline the handling of asynchronous behavior:
-
wrap()
: Used to wrap a JavaScript object or value so that it can be used within Cypress's chainable command structure. This is particularly useful when you want to work with non-Cypress objects, such as data variables or elements returned by a JavaScript function, within a Cypress test.
const myObject = { name: 'Test Object' }; // Wrap the object to use it in Cypress's command chain cy.wrap(myObject) .its('name') .should('equal', 'Test Object');
-
then()
: Used to execute a callback function once the previous Cypress command is resolved. It allows you to work directly with the resolved value of an asynchronous Cypress chainable command. This is especially useful for performing custom actions or additional assertions that cannot be directly achieved through Cypress commands.
cy.get('button').then(($button) => { // $button is the resolved jQuery element from cy.get('button') console.log($button.text()); // Log the button's text $button.click(); // Perform a click directly with raw jQuery commands });
If you're accustomed to working with native Promises, the Cypress .then() behaves in a similar way. However, they are not promises; instead, they are sequential commands added to a central Cypress queue, where they are executed asynchronously at a later time.
You can nest additional Cypress commands within the
.then()
block. Each nested command can utilize the results or actions performed by the preceding commands. Commands outside of the.then()
block will not execute until all the nested commands inside have completed. This is crucial for gaining a proper understanding of how the Cypress queue works.
📑 Note: In Cypress, you cannot implement a
catch(error)
handler for a failed command since Cypress does not provide built-in error recovery for failed commands. If a command fails, it halts the execution of all subsequent commands, leading to the failure of the entire test.
The Two Timelines in Cypress Tests (AKA 'Dual-Verse')
We have finally reached the fun part! 😄
What do I mean by "The Two Timelines"?
Here’s the answer:
The first is the JavaScript timeline, executed by the browser, which runs the JavaScript code synchronously, one statement after another.
The second time line is the Cypress queue, which follows its own pace, operating "in parallel" with the JavaScript timeline.
🚨🚨 Spoiler alert: the JavaScript timeline does not wait for the Cypress queue until both timelines reach the end of the block in which they are defined (this could be an it()
test, a .then()
block, a before()
/beforeEach()
/after()
/afterEach()
hook, or any other block definition supported in Cypress).
The official recommendation from Cypress is not to mix async code with sync code (see here). However, in reality, there will be cases and situations where you encounter this practice—whether you’re debugging flaky tests implemented by someone else or addressing logical problems that cannot be resolved with only Cypress commands.
This is why it is crucial for you to understand how Cypress's async behavior works and how these two timelines operate and interact with each other.
And we are going to learn this through a series of six examples and some "pretty" timeline graphs.
⚠️ DISCLAIMER: The examples we will explore are not meant to showcase the recommended best practices for these cases but are instead tailored to help me clearly illustrate how these timelines work.
Example Test 1
Here is the test:
// Cypress Commands and JS Sync Code
it('Test 1', () => {
cy.request('https://jsonplaceholder.typicode.com/posts/1')
.then((response) => {
let id = response.body.userId // userId value is 1
cy.wrap(id).should('equal', 1) // Pass! (because id == 1)
id = id + 1 // id set to 2
expect(id).to.equal(2) // Pass! (because id == 2)
})
});
In the first example, we will perform a network request and then process the response body by extracting the userId
field (which contains the value 1
). Next, we will assert the obtained value, modify it, and then assert again.
However, note that we have mixed Cypress should
assertions with Chai expect
assertions while also storing the extracted values in a local variable within the scope of the block.
But in what order do the browser execute these JavaScript statements (which are sync) along with the Cypress commands (that are async)?
When running a test, Cypress adds all the commands it finds to its queue in the same order they are encountered. However, the QA Engineer does not truly know or cannot assume when a command will be taken by the Cypress runner from the queue and executed.
And that is because JavaScript statements are executed lightning-fast by the browser and don’t wait around for the Cypress commands. It is like they are each living in their own parallel universe with separate timelines. 😄
The only certainty is that nested JavaScript statements and Cypress commands within another Cypress command block, are executed before any remaining Cypress command outside that block.
The two timelines come together either at the conclusion of a test or when a Cypress block finishes (like a .then()
block).
If we carefully analyze the order in which each statement and command is executed for the Test 1, it will look like a temporal flow diagram like this:
Interesting right? 🤔
Notice in the temporal diagram, that the JavaScript statements are executed as soon as the .then()
block begins and do not wait for the Cypress commands, even if those commands are defined earlier in the test code. The time??
(time with question marks )in the graph represents the uncertainty around how much time will elapse between when a command is queued by Cypress and when it is actually executed. However, it is almost guaranteed that the JavaScript statements will run before the Cypress commands are executed within the block.
Also, keep in mind that the value of the JavaScript variable id
, which is used in .wrap()
, is taken from its actual value at the moment .wrap()
is encountered during the test execution. The wrap command is then queued with that specific value (in this case, 1
).
And what happens in the Cypress Log in the test runner when we execute this test?
The Cypress Log clearly shows that the expect()
Chai assertion (which is JavaScript code) runs before the .wrap()
and .should()
Cypress commands. And the test pass!
Yeah, I wasn’t lying before—pure JavaScript code runs much faster than Cypress commands. It’s like they exist in a Dual-Verse, with time running much faster in one than the other, and they only converge in the future when the Cypress test or block ends!
Example Test 2
What if we create a second test similar to the first one, performing the same assertions, but using only Cypress commands?
// Only Cypress Commands
it('Test 2', () => {
cy.request('https://jsonplaceholder.typicode.com/posts/1')
.its('body.userId')
.should('eq', 1) // Pass! (because userId == 1)
.then((id) => id + 1) // Increment id and yield value to next command
.should('eq', 2); // Pass! (because id == 2)
});
And this is what the temporal execution diagram will look like in this case:
Hmmm, it's quite different from Test 1, even though it performs the same type of assertions. However, this time, it only uses Cypress commands instead of mixing with Chai assertions.
This difference is also noticeable when we look at the test results in the Cypress Log:
This time, the assertions were executed in order (first asserting 1
, and then asserting 2
), because Cypress executes commands in the same order they are added to the command queue. And the test also pass!
Example Test 3
Is the third test... a charm?
// Cypress Commands (with 2 .then()) and JS Sync Code
it('Test 3 ', () => {
let id = null; // id set to null
cy.request('https://jsonplaceholder.typicode.com/posts/1')
.then((response) => {
id = response.body.userId // userId value is 1
}).then(() => {
cy.wrap(id).should('equal', 1) // Pass! (because id == 1)
id = id + 1 // id set to 2
expect(id).to.equal(2) // Pass! (because id == 2)
})
});
This third test is a bit more complex. We are defining a local variable id
at the beginning of the test, which is accessible anywhere within the test, and also mixing JavaScript statements with Cypress commands.
The temporal diagram will look like:
Notice that the variable id
is initialized at the very beginning of the test to null
and then modified twice within both .then()
blocks. Similar to Test 1, the expect
Chai assertion (that is Javascript sync code) is executed before the .should()
Cypress assertion (that is async). However, the test still pass.
This is confirmed by the Cypress log when we run it in the Cypress runner:
Example Test 4
Let’s spice things up a bit! What would happen if we threw an expect
Chai assertion at the very end of the test, outside both .then()
blocks?
// Cypress Commands (with 2 .then()), JS Sync Code and expect() outside the last .then()
it('Test 4', () => {
let id = null; // id set to null
cy.request('https://jsonplaceholder.typicode.com/posts/1')
.then((response) => {
id = response.body.userId // userId value is 1
}).then(() => {
cy.wrap(id).should('equal', 1) // Pass! (because id == 1)
id = id + 1 // id set to 2
expect(id).to.equal(2) // Pass! (because id == 2)
})
expect(id).to.equal(2) // Fail!!! (because id == null)
});
And who wins the Amazing Race?
The test fail!!!
Indeed, even though the expect
is placed at the end of the code, it is executed immediately after we initialize the variable id
to null
, long before it is updated within the Cypress commands, so the Cypress commands are not even executed at all!
This is confirmed by the Cypress log when the test is executed:
Example Test 5
Let me think...
What if, instead of putting an expect
at the end of the test, I use Cypress commands to perform the assertion (using .wrap()
and .should()
)?
// Cypress Commands (with 2 .then()), JS Sync Code and should() outside the last .then()
it('Test 5', () => {
let id = null; // id set to null
cy.request('https://jsonplaceholder.typicode.com/posts/1')
.then((response) => {
id = response.body.userId // userId value is 1
}).then(() => {
cy.wrap(id).should('equal', 1) // Pass! (because id == 1)
id = id + 1 // id set to 2
expect(id).to.equal(2) // Pass! (because id == 2)
})
cy.wrap(id).should('equal', 2) // Will Fail!!! (because id == null)
});
Well... since Cypress commands are added to the queue in the order they are defined in the test, that final assertion should be executed at the end of the test, right?
Let's see the diagram:
And that is exactly what happens! The Cypress assertion at the very end of the test is executed last, BUT when the command .wrap()
is added to the Cypress queue, the variable id
still holds the value null
.
Remember? JavaScript statements are executed before Cypress commands, so the JavaScript timeline reaches the end of the test (with the id
variable still set to null
) before any of the earlier Cypress commands are executed. So the test fails!
And here’s the proof (the Cypress Log never lies!):"
Pesky variable! 😜
Example Test 6
And finally, the last test example.
Why don’t we enclose the final Cypress assertion within a cy.then()
command?
In this case, the .then()
command is not chained to the yielded value of another Cypress command, but is applied directly to the global object cy
(from the Cypress test framework).
it('Test 6', () => {
let id = null; // id set to null
cy.request('https://jsonplaceholder.typicode.com/posts/1')
.then((response) => {
id = response.body.userId // userId value is 1
}).then(() => {
cy.wrap(id).should('equal', 1) // Pass! (because id == 1)
id = id + 1 // id set to 2
expect(id).to.equal(2) // Pass! (because id == 2)
})
cy.then(() => {
expect(id).to.equal(2) // Pass! (because id == 2)
cy.wrap(id).should('equal', 2) //Pass! (because id == 2)
})
});
This approach will encapsulate those assertions within a Cypress block, allowing time for the id
variable to be set by the previous commands. Such a technique is a common and recommended practice in Cypress when you want to 'sync' commands with one another.
We also added, as a bonus, an expect in that final cy.then()
block, and as expected, it is executed before the final Cypress assertion.
Again Javascript synchronous timeline is faster!
And What About Cypress Hooks?
Actually, Cypress hooks are also blocks that wrap JavaScript and Cypress commands, so the same rules apply to them.
ACT3: RESOLUTION
So we’ve reached the end of the exercise!
Now you understand how Cypress's asynchronous nature really works—don’t be afraid of it. When used wisely, it can be a truly powerful tool. And also, the chained commands are quite elegant.
Once again, the official Cypress recommendation is to avoid mixing JavaScript synchronous code with Cypress asynchronous commands. However, you now have the knowledge and tools to handle the The Two Timelines in your Cypress tests—for instance, when debugging a faulty or flaky test written by someone you’ve never met who knows how long ago! 😄
One tool that might help you debug flaky tests caused by incorrect usage of Cypress's asynchronous nature is the cypress-command-chain plugin, developed by Gleb Bahmutov.
It is very easy to install and use. The plugin displays the Cypress queued commands in the Cypress log in the exact order they are inserted, along with the values of the arguments at that moment.
If we run Example Test 5 (from our earlier discussion) with the cypress-command-chain
plugin enabled, this is what you would see in your Cypress log:
At the top of the log, the Cypress Command Queue is displayed, showing each command inserted in order along with its arguments. Below that is the regular Cypress log, which includes the results of the assertions as they are executed.
You can find all the examples from this article in the GitHub repo: sclavijosuero/cypress-async-behavior-examples.
I'd love to hear from you! Please don't forget to follow me, leave a comment, or a reaction if you found this article useful or insightful. ❤️ 🦄 🤯 🙌 🔥
You can also connect with me on my new YouTube channel: https://www.youtube.com/@SebastianClavijoSuero
If you'd like to support my work, consider buying me a coffee or contributing to a training session, so I can keep learning and sharing cool stuff with all of you.
Thank you for your support!