Best practice in test automation with Cypress

Elisha Dongol
4 min readAug 31, 2022

Cypress is widely used for E2E (end to end) testing, so we do not need to divide our tests into front-end, back-end, and database tests. Instead, we can use Cypress to create actual user experiences and do end-to-end testing.

According to my experience with Cypress UI testing, the following Cypress best practices for avoiding anti-patterns should be used to generate high quality automation tests:

  1. Independent test

Automated tests must execute independently, without allowing the execution of another test to influence a state in the application being tested. The result of one tests shouldn’t be impacted by the failure from other test.

Anti-Pattern: Having tests dependent on each-other
Best Practice: Tests should always be independent of one another and still pass.

We can check whether our tests have been improperly coupled or whether one test is depending on the results of an earlier test by one simple action to modify it to it.only. If this test pass,then test is independent of each other, otherwise we need to restructure and alter our strategy.

Here’s how to fix it:

  • Combines several smaller tests into one test.
  • Use before or beforeEach hooks when we need to perform repetitive actions for all tests of a given describe or context
// ❌ Don't
describe('Test on form', () => {
it('should visit the form', () => {
cy.visit('/users')
})

it('should find input', () => {
cy.get('[data-cy="first-name"]').should("exist")
})

it('should type first name', () => {
cy.get('[data-cy="first-name"]').type('John')
})

it('should submit a valid form', () => {
cy.get('form').submit()
})
})

If we changed it in the tests above to it.only, they would fail. They are dependent on one another. We can fix this by following two ways:

a. Combines several smaller tests into one test

// ✅ Do
describe('Test on form', () => {
it('should fill in the form and submit it', () => {
cy.visit('/users')

cy.get('[data-cy="first-name"]').should("exist")

cy.get('[data-cy="first-name"]').type('Aliza')

cy.get('form').submit()
})
})

b. Execute shared code on beforeEach hook

// ✅ Do
describe('Test on form', () => {
beforeEach(() => {
cy.visit('/users')
cy.get('[data-cy="first-name"]').type('Aliza')
cy.get('[data-cy="last-name"]').type('Chen')
})

it('should display form validation', () => {
cy.get('[data-cy="first-name"]').clear()
cy.get('form').submit()
cy.get('[data-cy="first-name-error"]').should('contain', 'First name is mandatory')
})

it('should submit a valid form', () => {
cy.get('form').submit()
})
})

2. Use proper selectors

One of the most important best practices we can do when developing our E2E tests is use of data attributes when choosing elements that are entirely separated from our CSS or JavaScript.

Anti-Pattern: Using selectors that are too brittle and subject to change.
Best Practice: Utilize the data-* custom attributes built only for testing, such as data-test, data-testid, or data-cy.

For example:

<button id="main" class="btn btn-small" name="submit"
role="button" data-cy="submit">Submit</button>
// ✅ Do
cy.get('[data-cy="submit"]');
// ❌ Don't
cy.get('button').click(); // Too generic
cy.get('.btn.btn-small').click(); // Coupled with CSS
cy.get('#main').click(); // Coupled with JS
cy.get('[type="submit"]').click(); // Coupled with HTML

3. Assigning Return Values

We can never specify the return value of any Cypress command because Cypress doesn’t operate synchronously.

Anti-Pattern: Assigning return values of Cypress commands with const, let, or var.
Best Practice: Using aliases and closures to retrieve and store the results of commands.

// ❌ Don't
const button = cy.get('button')
button.click()

This code does not function due to the asynchronous execution of commands. Cypress has provided us with a few extra methods that we can use to get the values of any selected element.

a. Using Closures

The .then() command allows us to obtain the value that each Cypress command returns.

// ✅ Do
cy.get("h2").then((heading) => {
const text = heading.text()
cy.get("input").first().type(text)
})

b. Using alias

By using .as() command, we can create an alias for later use that returns the same subject as the preceding command. We can then access either by this.alias or cy.get(‘@alias’) with a @ at the start.

// ✅ Do
cy.contains('button', 'Submit').as('submitBtn')
cy.get('@submitBtn').click()

4. Avoid waiting for arbitrary periods of time.

We use cy.wait command with a fixed number to wait for an element to appear or a network request to complete before proceeding. To prevent unexpected failures, we introduce cy.wait with an arbitrary number to check if commands have finished execution.

Anti-Pattern: Using cy.wait to wait for arbitrary amounts of time (Number).
Best Practice: Use intercepts or assertions to prevent Cypress from continuing unless a clear condition is satisfied.

// ❌ Don't
cy.wait(5000);
// ✅ Do
cy.intercept('POST', '/signup').as('signup');
cy.wait('@signup'); // Waiting for the request

5. Enable Retries

Retries should be enabled to find tests that are flaky. It can be used to rerun a failing test without writing any code. beforeEach should be used rather than before hooks because before hooks are not retried. Tests can be set to have X retry attempts after they are activated. This configuration can be adjusted in cypress. json as follow.

{
"retries": {
"runMode": 2,
"openMode": 2
}
}

Conclusion

Cypress is an excellent testing framework if utilized correctly and in accordance with best practices.

Some of the Cypress best practices could be bothersome or challenging to put into practice. However, they will undoubtedly save time and money in the long run when Cypress E2E testing is performed. If not, the process will be slowed down by errors and failed tests.

By adhering to these Cypress best practices, tests will run lot more quickly and smoothly without causing any errors or failures in the future.

--

--