When it comes to testing an application, End-to-End (E2E) testing provides the most confidence and the most bang for the buck.
On the contrary, there is no doubt that End-to-End testing is hard, time-consuming, and comes with a bag of issues to solve. But, only if you're using the wrong tool for the job.
Enter Cypress: Fast, easy, and reliable testing for anything that runs in a browser.
Cypress helps in solving most of the pain points of End-to-End testing and makes it fun to write tests. But, there are certain mistakes to be avoided so that you can get the full benefit of working with Cypress.
In this blog post, we'll cover 5 such common mistakes, which should be avoided when writing Cypress tests. So, without further ado, let's begin!
id
and class
For Selecting ElementUsing id
and class
for selecting element is problematic because they are primarily for behavior and styling purposes, due to which they are subject to change frequently. Doing so result in brittle tests which, you probably don't want.
Instead, you should always try to use data-cy
or data-test-id
. Why? Because they are specifically for testing purposes, which makes them decoupled to the behavior or styling, hence more reliable.
For example, let's suppose we have an input
element:
<input
id="main"
type="text"
class="input-box"
name="name"
data-testid="name"
/>
Instead of using id
or class
to target this element for test, use data-testid
:
// Don't ❌
cy.get("#main").something();
cy.get(".input-box").something();
// Do ☑️
cy.get("[data-testid=name]").something();
What about using text for selecting element?
Sometimes it is necessary to use text such as button label to make an assertion or action. Although, it's perfectly fine, keep in mind that, your test will fail if the text changes, which is what you might want if the text is critical for the application.
Cypress tests are composed of Cypress commands, for example, cy.get
and cy.visit
. Cypress commands are like Promise, but they are not real Promise.
What that means is, we can't use syntax like async-await
while working with them. For example:
// This won't work
const element = await cy.get("[data-testid=element]");
// Do something with element
If you need to do something after a command has been completed, you can do so with the help of the cy.then
command. It will guarantee that only after the previous command finishes, the next will run.
// This works
cy.get("[data-testid=element]").then($el => {
// Do something with $el
});
Note when using a clause like Promise.all
with Cypress command, it might not work as you expect because Cypress commands are like Promise, but not real Promise.
When writing the Cypress test we want to mimic the behavior of a real user in real-world scenarios. Real-world applications are asynchronous and slow due to things like network latency and device limitations.
When writing tests for such applications we are tempted to use arbitrary values in the cy.wait
command. The problem with this approach is that, while it works fine in development, it is not guaranteed. Why? Because the underlying system depends upon things like network requests which are asynchronous and nearly impossible to predict.
// Might work (sometimes) 🤷
cy.get("[data-testid=element]").performSomeAsyncAction();
// Wait for 1000 ms
cy.wait(1000);
// Do something else after the action is completed
Instead, we should wait for visual elements, for example, completion of loading. Not only does it mimic the real-world use case more closely, but it also gives more reliable results. Think about it, a user using your application mostly likely wait for a visual clue like loading to determine the completion of an action rather than arbitrary time.
// The right way ☑️
cy.get("[data-testid=element]").performSomeAsyncAction();
// Wait for loading to finish
cy.get("[data-testid=loader]").should("not.be.visible");
// Now that we know previous action has been completed; move ahead
Cypress commands, for example, cy.get
wait for the element before making the assertion, of course for a predefined timeout value which you can modify. The cool thing about timeout is that they will only wait until the condition is met rather than waiting for the complete duration like the cy.wait
command.
One limitation of Cypress is that it doesn't allow using more than one domain name in a single test.
If you try using more than one domain in a single test block it(...)
or test(...)
, Cypress will throw a security warning. This is the way Cypress has been built.
With that being said, sometimes there is a requirement to visit more than one domain in a single test. We can do so by splitting our test logic into multiple test blocks within a single test file. You can think of it as a multi-step test, for example,
describe("Test Page Builder", () => {
it("Step 1: Visit Admin app and do something", {
// ...
});
it("Step 2: Visit Website app and assert something", {
// ...
});
});
We use a similar approach at Webiny for testing the Page Builder application.
Few things to keep in mind when writing tests in such a manner are:
You cannot rely on persistent storage be it variable in test block or even local storage. Why? Because, when we issue a Cypress command with a domain other than the baseURL
defined in the configuration, Cypress performs a tear-down and does a full reload.
Blocks like "before"
, "after"
will be run for each such test block because of the same issue mentioned above.
Be mindful of these issues before adapting this approach and adjust the tests accordingly.
Cypress commands are asynchronous, and they don't return a value but yield it.
When we run Cypress it won't execute the commands immediately but read them serially and queue them. Only after it executes them one by one. So, if you write your tests mixing async and sync code, you will get the wrong results. For example:
it("does not work as we expect", () => {
cy.visit("your-application") // Nothing happens yet
cy.get("[data-testid=submit]") // Still nothing happening
.click() // Nope, nothing
// Something synchronous
let el = Cypress.$("title") // evaluates immediately as []
if (el.length) {
// It will never run because "el.length" will immediately evaluates as 0
cy.get(".another-selector")
} else {
/*
* This code block will always run because "el.length" is 0 when the code executes
*/
cy.get(".optional-selector")
}
})
Instead, use our good friend cy.then
command to run code after the command has been completed. For example,
it("does work as we expect", () => {
cy.visit("your-application") // Nothing happens yet
cy.get("[data-testid=submit]") // Still nothing happening
.click() // Nope, nothing
.then(() => {
// placing this code inside the .then() ensures
// it runs after the cypress commands 'execute'
let el = Cypress.$(".new-el") // evaluates after .then()
if (el.length) {
cy.get(".another-selector")
} else {
cy.get(".optional-selector")
}
})
})
Cypress is a powerful tool for End-to-End testing, but sometimes we make few mistakes which makes the experience not fun. By avoiding the common mistakes we can make the journey of End-to-End testing smooth and fun.
Thanks for reading! My name is Ashutosh and I work as a full stack developer at Webiny. If you have any questions, comments or just want to connect, feel free to reach out to me via Twitter.