Cypress | How to handle auth JWT using env and commands

924 Words

1. Setting the scene#

A typical case of application structure is when we have a client paired with API. In such a scenario, its very useful to set up end-to-end tests using Cypress, which will help you mimic actual user behavior across the application and builds a lot of confidence in your implementation.

If you want some api endpoints protected behind auth, using JWT is a very common solution.

This complicates our cypress testing a bit, and one of the recommended approaches we can take to speed it up is to use network requests in Cypress.

2. Need for writing explicit network requests in cypress?#

There are two ways you can trigger network requests in cypress:

  1. The UI way - navigate to the form using cypress, fill in the details and click on the button. Let the application build the payload and make the network request for you, mimicking the expected user flow.
  2. The network request way - Build your payload and use cy.request to make a direct network request. Then handle the response (Eg - If you are using redux, dispatch the correct action).

In the UI way, you do not have to know anything about the application is handling the state, and it would be a complete black box testing. It also gets slower and slower the more you do it. Imagine, you have 50 tests and for each test you need to create 10 products before you run the actual test!

The network request way is more involved in the sense that you need full knowledge of payload, auth, state management, and other cascading actions needed to handle the network request. It is much quicker though and speeds up the test run duration for a larger test suite.

Ideal combination is to test the flow using UI components ONCE, and in other places use the network requests when that UI flow is not the focus of the test.

3. Why not mock API responses OR seed the data OR both?#

These are all very valid questions, and in some situations, the best course of action. However, in true end-to-end testing, we want to test how the application behaves FOR THE USER which includes front end and back end both.

Sometimes seeded data is not updated after API updates and then tests pass but application fails and so on.

Simply put, we need the most accurate mimicking of actual user flows while we are running the end-to-end tests. At the same time, we should also consider developer experience, if your CI/test run is taking a lot of time after every push, it gets frustrating pretty quickly.

4. What are cypress commands?#

Commands are a great way to abstract away repeatedly used code, and also we can override default behavior of cypress core commands as well. Read about them here on the Cypress website.

5. How does the login command look?#

A simple login command can look like this -

Commands.js

Cypress.Commands.add('login', (email, password) => {
  // make login call to endpoint
  cy.visit('/')
  cy.request('POST', `${BACKEND}/login/`, {
    email: email,
    password: password,
  })
})

We can also store username/email and password in cypress.json and access it.

We call it in our test like so -

create-product.spec.js

it('Authenticated user can create a new product', () => {
  cy.login('user@test.com', 'password') // logs in the user
})

6. How to access redux store?#

It’s pretty simple once you see it in action! Read it here.

7. Exploring Cypress env#

Read it here.

8. How to set and access cypress env#

You set it using

Cypress.env(<env key> : <value you want to store>)

You access it using

const value = Cypress.env(<env key>)

Simple really.

9. TLDR: Some Example code#

To show it in action - Let’s assume product creation requires authenticated request with payload to POST/products.

In this example, I am storing JWT in me:{token: <token>} format in my redux store.

This is how the command could look like -

Commands.js

Cypress.Commands.add('setUserToken', () => {
  cy.window()
    .its('store')
    .invoke('getState')
    .its('me')
    .then((me) => Cypress.env('token', me.token))
  // Here, me is the user stored in redux
})

Cypress.Commands.add('login', (email, password) => {
  // make login call to endpoint, and push info to redux store
  cy.visit('/')
  cy.request('POST', `${BACKEND}/sessions/`, {
    email: email,
    password: password,
  }).then((response) =>
    cy
      .window()
      .its('store')
      .invoke('dispatch', { type: 'LOGIN', body: response.body })
  )
  // response.body contains `user` object
  // which is stored in redux store as `me`
  cy.setUserToken()
})

Cypress.Commands.add('createProduct', (name) => {
  const payload = {
    name: name,
  }

  const token = Cypress.env('token')
  // We access the user JWT token stored in env

  cy.request({
    method: 'POST',
    url: BACKEND + '/products/',
    body: JSON.stringify(payload),
    headers: {
      authorization: 'Bearer ' + token, // consume the token
    },
  }).as('createProduct')
  cy.get('@createProduct')

  // We can also add assertions about
  // request/response structures here
})

and this is how it is used in a test -

create-product.spec.js

it('Authenticated user can create a new product', () => {
  cy.login('user@test.com', 'password') // logs in the user
  // Assert that product does not exist
  cy.createProduct('My new product') // Creates product in BE
  // Assert that product exists
})

I have extracted out setting the token as env logic as a separate command setUserToken, to make it more explicit and clean.