Testing Loading Skeletons

How to write end-to-end tests for the loading skeletons.

Loading skeletons are displayed while the real data is loading. For example, the login passwords are displayed after 1 second in the GIF below, and the loading skeleton is displayed first.

Loading skeleton

The skeleton itself is simple, just a DIV with some gradient CSS.

src/pages/Login.jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
if (isLoading) {
return (
<div>
<div className="login_logo" />
<div className="login_wrapper">
<div className="login_wrapper-inner">
<div id="login_button_container" className="form_column">
<div className="login-box">
<div className="skeleton skeleton-heading"></div>
<div className="skeleton skeleton-text"></div>
<div className="skeleton skeleton-text"></div>
<div className="skeleton skeleton-heading"></div>
<div className="skeleton skeleton-text"></div>
</div>
</div>
<div className="bot_column" />
</div>
<div className="login_credentials_wrap">
<div className="login_credentials_wrap-inner">
<div className="skeleton skeleton-heading"></div>
<div className="skeleton skeleton-text"></div>
<div className="skeleton skeleton-text"></div>
</div>
</div>
</div>
</div>
)
}
// else return the real component
src/pages/Login.css
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s ease-in-out infinite;
border-radius: 4px;
margin-bottom: 15px;
}
.skeleton-heading {
height: 24px;
width: 60%;
}
.skeleton-text {
height: 16px;
width: 100%;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}

Test the loading skeleton

Let's confirm the loading skeleton is shown initially and then replaced by the real component. If the loading skeleton is always displayed, we can write a test similar to this one:

cypress/e2e/login/skeleton.cy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { LoginPage } from '@support/pages/login.page'

describe('Login form skeleton', () => {
// visit the login page before each test
beforeEach(() => {
cy.visit('/')
})

it('shows the loading skeleton first', () => {
cy.get('#login_button_container').should('be.visible')
cy.get('.skeleton').should('be.visible').and('have.length.greaterThan', 2)
// skeleton should go away
// and the login form is immediately visible (within 100ms)
cy.get('.skeleton').should('not.exist')
cy.get(LoginPage.selectors.username, { timeout: 100 }).should('be.visible')
})
})

Testing the skeleton and its disappearance

Tip: if the skeleton does NOT always appear (for example, if the data is cached and the skeleton is skipped), we can clear the data and/or slow down the network request to make the skeleton always appear.

Test the skeleton positioning

One of the disturbing aspects of the loading skeleton is mismatch between its positions and the loaded text. For example, take a look at this loop - can you see the "jump" between the skeleton heading DIV and the "Accepted usernames are:" H4?

The heading jumps when the skeleton goes away

We want to catch this skeleton position mismatch. Let's compare the "top" property of the skeleton DIV and the rendered H4 elements - they should be close to each other in order to avoid disorienting the user. We could write a test like this:

cypress/e2e/login/skeleton-position.cy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
describe('Login form skeleton', () => {
const skeletonHeading = '.login_credentials_wrap .skeleton-heading'
const loginCredentials = '.login_credentials_wrap .login_credentials h4'
const tolerance = 20 // pixels

// visit the login page before each test
beforeEach(() => {
cy.visit('/')
})

it('does not move from the top', () => {
// confirm the skeleton heading does NOT change
// it "top" position on the page too much (within tolerance)
cy.get(skeletonHeading)
.should('be.visible')
.then(($el) => {
const rect = $el[0].getBoundingClientRect()
return rect.top
})
// round to pixels for nicer comparison
.then(Math.round)
.as('initialTop', { type: 'static' })
cy.get(skeletonHeading).should('not.exist')

cy.get('@initialTop').then((initialTop) => {
cy.get(loginCredentials)
.should('be.visible')
.then(($el) => {
const rect = $el[0].getBoundingClientRect()
return rect.top
})
.then(Math.round)
.should('be.closeTo', initialTop, tolerance)
})
})
})

The test catches the heading skeleton "moving" 100 pixels when the real heading H4 element is shown.

The test fails if the skeleton is too far from its real element

Because I love writing concise and elegant code, I will use my cypress-map plugin to rewrite the above test a little bit to make it shorter and easier to read.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
it('does not move from the top (cypress-map)', () => {
// confirm the skeleton heading does NOT change
// it "top" position on the page too much (within tolerance)
cy.get(skeletonHeading)
.should('be.visible')
.invokeFirst('getBoundingClientRect')
.its('top')
// round to pixels for nicer comparison
.then(Math.round)
.as('initialTop', { type: 'static' })
cy.get(skeletonHeading).should('not.exist')

cy.get('@initialTop').then((initialTop) => {
cy.get(loginCredentials)
.should('be.visible')
.invokeFirst('getBoundingClientRect')
.its('top')
.then(Math.round)
.should('be.closeTo', initialTop, tolerance)
})
})

Now let's fix the skeleton "jump", I found the following problem in the CSS

1
2
3
4
5
6
7
.skeleton-heading {
/* why are we shifting the skeleton heading by 100 pixels?!! */
position: relative;
top: 100px;
height: 24px;
width: 60%;
}

Let's remove the position: relative and top: 100px from the skeleton's heading CSS. The test is now green - the skeleton heading is pretty close to where the real H4 appears.

The skeleton is close to the real heading

We can similarly check other loading skeleton dimensions: left, bottom, and right. We can also check other skeleton DIV elements, not just the heading.