Cypress Best Practices for Test Automation
By Hamid Akhtar, Community Contributor - November 28, 2024
Cypress is a cutting-edge front-end testing tool designed for the modern web. You could easily claim that Cypress solves the major issues that QA engineers and developers have while testing modern applications.
Cypress vs Selenium is topic that is frequently contrasted, however, Cypress is fundamentally and architecturally distinct from Selenium. Cypress is not subject to the same limitations that apply to Selenium. As a result, you can construct tests more quickly, easily, and correctly.
This article will discuss some of the best practices for Cypress test automation.
- What is Cypress?
- 10 Cypress Best Practices you Need to Know
- 1. Use data Attributes When Selecting Elements
- 2. Independent it() blocks
- 3. Cypress basics: before(), beforeEach(), after() and afterEach()
- 4. Adding BaseUrl in the config file
- 5. Defining “scripts” in package.json
- 6. 3rd Party Servers
- 7. Control State Programmatically
- 8. Avoid Single Assertions
- 9. Use Commands to Reuse Code
- 10. Write Clear and Descriptive Test Names
What is Cypress?
Cypress is a modern front-end testing tool designed specifically for the needs of today’s web applications. It addresses common challenges that developers and QA teams face, such as test reliability, debugging complexity, and slow test execution.
With Cypress, you can perform End-to-End Testing, Component Testing, Accessibility testing, UI Coverage, and more.
Cypress runs directly in the browser, giving real-time feedback and insights to help optimize your test suite and enhance the overall quality of your applications.
10 Cypress Best Practices you Need to Know
Here are 10 best practices that you need to know for Cypress Test automation:
1. Use data Attributes When Selecting Elements
One of the most important best practices you can adopt when creating your E2E tests is writing selectors completely independent of your CSS or JavaScript. In order to prevent your test suite from being broken by a simple CSS or JavaScript update, you should construct selectors that may be explicitly targeted for testing.
Utilising custom data attributes is the best choice in this case:
// ✅ 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 explicitly state what they are intended to accomplish, and you will be aware that if you change them, your test cases will also need to be updated.
It is difficult to select an element using id and class because these attributes are largely used for behaviour and styling, which means they are constantly subject to change. It’s likely a bad idea to do this if you don’t want the tests that come out of it to be brittle.
Use data-cy or data-testid instead wherever possible. Why? They are more dependable because they are created purely for testing and are therefore independent of the behaviour or styling.
Let’s say, for instance, that we have an input element:
<input id="main" type="text" class="input-box" name="name" data-testid="name" />
To target this element for testing, use data-testid rather than id or class:
// Don't ❌ cy.get("#main").something(); cy.get(".input-box").something(); // Do ☑️ cy.get("[data-testid=name]").something();
2. Independent it() blocks
In it() block, we write the test case script. In a spec file, we include several it() blocks inside the “describe” block. In this scenario, it is essential that we follow the coding principle that no two it() blocks of code should depend on one another.
This approach of coding was selected because, when we run a spec file with many it() blocks (for instance, 10 test cases) if one of them fails, the other it() blocks won’t fail either because there is no dependency between them.
Also Read: Test Case Vs Test Script
Using dynamic wait
Some developers write code for any page action, such as visiting a URL, saving, updating, or deleting action, and then waiting for the API to be active and return results using the cy.wait(timeout) command.
cy.visit('/') cy.wait(5000) // <--- this is unnecessary
The script will wait for five seconds when the aforementioned code is executed, even when the page loads in just two or three seconds.
Using the static wait command cy.wait(timeout) while writing code in these situations is not recommended.
A better fix is to use cy.intercept() while writing dynamic wait code.
cy.intercept('POST', '**/login').as('login'); cy.visit("/") cy.wait('@login')
In the code above, we utilize cy.wait() to hold off on using the specific API “login.” The next piece of code begins to run once we receive the API’s result. The main benefit of developing dynamic code is that it speeds up the script execution and reduces waiting time.
3. Cypress basics: before(), beforeEach(), after() and afterEach()
Consider that each of the 10 test cases in our specification file needs to begin with a few lines of code that are applicable to all of them. In this case, starting each it() block with the same repetitive lines of code is not a smart idea.
The answer is to write the code in the beforeEach() hook.
The beforeEach() hook will automatically execute whatever code it contains before each test case is executed.
In a similar way, the afterEach() hook may be used to write common code that will execute following the completion of each and every test case. Additionally, we can use the before() hook to write a piece of common code that executes before all test cases in a spec file are executed. The after() hook allows us to write common code that will execute following the execution of all test cases in a spec file.
4. Adding BaseUrl in the config file
The login page is the base URL of the application in the majority of cases. The login URL must be used as the base URL in all spec files in order to perform logins and other test-related activities. Some programmers use cy.visit() to hard code the base URL in each spec file’s before() block, as seen in the example below:
before(() => { cy.visit("https://demoapp.com") })
This is a bad tactic because when the spec file is executed in the Cypress runner, it will first load the localhost URL and then reload the URL we supplied in the cy.visit.
This takes time and appears unprofessional. By entering the base url as shown below in the cypress.json file, this issue can be resolved.
cy.visit("http://localhost:3000/login") Change it to: cy.visit("/login") cypress.json { ... "baseUrl": "http://localhost:3000" ... }
5. Defining “scripts” in package.json
Typically, we use Visual Studio Code’s terminal to execute Cypress commands. To open the Cypress runner, for instance, we’ll use the “cypress open” command. “npm run cypress open” is the terminal command to use. It is recommended to include this “cypress open” command in the package.json file’s “scripts” JSON section.
“npm run cy:open” is the corresponding command we type in the terminal. The “scripts” JSON allows us to define all the commands we use to run in the terminal together with a user-defined name.
The main advantage of creating in this method is that, when we need to run lengthy commands in the terminal, we may define the command in “scripts” JSON and utilize the user-defined name.
6. 3rd Party Servers
It’s possible for your application to have an impact on another application developed by a third party. While not frequently occurring, these circumstances are nevertheless conceivable. Consider integrating your app with GitHub so that users can edit data inside of GitHub using the app.
You can use cy.request() to programmatically communicate with GitHub’s APIs after your test has finished running rather than attempting to cy.visit() GitHub. This eliminates the need to ever interact with another application’s user interface.
7. Control State Programmatically
In order to test under the appropriate conditions, try to set the state of your application programmatically whenever you can rather than through the UI. As a result, the UI will no longer be dependent on your state.
You will also notice an improvement in performance because the programmatic state setting is quicker 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();
Instead of using the UI to do the same task, as seen in the code sample above, we can utilize cy.request to communicate directly with an API to log a user in. This also holds true for other actions, like adding test data to your application to put it in the proper state.
Learn More: How to run UI tests in Cypress
8. Avoid Single Assertions
Avoid using single assertions. While single assertions may work well for unit testing, we are writing E2E tests here. You will be able to identify the specific assertion that failed even if you don’t divide your Cypress assertions up into multiple test phases.
// ✅ Do it('Should have an external link pointing to the right domain', () => { cy.get('.link') .should('have.length', 1) .find('a') .should('contain', 'wtips.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', 'wtips.dev'); }); it('Should be external', () => { cy.get('.link').find('a').should('have.attr', 'target', '_blank'); });
The most significant part is that Cypress runs lifecycle events between your tests that reset your state. This requires more processing than simply adding assertions to one test. As a result, writing a single assertion may hinder the effectiveness of your test suite.
9. Use Commands to Reuse Code
To make tests more maintainable and reduce redundancy, leverage Cypress commands for reusable code. Custom commands allow you to encapsulate repetitive actions (like logging in or filling out forms) in a single function that can be reused across multiple test cases.
This improves test readability and simplifies updates—changing the command in one place will apply updates everywhere it’s used. Define custom commands in Cypress’s commands.js file to organize and streamline your test suite.
10. Write Clear and Descriptive Test Names
Clear, descriptive test names make understanding what each test covers easy without diving into the code. Effective test names should summarize the specific action being tested and the expected outcome, like ‘should display error message for invalid login’.
This approach helps with debugging, improves communication within the team, and makes test reports more useful for tracking failures. Remember to keep test names consistent and concise for easier maintenance and readability.
Is Cypress redefining Test Automation?
Yes, Cypress has introduced features that simplify and enhance test automation.
Below is a list of the said features:
- Quick Setup and Usage: Cypress is easy to install and start, allowing users to quickly set up automated tests for critical functionalities. It even provides demos to guide you through using each feature.
- Time Travel Debugging: Cypress captures snapshots during tests, enabling you to “time travel” back to see the exact state of the application at each command—a unique feature in test automation.
- Enhanced Debugging: Cypress offers clear error messages, pinned snapshots to show before-and-after states, and full access to browser developer tools, making debugging faster.
- Bundled JavaScript Tools: Cypress includes popular tools like jQuery, Moment, Sinon, Lodash, Mocha, and Chai for seamless testing within the JavaScript ecosystem.
- Automatic Retry-ability: Cypress waits for commands and assertions to pass without hardcoded waits, adapting dynamically to the loading speed of DOM elements, making it ideal for testing dynamic apps.
- Visibility-Driven Interactions: Cypress only interacts with visible elements and automatically waits for animations and requests to complete, reducing flakiness in tests.
- HTTP Request Control: With cy.intercept(), you can intercept and wait for HTTP requests, making tests more stable and controlled.
- Efficient Element Selection: Cypress’s cy.get() locates elements more quickly than Selenium, as it doesn’t rely on explicit waits, allowing both front-end and unit testing in one tool.
- Spies, Stubs, and Clocks: Cypress provides control over function behaviors, server responses, and timers, giving you unit testing-like control in end-to-end tests.
Why Run Cypress Tests with BrowserStack Automate
Running Cypress parallel tests on BrowserStack Automate offers several benefits:
- Seamless Integration: Cypress doesn’t support parallel testing locally, but BrowserStack Automate integrates smoothly, enabling efficient parallel test execution.
- Real User Conditions: Access to a wide range of real devices and environments ensures tests reflect actual user conditions.
- Enhanced Security: Real device clouds provide secure, isolated testing environments, reducing risks of data breaches.
- Broad Browser and OS Coverage: Helps identify compatibility issues across different browsers and operating systems, improving user experience.
- Performance Insights: Real devices deliver accurate performance data to optimize app responsiveness.
- Scalability and Accessibility: Enables scalable testing for distributed teams.
- CI/CD Integration: Easily integrates into CI/CD pipelines for continuous testing and quick issue detection.
- Cost-Effectiveness: While the initial cost is higher, it saves long-term expenses by reducing the need for fixes and support.
Conclusion
Leverage the various advantages of Cypress to run parallel tests effortlessly. Use BrowserStack’s real browsers to ensure that all tests return 100% accurate results, even when executing multiple tests simultaneously.
Don’t limit your Cypress group tests to the various inadequacies of emulators and simulators; only rely on the real deal to create customer-ready, meticulously optimized web applications.