Cleaner async patterns for sharper Cypress tests!
ACT 1: EXPOSITION
Most of us (if not all) are familiar with the JavaScript Array map() method. There is hardly anything better when you want to process or transform every element of an array into a new one based on a transformation function.
const array = [1, 3, 5, 7];
const newArray = array.map(x => x * x + 2)
// Expected output: Array [3, 11, 27, 51]
This example is trivial, of course, but that transformation function can be anything, from a simple calculation to something like a 90° clockwise matrix rotation. You name it!
I am sure many of us are also familiar with a very similar concept, but this time in the Cypress world. I am talking about the fantastic cy.map() command from the multifaceted cypress-map plugin by Gleb Bahmutov.
You pass a Cypress chainable (yielding an array or a jQuery object) as the subject, apply a JavaScript function to its yielded value, and it returns a new chainable whose elements are the result of that transformation. It can also map each object directly to one of its properties.
cy.wrap([1, 3, 5, 7])
.map(x => x * x + 2)
// Expected output: Cypress Chainable [3, 11, 27, 51]
But what if what you want to apply to all the elements of the subject chainable is not a function, but another Cypress command? Or worse… what if you want to merge the results of multiple custom commands into a single output?
Yeah… now we’re talking. 😏
ACT 2: CONFRONTATION
A few months ago, I was working on a new plugin to handle internationalized messages in a Cypress framework (I will probably write an article about it in the future).
Without going into too much detail, it leverages the powerful JavaScript library i18next to manage multilingual message files, fallback languages, fallback translations, and many other features that simplify the often cumbersome task of multilingual management.
To provide a bit of context, translations for a given message ID and its arguments were returned by a Cypress custom command in the plugin, along with support and helper utilities for multilingual assertions.
You define all the translations of a message for each supported language, and then use a command like cy.getIntlMessage() to retrieve the appropriate one from those resources for the desired language.
// Messages definition
i18next.init({
lng: 'en-US ',
resources: {
'en-US': {
translation: {
"hello": "Hello.",
"my.age": "My age… {{age}} years!",
"tell.me.your.age": "How old are you, {{name}}?",
}
},
'es-ES': {
translation: {
"hello": "Hola.",
"my.age": "Mi edad… ¡{{age}} años!",
"tell.me.your.age": "¿Cual es tu edad, {{name}}?",
}
},
'el-GR': {
translation: {
"hello": "Γεια σου.",
"my.age": "Η ηλικία μου… {{age}} χρονών!",
"tell.me.your.age": "Πόσων χρονών είσαι, {{name}};",
}
},
// Rest of languages [...]
}
// Rest of i18next properties [...]
})
So a call to cy.getIntlMessage() like this:
// "my.age" is the message key, age is the interpolation key
cy.getIntlMessage("my.age", { lng: "es-ES", age: 100 })
It would yield a Cypress chainable holding the value "Mi edad… ¡100 años!" (in plain English: "My age… 100 years!")
And something like:
// "tell.me.your.age" is the message key, name is he interpolation key
cy.getIntlMessage("tell.me.your.age", { lng: "el-GR", name: "Toúla" })
It would yield a Cypress chainable holding the text "Πόσο χρονών είσαι, Toúla;" (or in English: "What's your age, Toúla?")
Nice, right?
Now let’s say we need to concatenate multiple internationalized messages into a single string when the translations come from a custom command, maybe to make an assertion or to fill an input field.
How would you do it?
"Hmmm..." 🤔
Using The All Times "Classic" Way
Given that Cypress code runs asynchronously in its own command queue, the chainable returned by a Cypress command cannot be stored in a regular JavaScript variables like:
// Forget about it! 🚫
const myAge= cy.getIntlMessage("my.age", { lng: "es-ES", age: 100 })
const tellMeYourAge = cy.getIntlMessage("tell.me.your.age", { lng: "el-GR", name: "Toúla" })
This is a big no-no in Cypress, and if you somehow still don’t believe me, check out my article "The Async Nature of Cypress: Don't Mess with the Timelines in Your Cypress Tests 'Dual-Verse'".
Sure, you can store the value in a Cypress alias or yield it to the next chained command:
// Stored in an alias (a single message)
cy.getIntlMessage("my.age", { lng: "es-ES", age: 100 }).as("myAgeMsg")
But how can you combine two messages returned by cy.getIntlMessage() into a single one?
"Why, you ask? That should be pretty easy-peasy!" 🥱
Yeah… fair enough. I bet most Cypress automation folks would instinctively write something like this:
cy.getIntlMessage("my.age", { lng: "es-ES", age: 100 })
.then(message1 =>
cy.getIntlMessage("tell.me.your.age", { lng: "el-GR", name: "Toúla" })
.then(message2 => `${message1} ${message2}`)
).then(text => cy.log(text))
// Output in Cypress log: "Mi edad… ¡100 años! Πόσων χρονών είσαι, Toúla;"
The classic way!
Or, if you decide to go the alias route:
cy.getIntlMessage("my.age", { lng: "es-ES", age: 100 }).as("myAgeMsg")
cy.getIntlMessage("tell.me.your.age", { lng: "el-GR", name: "Toúla" }).as("tellMeYourName")
cy.get("myAgeMsg")
.then(message1 =>
cy.get("tellMeYourName")
.then(message2 => `${message1} ${message2}`)
).then(text => cy.log(text))
// Output in Cypress log: "Mi edad… ¡100 años! Πόσων χρονών είσαι, Toúla;"
We could even create a custom Cypress command to simplify the concatenation, something like:
// Command to concat intl messages
Cypress.Commands.add('concatIntlMessages_classic'), (arg1, arg2) => {
return
cy.getIntlMessage(...arg1)
.then(msg1 =>
cy.getIntlMessage(...arg2)
.then(msg2 => `${msg1} ${msg2}`)
)
})
cy.concatIntlMessages_classic(
["my.age", { lng: "es-ES", age: 100 }],
["tell.me.your.age", { lng: "el-GR", name: "Toúla" }]
).then(text => cy.log(text))
// Output in Cypress log: "Mi edad… ¡100 años! Πόσων χρονών είσαι, Toúla;"
With the Cypress command, the test code looks cleaner. Very nice!
Ehhh… I’d say cleaner-ish.
"Why is that?" 🤨
And here is where I become the buzzkill…
What if it is not two messages, but three?
Four?
Five?
"Hmmm… just keep going. Nest as many .then() calls as you need!" 🥱🥱
So… let me get this straight.
You are suggesting we create a command chain that just keeps going…?
Something that, for four messages, looks like this:
Cypress.Commands.add('concatIntlMessages_classic_fantastic4', (arg1, arg2, arg3, arg4) => {
return cy.getIntlMessage(...arg1)
.then(msg1 =>
cy.getIntlMessage(...arg2)
.then(msg2 =>
cy.getIntlMessage(...arg3)
.then(msg3 =>
cy.getIntlMessage(...arg4)
.then(msg4 => `${msg1} ${msg2} ${msg3} ${msg4}`)
)
)
)
})
cy.concatIntlMessages_classic(
["hello", { lng: "en-US" }],
[" 🙂 "],
["my.age", { lng: "es-ES", age: 100 }],
["tell.me.your.age", { lng: "el-GR", name: "Toúla" }]
).then(text => cy.log(text))
// Output in Cypress log: "Hello. 🙂 Mi edad… ¡100 años! Πόσων χρονών είσαι, Toúla;"
I do not think so. I’m pretty sure we can do better than that.
Using Cypress.Promise.all()
Cypress.Promise.all is Cypress’s version of Promise.all, built on Bluebird. It lets you run multiple asynchronous operations at the same time and waits until all of them finish (or stops as soon as one fails).
You can learn about Cypress.Promise utility in the Cypress product documentation https://docs.cypress.io/api/utilities/promise. However, you will not find dedicated section showing Cypress.Promise.all, so let’s talk about that here.
At its core, Bluebird’s Promise.all() takes an iterable (usually an array) of Promises and returns one single Promise:
- If every Promise resolves, the returned Promise resolves with an array of results (same order as the input).
- If any Promise rejects, the returned Promise immediately rejects with the first error.
And that is exactly what we need to concatenate multiple translated messages returned by a Cypress command: gather them all, then merge them into a single string. After all, Cypress chainables walk, talk, and quack like promises. And that is precisely the universe in which Cypress.Promise.all was born.
So let’s summon a custom command that does what we need:
Cypress.Commands.add('concatIntlMessages_promiseAll', (...commands) => {
return cy.wrap(
Cypress.Promise.all(commands),
{ log: false }
).then((text) => text.join(' '))
})
And the test code that uses it ends up looking something like this:
cy.concatIntlMessages_promiseAll(
cy.getIntlMessage("hello", { lng: "en-US" }),
[" 🙂 "],
cy.getIntlMessage("my.age", { lng: "es-ES", age: 100 }),
cy.getIntlMessage("tell.me.your.age", { lng: "el-GR", name: "Toúla" }),
).then(text => cy.log(text))
// Output in Cypress log: "Hello. 🙂 Mi edad… ¡100 años! Πόσων χρονών είσαι, Toúla;"
Let’s break this thing down plain and simple.
In the test code, we pass to our custom command
cy.concatIntlMessages_promiseAll()a mix of inputs: severalcy.getIntlMessage()chainables and even plain synchronous values (like [" 🙂 "] - why not?).Cypress.Promise.all(commands)waits until every item has "settled" into a resolved value, then yields an array of results in the same order.Finally, our
.then((text) => text.join(' '))concatenates that array into a single string, which is yielded as the result of the custom command.
"There you go!"
DISCLAIMER: I borrowed the line from My Big Fat Greek Wedding and the image from The New York Times article.
"Anything else?"
Please, be patient, we are not quite done yet.
Using cy.mapChain()
This Cypress command, cy.mapChain(), is available in the cypress-map plugin, and it was not familiar to me until quite recently. Once I stumbled upon it, I was pretty upset I had not discovered it earlier. That is what happens when you don’t read a plugin’s documentation all the way to the end. You miss these little gems!
After this "mea culpa", we are going to know what this command ("not a query!" as the own author stated) does:
It processes each element of the provided array-like chainable (an array or a jQuery object) individually, applies Cypress command functions to each one, and returns an array-like chainable with the same number of elements.
"What?! Apply a Cypress command? Not a regular function?" 🫨
Actually, you can also use synchronous, or even asynchronous, functions as well! 🤯
Cool, right? So, in a few words, it is like a cy.map() command whose mapping function can run Cypress commands, and it does not yield a result until all elements have been processed.
And to me, it seems like we could use this precisely to concatenate the results of our multiple cy.getIntlMessage() calls.
So the new custom command, and its invocation in a test, could look something like this:
// Custom command using cy.mapChain()
Cypress.Commands.add('concatIntlMessages_mapChain', (...args) => {
// args should be arrays like ["hello", {lng:...}]
return cy.wrap(args, { log: false })
.mapChain(arg => cy.getIntlMessage(...arg))
.then(messages => messages.join(' '))
})
// Command invocation
cy.concatIntlMessages_mapChain(
["hello", { lng: "en-US" }],
[" 🙂 "],
["my.age", { lng: "es-ES", age: 100 }],
["tell.me.your.age", { lng: "el-GR", name: "Toúla" }],
).then(text => cy.log(text))
// Output in Cypress log: "Hello. 🙂 Mi edad… ¡100 años! Πόσων χρονών είσαι, Toúla;"
In the test code, we call
cy.concatIntlMessages_mapChain()with several arguments, each representing the input needed to retrieve an internationalized message (for example, a message key, its language and options). These arguments define what messages we want and in which order.Inside the custom command,
cy.mapChain()is the key player. It takes that wrapped list of arguments and processes them one by one, running a Cypress commandcy.getIntlMessage()for each element instead of a plain JavaScript function.Finally, once
mapChain()has processed all elements, the resulting array of messages is joined into a single string with.then((text) => text.join(' ')), and yielded as the result of the custom command.
cy.mapChain() waits until all those Cypress commands have finished and then yields an array with all the resolved values, in the same order, to be combined in this case in single message string.
And again... "there you go!"
⚠️ DISCLAIMER: Yeah, sorry! Using the same image twice in the same article might be a bit much for some, but I couldn’t help myself. I really love the movie, and especially Gus Portokalos. Still, probably not quite as powerful as what we’ll get in John Wick: Chapter 5. 😄
"Ok, fine. You win." 😜
ACT3: RESOLUTION
Ok, we have finally reached the end of our Greek movie detour, where we learned new expressions in all three languages and, along the way, a couple of really útiles Cypress εργαλεία.
Cypress.Promise.all() is ideal when we want to run multiple Cypress commands and wait for all of them to finish, with all the yielded values returned as an array-like chainable.
cy.mapChain() excels when we want to apply a custom command to all the elements of an array-like Cypress chainable and yield the results, also as an array-like chainable.
Cypress.Promise.all() and cy.mapChain(), although quite different in how they work, both helped solve the same problem, just with a little imagination.
One approaches it from the Promise side, coordinating multiple async operations and resolving them together, while the other stays firmly in Cypress territory, transforming values as they move through the command chain. Different mental models, same outcome: cleaner, more expressive tests.
Now it is up to you to decide how you want to use them to make your automation experience a bit more pleasant.
Or maybe not... Your call, no judgment. 😊
And... "THERE YOU GO!!!" (one last time)
😆
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 are feeling especially generous and enjoy my articles, you can buy me a coffee or contribute to a training session. In both cases, my brain will definitely thank you for it! ☕😄



Top comments (0)