Deep dive

Cypress: Overview and best practices

General notes about E2E testing with Cypress
Jonathan Machado avatar picture

By Jonathan Machado

January 1, 2021


This week I'm diving deep into Cypress, its patterns and best practices!

Cypress has native access to everything

Because Cypress operates within your application, that means it has native access to every single object. Whether it is the window, the document, a DOM element, your application instance, a function, a timer, a service worker, or anything else - you have access to it in Cypress. - Cypress How it works

This allows us to access the store or even a method from a library, like in the example below, where I'm using Nuxt Auth setToken method to programmatically login:

Cypress.Commands.add('login', () => {
  const email = Cypress.env('email')
  const passwordHash = hashPassword(email, Cypress.env('password'))

  cy.request({
    method: 'POST',
    url: 'http://localhost:5000/auth',
    body: { email, password: passwordHash },
  }).then((resp) => {
    // ๐Ÿ“ using Nuxt Auth 'setToken' method to programmatically login
    cy.window().then(({ app }) => {
      app.$auth.setToken(resp.headers.token)
    })
  })
})

Do not use cy.wait(time)

This one is commonly mentioned on Cypress videos and blogs. Here are the main problems that can result from using cy.wait(time):

  • It makes your suite too slow
  • Your tests are still flaky
  • It masks real application bugs

A good practice instead is to wait for a particular event to occur. We can do that by using the Cypress intercept() command. We can use it together with cy.wait() to wait for the request/response cycle to complete:

// Create a route interception aliased as 'getBooks'
cy.intercept('/books', []).as('getBooks')

// find the search input and type...
cy.get('#search').type('Design Patterns')

// once a request to /books responds, this 'cy.wait' will resolve
cy.wait('@getBooks')
// OR
// we can use the object yielded by 'cy.wait' to assert the response
cy.wait('@getBooks').its('response.statusCode').should('eq', 200)

Here's one video where this concept is covered:

Additional details about how to manage flaky tests and some important nuances about Cypress are also covered on this video.

Unprepared Elements issue

Another highlight from the video is a common Cypress scenario referred to as Unprepared Element. That's when an element in the DOM is not ready yet to be interacted with by Cypress. This happens because Cypress checks a lot of things to determine an elementโ€™s visibility.

You can refer to the docs to see How Cypress ensures elements are actionable. Here's some of the things checked by Cypress:

  • Ensure the element is not hidden.
  • Ensure the element is not disabled.
  • Ensure the element is not animating.
  • Ensure the element is not covered.

The solution is to disable interaction with the element (ex. a button) until it can be interacted with. This can be accomplished in a few ways:

  • Don't render the element at all
  • Render as disabled
  • Cover with an overlay

Testing edge cases

We can stub requests and responses to simulate how our app should respond to errors from our server. This allow us to:

  • Test edge cases like 'empty views' by forcing your server to send empty responses.

  • Test how your application responds to errors on your server by modifying response status codes to be 500.

Here's an example from the official docs:

cy.intercept(
  {
    method: 'GET', // Route all GET requests
    url: '/users/*', // that have a URL that matches '/users/*'
  },
  [] // and force the response to be: []
)

You will notice that you can also use fixtures (a fixed set of data located in a file that is used in your tests) together with cy.intercept()!

Exploring the Source Code ๐Ÿš€

In this section I dive into the original source code to explore how things work under the hood.

cy.get() command

Cypress cy.get() is a command just like a custom command you can write in your own project cypress/support/commands.js file.

When executing cy.get(), Cypress automatically retries the query until either: 1) the element is found, or 2) a set timeout is reached

// cypress/packages/driver/src/cy/commands/querying.js
get (selector, options = {}) {
  ...

  // ๐Ÿ“ Use of loadash defaults() function to set the default parameters,
  // in case it hasn't been specified by the user:
  options = _.defaults({}, userOptions, {
    retry: true,
    withinSubject: state('withinSubject'),
    log: true,
    command: null,
    verify: true,
  })
  ...
}

๐Ÿ”— GitHub permalink

Ins and outs of Cypress commands

Cypress commands do not return values, they yield subjects from one command to the next one in the chain. How do they do that? Commands are essentially promises.

This is a great example from the docs that help us to understand how commands are chained under the hood. Notice how each promise returns a value to the following promise:

it('changes the URL when "awesome" is clicked', () => {
  // THIS IS NOT VALID CODE.
  // THIS IS JUST FOR DEMONSTRATION.
  return cy
    .visit('/my/resource/path')
    .then(() => {
      return cy.get('.awesome-selector')
    })
    .then(($element) => {
      // not analogous
      return cy.click($element)
    })
    .then(() => {
      return cy.url()
    })
    .then((url) => {
      expect(url).to.eq('/my/resource/path#awesomeness')
    })
})

Stay in touch

Receive updates about new articles as well as curated and helpful content for web devs. No spam, unsubscribe at any time.