6 Cypress Best Practices for Making Your Tests More Deterministic
Writing good E2E tests can be harder than it seems. You have to come up with the right configurations, the right ways to use your test suite, and decide on what to test and what to not, and how to test those things that you do need tests for. So here are 6 different best practices you can follow when writing your tests to get the most out of Cypress. In fact, you can apply the below practices to any testing framework, not just for Cypress.
1. Use data Attributes When Selecting Elements
One of the most important best practices you can follow when creating your E2E tests is writing selectors that are completely isolated from your CSS or JavaScript. You want to create selectors that can be specifically targeted for testing purposes so that no CSS or JavaScript update can break your test suite, just because a selector has been changed. The best option here is to use custom data
attributes:
// β
Do
cy.get('[data-cy="link"]');
cy.get('[data-test-id="link"]');
// β Don't
cy.get('button'); // Too generic
cy.get('.button'); // Coupled with CSS
cy.get('#button'); // Coupled with JS
cy.get('[type="submit"]'); // Coupled with HTML
These clearly describe their purpose, and you will know that if you change them, you also need to update your test cases.
In summary: Avoid using class names, ids, tags, or common attribute selectors. Use custom data
selectors to isolate your test from your CSS and JS.
2. Set a Base Url
Setting a base URL globally is also a great practice. It can not only make your tests cleaner, it also makes it easier to switch the test suite between different environments, such as a localhost and a production site.
// β
Do set a base URL in your cypress.json config
cy.visit('webtips/cypress');
// β Don't
cy.visit('https://webtips.dev/webtips/cypress');
cy.visit('http://localhost/webtips/cypress');
When it comes to Cypress, this also has performance benefits. By default, if you don't define a global base URL, Cypress will try to load in your localhost before switching to the final location when it comes across a cy.visit
command.
In summary: Set a base URL to avoid unnecessary reloads and easily switch between different environments.
3. Avoid Using cy.wait with a Number
A common pitfall in Cypress is using the cy.wait
command with a fixed number. This likely happens because you want to wait for an element to show up or a network request to finish before proceeding. In order to prevent random failures, you introduce cy.wait
with an arbitrary number to ensure that commands have finished running.
The problem with this is that you end up waiting more than necessary. If you use cy.wait
with 5000 milliseconds to wait for a network request, but the request finishes in 500 milliseconds, then you increased the run time of your test suite by 4500 milliseconds for no reason.
// β
Do
cy.intercept('POST', '/login').as('login');
cy.wait('@login'); // Waiting for the request explicitly
// β Don't
cy.wait(5000);
Instead, use cy.wait
with an alias to ensure that the condition you are waiting on is met, so you can proceed safely. You can also use assertions in place of cy.wait
to ensure that certain conditions are met before moving on.
In summary: Only use cy.wait
with an alias to avoid waiting more than necessary.
4. Tests Should be Able to Pass Independently
Another common mistake that you can do is creating tests that are coupled and depend on each other. Relying on the state of a previous test leads to a brittle test suite that can break the rest of your test cases if initial conditions are not met. Take the following as an example:
// β Don't
it('Should log the user in', () => { ... });
it('Should be able to change settings', () => {
cy.get('[data-cy="email"]').type('email@updated.com');
});
it('Should show updated settings', () => {
cy.contains('[data-cy="profile"]', 'email@updated.com');
});
In the above code example, each test relies on the previous one, meaning that if one fails, others will too. If you change things in the first one, you likely have to update the rest. Decouple your tests and either combine multiple steps that rely on each other into one, or create shared code that you can reuse.
In summary: Tests should be able to pass independently from each other, never rely on the state of a previous test.
5. Control State Programmatically
Whenever you need to set the state for your application so you can test under the right conditions, always try to set the state programmatically, rather than using the UI. This means your state will be decoupled from the UI. You will also see a performance improvement, as setting the state programmatically is faster than using the UI of your application.
// β
Do
cy.request('POST', '/login', {
email: 'test@email.com',
pass: 'testPass'
});
// β Don't
cy.get('[data-cy="email"]').type('test@email.com');
cy.get('[data-cy="pass"]').type('test@email.com');
cy.get('[data-cy="submit"]').click();
In the above code example, we can use cy.request
to directly communicate with an API to log a user in, rather than using the UI to do the same. The same applies to other actions, such as adding test data for your application to get it into the right state.
In summary: Whenever you need to set the state for the right conditions, do so programmatically rather than using the UI of your application.
6. Avoid Single Assertions
Last but not least, avoid using single assertions. Single assertions might be good for unit testing but here we are writing E2E tests. Even if you don't separate your assertions out into different test steps, you will know exactly which assertion failed.
// β
Do
it('Should have an external link pointing to the right domain', () => {
cy.get('.link')
.should('have.length', 1)
.find('a')
.should('contain', 'webtips.dev');
.and('have.attr', 'target', '_blank');
});
// β Don't
it('Should have a link', () => {
cy.get('.link')
.should('have.length', 1)
.find('a');
});
it('Should contain the right text', () => {
cy.get('.link').find('a').should('contain', 'webtips.dev');
});
it('Should be external', () => {
cy.get('.link').find('a').should('have.attr', 'target', '_blank');
});
Most importantly, Cypress runs lifecycle events between your tests that reset your state. This is more computation-heavy than adding assertions to a single test. Therefore, writing single assertions can have a negative impact on the performance of your test suite.
In summary: Avoid using single assertions, to prevent unnecessary state resets that affects performance.
Want to learn Cypress from end to end? Check out my Cypress course on Educative where I cover everything:
Rocket Launch Your Career
Speed up your learning progress with our mentorship program. Join as a mentee to unlock the full potential of Webtips and get a personalized learning experience by experts to master the following frontend technologies: