Dai Codes

A software engineerʼs blog

Menu
Back
A picture of a pixelated, old-fashioned loading progress bar

Testing loading spinner states with Cypress

How to test for loading spinner states using Cypress without the flakiness caused by race conditions

When building a web app that issues asynchronous HTTP requests, you will inevitably want to implement some sort of "loading" state UI. Maybe a spinner, a throbber, a progress bar, or perhaps just a simple callout box containing some variation on the phrase "Loading, please wait".

Whatever your chosen loading state UI is, how do you test it actually works as expected?

A flaky loading state test

If you're using Cypress to test your application, you may first try writing a test like this:

describe('page that loads data', () => {
it('should show the loading spinner when loading the data then hide it afterwards', () => {
cy.visit('/somewhere');
// A bad loading state test - DO NOT DO THIS
cy.get(selectors.loadingSpinner).should('be.visible');
cy.get(selectors.loadingSpinner).should('not.exist');
cy.get(selectors.dataContainer).should('be.visible');
});
});

The problem here is that following the visit command, the browser may render the page and load the data so quickly that the loading spinner disappears before Cypress has checked that it is visible. In which case, Cypress will eventually timeout trying to assert the loading spinner is visible, and the test will fail.

Removing the race condition

To ensure Cypress always has the opportunity assert the loading state UI is visible before the application hides it, we can use the intercept API introduced in Cypress 6 to intercept the HTTP request. We can then prevent the response being received by the application until after Cypress has successfully asserted that the loading spinner is visible:

describe('page that loads data', () => {
it('should show the loading spinner when loading the data then hide it afterwards', () => {

// Create a Promise and capture a reference to its resolve
// function so that we can resolve it when we want to:
let sendResponse;
const trigger = new Promise((resolve) => {
sendResponse = resolve;
});

// Intercept requests to the URL we are loading data from and do not
// let the response occur until our above Promise is resolved
cy.intercept('data-url', (request) => {
return trigger.then(() => {
request.reply();
});
});

// Now visit the page and assert the loading spinner is shown
cy.visit('/somewhere');

cy.get(selectors.loadingSpinner).should('be.visible').then(() => {
// After we've successfully asserted the loading spinner is
// visible, call the resolve function of the above Promise
// to allow the response to the data request to occur...
sendResponse();
// ...and assert the spinner is removed from the DOM and
// the data is shown instead.
cy.get(selectors.loadingSpinner).should('not.exist');
cy.get(selectors.dataContainer).should('be.visible');
});

});
});

Notice how we're putting the sendResponse(); call inside a then callback chained to the loading spinner visibility assertion. This is to ensure it doesn't get invoked until after that assertion has successfully passed. (Remember that Cypress commands are asynchronous by design; execution of the test method does not pause at the assertions. The assertions are queued and then repeatedly tested asynchronously until either they pass, or a timeout occurs. To execute code after an assertion has passed, we must use the Chainable.then callback.)

Making it reusable

Given the above pattern, we can abstract the setup of the interceptor into a utility function for reuse across our test suite:

import {
HttpResponseInterceptor,
RouteMatcher,
StaticResponse,
} from 'cypress/types/net-stubbing';

export function interceptIndefinitely(
requestMatcher: RouteMatcher,
response?: StaticResponse | HttpResponseInterceptor
): { sendResponse: () => void } {
let sendResponse;
const trigger = new Promise((resolve) => {
sendResponse = resolve;
});
cy.intercept(requestMatcher, (request) => {
return trigger.then(() => {
request.reply(response);
});
});
return { sendResponse };
}

The above utility function can be used in a variety of ways:

import { interceptIndefinitely } from '../support/utils';
import { selectors } from '../support/app.po';

describe('page that loads data', () => {

context('with a real response', () => {
it('should show then hide the loading spinner', () => {
const interception = interceptIndefinitely('data-url');
cy.visit('/somewhere');
cy.get(selectors.loadingSpinner).should('be.visible').then(() => {
interception.sendResponse();
cy.get(selectors.loadingSpinner).should('not.exist');
cy.get(selectors.dataContainer).should('be.visible');
});
});
});

context('with a mock response', () => {
it('should show then hide the loading spinner', () => {
const interception = interceptIndefinitely(
'data-url',
{
body: {
answer: 42
},
statusCode: 200,
}
);
cy.visit('/somewhere');
cy.get(selectors.loadingSpinner).should('be.visible').then(() => {
interception.sendResponse();
cy.get(selectors.loadingSpinner).should('not.exist');
cy.get(selectors.dataContainer).should('be.visible');
});
});
});

context('with an error response', () => {
it('should show then hide the loading spinner', () => {
const interception = interceptIndefinitely(
'https://xkcd.com/123/info.0.json',
{
body: {
error: 'Not found',
},
statusCode: 404,
}
);
cy.visit('/somewhere');
cy.get(selectors.loadingSpinner).should('be.visible').then(() => {
interception.sendResponse();
cy.get(selectors.loadingSpinner).should('not.exist');
cy.get(selectors.loadError).should('be.visible');
});
});
});
});

To see this in action for yourself, clone this project on Github which contains a simple Angular app that demonstrates these tests.

Summary

The Cypress intercept API gives us the power to precisely control when our application receives responses to HTTP requests it issues, and thus the ability to create reliable loading state tests free from flaky race conditions.

Support

If you've found my blog useful, please consider buying me a coffee to say thanks:

Discuss

Comments or questions? Join the conversation on Twitter: