Asserting Network Calls from Cypress Tests

December 23, 2019

By Gleb Bahmutov

The Cypress Test Runner can "see" everything happening inside the web application under test: DOM elements, cookies, local storage items, location, etc. The Test Runner can also spy on and stub network requests the application is making to its backend API or even to the 3rd party APIs. This blog post shows how easy it is to confirm the network calls from the web app as expected.

What Are Network Calls?

Network Calls, in the context of web applications, refer to the interactions between the application and external resources, such as backend servers, APIs, and third-party services. These calls involve sending requests from the web app to retrieve or exchange data, perform operations, or trigger specific functionalities.

Confirming network calls is the process of ensuring that the expected requests are being made accurately and that the received responses align with the desired behavior. By effectively monitoring and validating network calls, developers can verify the proper functioning of data retrieval, integration with external services, and the overall performance of the web application. This confirmation process plays a crucial role in maintaining the reliability, functionality, and security of web applications.

Note: You can find the full source code for this blog post in our XHR Assertions recipe.

Imagine an application that posts a JSON object to an endpoint and shows the response. The web application code might look like this:

const makeRequest = () => {
  const xhr = new XMLHttpRequest();
  xhr.open("POST", "https://jsonplaceholder.cypress.io/posts")
  xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8")
  const data = {
    title: 'example post',
    body: 'this is a post sent to the server',
    userId: 1
  }
  xhr.onreadystatechange = function () {
    if (xhr.readyState === 4) {
      document.getElementById('output').innerText = xhr.response
    }
  }
  xhr.send(JSON.stringify(data))
}
document.getElementById('load').addEventListener('click', makeRequest)

We can see the outgoing XHR request and the server's response by looking at the DevTools Network tab

Inspecting a cypress network call

The application sends an object with title, body, userId fields and receives the same object back plus id field assigned by the server. Let's confirm that this is really happening.

Wait for the User Interface Update

We can start with a test that "waits" for the call to complete by observing the UI changes the application performs at the end of the test. In our case, the application posts the response object into the element with an id output. Let's write a test.

it('sends XHR to the server and gets expected response', () => {
  cy.visit('index.html')
  // before the request goes out we need to set up spying
  // see https://on.cypress.io/network-requests
  cy.server()
  cy.route('POST', '/posts').as('post')
  cy.get('#load').click()
  // make sure the XHR completes and the UI changes
  cy.contains('#output', '"title": "example post"').should('be.visible')
})

The Cypress Test Runner automatically waits for cy.contains to find the given visible text thanks to the built-in retry-ability. After that, we can safely "get" the network call and log it to the console for example.

cy.contains('#output', '"title": "example post"').should('be.visible')

// because the UI has changed, we know the XHR has completed
// and we can retrieve it using cy.get(<alias>)
// see https://on.cypress.io/get
  
// tip: log the request object to see everything it has in the console
cy.get('@post').then(console.log)

Open the DevTools console and inspect the network call yield by cy.get('@post').

The network call has its own property plus request and response objects

Assert network call properties

Let's confirm that posting a new item completes with a HTTP status 201 Created.

// same test

// tip: log the request object to see everything it has in the console
cy.get('@post').then(console.log)

// you can retrieve the XHR multiple times - 
// returns the same object.
cy.get('@post').should('have.property', 'status', 201)

If we need to confirm multiple properties, it makes sense to use a .should(callback) assertion form and place multiple expect assertions there.

// same test
cy.get('@post').should('have.property', 'status', 201)

// we cannot chain any more assertions to the above request object
// because the "have.property" assertion yields the property's value
// so let's just grab the request object again and run multiple assertions
cy.get('@post').should((req) => {
  expect(req.method).to.equal('POST')
  expect(req.url).to.match(/\/posts$/)
  // it is good practice to add message to the assertion
  expect(req, 'has duration in ms').to.have.property('duration').and.be.a('number')
})
Asserting the network call's status and other properties

Assert Request and Response Objects

The network call object yielded by cy.get(<alias>) has the request object with headers and body. Let's confirm the web application sends the expected data.

// same test
// let's confirm the request sent to the server
cy.get('@post').its('request.body').should('deep.equal', {
  title: 'example post',
  body: 'this is a post sent to the server',
  userId: 1,
})
// get the same request object again and confirm the response
cy.get('@post').its('response').then((res) => {
  // because the response object is not going to change
  // we can use cy.then() callback to run assertions just once
  expect(res.headers).to.include({
    'cache-control': 'no-cache',
    expires: '-1',
    'content-type': 'application/json; charset=utf-8',
    location: 'http://jsonplaceholder.cypress.io/posts/101',
  })

  // it is a good practice to add message argument to the
  // assertion "expect(value, message)..." that will be shown
  // in the test runner's command log
  expect(res.body, 'response body').to.deep.equal({
    body: 'this is a post sent to the server',
    id: 101,
    title: 'example post',
    userId: 1,
  })
})

The test passes and asserts the request and response objects and some headers.

Cypress full test

Waiting for the Network Call to Happen

The test above waited for the user interface to change, which means the network call has finished and we can safely use cy.get(<alias>) to get the object and start asserting its properties. The cy.get yields null if the aliased network call has not happened yet, and not every call leads to the user interface changes. In this case you should directly wait for the network call to happen using cy.wait(<alias>) command.

For example, the web application makes the same XHR request after a 1 second delay. The next test demonstrates how to skip the user interface and wait for the network call, and then assert its properties.

it('sends request after delay', () => {
  cy.visit('index.html')

  // before the request goes out we need to set up spying
  // see https://on.cypress.io/network-requests
  cy.server()
  cy.route('POST', '/posts').as('post')

  cy.get('#delayed-load').click()

  // the XHR request has NOT happened yet - we are not checking the UI
  // to "wait" for it. Thus we cannot use cy.get("@post"),
  // instead we need to wait for the request to happen
  // using cy.wait("@post") call
  //
  // see https://on.cypress.io/wait
  cy.wait('@post').should((xhr) => {
    expect(xhr.status, 'successful POST').to.equal(201)
    expect(xhr.url, 'post url').to.match(/\/posts$/)
    // assert any other XHR properties
  })

  // if you need to assert again, retrieve the same XHR object
  // using cy.get(<alias>) - because by now the request has happened
  cy.get('@post').its('request.body').should('deep.equal', {
    title: 'example post',
    body: 'this is a post sent to the server',
    userId: 1,
  })
})

The test waits for the network call to happen using the cy.wait(<alias>), and runs several assertions against the yielded object. Then we need the same network call object again - and now it is safe to grab it using cy.get(<alias>) because it has happened for sure.

Waiting for the network call to complete

Notice the network call starts 1 second after the button click, but it takes another 2 seconds to complete - and cy.wait automatically waits until the network trip finishes.

Multiple Assertions Using Spok

Looking at the Command Log I have to admit that the assertion messages are noisy, yet do not really reveal useful information, as shown below

Assertion messages highlighted in blue

For example, the last assertion message simply says "response body expected ... to deeply equal ..." but we don't see the values, and have to look at the spec code to find out what is going on.

expect(res.body, 'response body').to.deep.equal({
  body: 'this is a post sent to the server',
  id: 101,
  title: 'example post',
  userId: 1,
})

We also had to get the network call object several times and write boilerplate code like expect(...).to.have.property and expect(...).to.equal again and again. Luckily there is a better way.

One of our Cypress engineers Thorsten Lorenz has written a tiny assertion helper called spok that can check a complex nested object property by property in a single call. It is a combination of predicates, value checks and schema checks. I have written an adaptor for Spok to produce Cypress assertions, a plugin called cy-spok. Here is the same network test but rewritten using a single cy-spok assertion.

const spok = require('cy-spok')

it('asserts multiple XHR properties at once using cy-spok', () => {
  cy.visit('index.html')

  // before the request goes out we need to set up spying
  // see https://on.cypress.io/network-requests
  cy.server()
  cy.route('POST', '/posts').as('post')

  cy.get('#load').click()
  cy.contains('#output', '"title": "example post"').should('be.visible')

  // Spok https://github.com/thlorenz/spok is a mix between schema and value assertions
  // Since it supports nested objects, in a single "should()" we can verify desired
  // properties of the XHR object, its request and response nested objects.
  cy.get('@post').should(spok({
    status: 201,
    url: spok.endsWith('posts'),
    // network request takes at least 10ms
    // but should finish in less than 1 second
    duration: spok.range(10, 1000),
    statusMessage: spok.string,
    // check the request inside XHR object
    request: {
      // using special keyword "$topic" to get
      // nicer indentation in the command log
      $topic: 'request',
      body: {
        title: 'example post',
        userId: 1,
      },
    },
    response: {
      $topic: 'response',
      headers: {
        'content-type': 'application/json; charset=utf-8',
        'cache-control': 'no-cache',
      },
      body: {
        title: 'example post',
        body: spok.string,
        userId: 1,
        // we don't know the exact id the server assigns to the new post
        // but it should be > 100
        id: spok.gt(100),
      },
    },
  }))
})

We grab the network call using cy.get(<alias>) or cy.wait(<alias>) and then in a single assertion confirm multiple properties using .should(spok(expectations)) syntax. The Command Log now has a much better information-to-noise ratio.

Network call checked using cy-spok plugin

Waiting for Multiple Calls

Sometimes an application can fire multiple network calls. We can wait for them using several cy.wait commands - each wait expects one network call to happen. Then we can use a special syntax to retrieve all network call objects at once or just a single one.

it('waits for multiple requests to finish', () => {
  cy.visit('index.html')
  cy.server()
  cy.route('POST', '/posts').as('post')

  // click both buttons - there will be 2 XHR requests going out
  cy.get('#load').click()
  cy.get('#delayed-load').click()

  // there are two XHR calls matching our route
  // wait for both to complete
  cy.wait('@post').wait('@post')

  // we can retrieve all matching requests using the following syntax
  // cy.get('<alias>.all')
  cy.get('@post.all').should('have.length', 2)
  .then((xhrs) => {
    // xhrs is an array of network call objects
    expect(xhrs[0], 'first request status').to.have.property('status', 201)
    expect(xhrs[1], 'second request status').to.have.property('status', 201)
  })

  // and we can make assertions about each separate call
  // by retrieving it like this (index starts with 1)
  // cy.get('<alias>.<index>')
  cy.get('@post.1').should((xhr1) => {
    expect(xhr1, 'first request').to.have.property('status', 201)
  })
  cy.get('@post.2').its('response.body.id').should('equal', 101)
})
A test waiting for two network calls (blue arrows)

Bonus: Control the Clock

When the user clicks the "Make delayed request" button, the web application fires the network call after 1 second delay. Thus our end-to-end test must take at least 1 second, right? Not so fast, I mean, not so slow,  ... I mean, let's make it faster!

Cypress includes an ability to control and stub the app's clock via cy.clock and cy.tick commands. If our application "waits" for 1 second to fire the network call - let's tell the app that 1001ms has already passed!

it('speeds up application by controlling clock', () => {
  // before loading application start mocking the app's clock
  // https://on.cypress.io/clock
  cy.clock()
  cy.visit('index.html')

  // before the request goes out we need to set up spying
  // see https://on.cypress.io/network-requests
  cy.server()
  cy.route('POST', '/posts').as('post')

  cy.get('#delayed-load').click()
  // force the application to pass 1 second really quickly
  // https://on.cypress.io/tick
  cy.tick(1001)
  cy.wait('@post').should('have.property', 'status', 201)
})
The test is made faster by mocking app's clock

Notice how the test takes well under 1 second - the time is now dominated by the actual network round-trip time and not by the application's built-in setTimeout(..., 1000) delay.

See More about Asserting Network Calls

Happy Testing!

Want to become a part of the thriving Cypress community and take your testing skills to new heights? Connect with like-minded professionals, share knowledge, and stay up-to-date with the latest trends in component testing.