Cypress History API Example

Accessing the browser History API from the Cypress tests.

One of the key features of Cypress is its ability to access the native browser API objects used by the application itself. For example, you could stub the browser FileSystem methods or the navigator API to ensure the application is using those APIs correctly. In this blog post I will show how you can test an application that uses the browser History API.

🎁 You can find the example application and its Cypress tests in the repo bahmutov/cypress-history-api-example.

The application

The application shows pictures of cats. When the user clicks on the link, the application changes the image by modifying the image source attribute, and then pushes the state to the browser history. To the user it looks like normal (but very fast) navigation, yet you do see the URL change, the location history, and you can correctly navigate using the browser "Back" button.

The Cats example application

The code in the application pushes the new state to the application after switching the image source and the content.

public/app.js
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
37
38
39
40
41
vat cats = {
fluffy: {
content: 'Fluffy!',
photo: 'https://placekitten.com/200/200',
},
socks: {
content: 'Socks!',
photo: 'https://placekitten.com/280/280',
},
whiskers: {
content: 'Whiskers!',
photo: 'https://placekitten.com/350/350',
},
bob: {
content: 'Just Bob.',
photo: 'https://placekitten.com/320/210',
},
}

function updateContent(data) {
if (data == null) return

contentEl.textContent = data.content
photoEl.src = data.photo
}

function goTo(cat, title, href) {
const data = cats[cat] || null // In reality this could be an AJAX request
updateContent(data)

// Add an item to the history log
console.log('going to', cat, title, href)
history.pushState(data, title, href)
}

// each links has this handler
function clickHandler(event) {
var cat = event.target.getAttribute('href').split('/').pop()
goTo(cat, event.target.textContent, event.target.href)
return event.preventDefault()
}

Can we verify this behavior?

Cypress and History API

First, let's verify the test can access the History object.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
it('redirects to a cat at the start', () => {
cy.visit('/')

cy.location('pathname').should('equal', '/history/fluffy')
cy.log('**has History API**')
cy.window()
.its('history')
.should('respondTo', 'pushState')
.and('have.property', 'state')
// inspect the state object
.should('deep.include', {
content: 'Fluffy!',
})
})

The test gets the history property from the application's window object. We could validate the entire state object, or just parts of it. We can click on the ITS history command to see the entire object. We could call those methods like back and go from our tests too!

The history object

Spy on the history method calls

Before we try to call the history methods, why don't we check how the application uses them. Using the Sinon spies (bundled with Cypress using cy.spy command) let's confirm the application uses the history.pushState correctly.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
it('spies on history.pushState', () => {
cy.visit('/', {
onBeforeLoad(win) {
// spy on the "pushState" method
cy.spy(win.history, 'pushState').as('pushState')
},
})

cy.location('pathname').should('equal', '/history/fluffy')
cy.get('@pushState')
.should('have.been.calledOnce')
.its('args.0')
.should('deep.equal', [
{ content: 'Fluffy!', photo: 'https://placekitten.com/200/200' },
'Fluffy',
'/history/fluffy',
])
})

Spying on the history.pushState method calls made by the application

Tip: using the cy-spok plugin, we can write powerful assertions to check the entire list of arguments.

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
import spok from 'cy-spok'

it('spies on history.pushState using cy-spok', () => {
cy.visit('/', {
onBeforeLoad(win) {
// spy on the "pushState" method
cy.spy(win.history, 'pushState').as('pushState')
},
})

cy.location('pathname').should('equal', '/history/fluffy')
// navigate to a different cat
cy.contains('a', 'Whiskers').click()
cy.contains('#content', 'Whiskers!')
// check the pushState calls
cy.get('@pushState')
.should('have.been.calledTwice')
.its('args')
.should(
spok([
// first call
[
{ content: 'Fluffy!', photo: 'https://placekitten.com/200/200' },
'Fluffy',
'/history/fluffy',
],
// second call
[
{ content: 'Whiskers!', photo: 'https://placekitten.com/350/350' },
'Whiskers',
// we get the full URL here
Cypress.config('baseUrl') + '/history/whiskers',
],
]),
)
})

Checking multiple calls using cy-spok

Calling history methods

Now let's navigate by calling the History object methods from the test.

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
it('navigates using history methods', () => {
cy.visit('/')
cy.contains('#content', 'Fluffy!')
cy.location('pathname').should('equal', '/history/fluffy')

cy.contains('a', 'Socks').click()
cy.contains('#content', 'Socks!')
cy.location('pathname').should('equal', '/history/socks')

cy.contains('a', 'Whiskers').click()
cy.contains('#content', 'Whiskers!')
cy.location('pathname').should('equal', '/history/whiskers')

cy.contains('a', 'Bob').click()
cy.contains('#content', 'Just Bob.')
cy.location('pathname').should('equal', '/history/bob')

cy.log('**go back in history**')
cy.window().its('history').invoke('back')
cy.contains('#content', 'Whiskers!')
// unfortunately Cypress does not change the URL _shown_
// but it does change the URL _in_ the browser
cy.location('pathname').should('equal', '/history/whiskers')

cy.log('**go -2 in history**')
cy.window().its('history').invoke('go', -2)
cy.contains('#content', 'Fluffy!')
cy.location('pathname').should('equal', '/history/fluffy')
})

The application navigates when we call history methods from the test

Adding synthetic history state

Finally, our application restores the saved history state if it finds it at the start

public/app.js
1
2
3
4
5
6
7
8
9
10
const initialCat = document.location.href.split('/').pop()
if (!cats[initialCat]) {
// maybe there is something in the history?
if (history.state) {
updateContent(history.state)
} else {
// go to the first cat
goTo('fluffy', 'Fluffy', '/history/fluffy')
}
}

Let's test it by putting a robot cat 🤖😺 into the History object first and the visiting the application.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
it('starts at our state', () => {
cy.visit('/', {
onBeforeLoad(win) {
// populate the state history
win.history.pushState(
{
cat: 'robot-whiskers',
content: 'Robot Whiskers!',
// we can even use some other photos during testing
photo: 'https://robohash.org/CE6.png?set=set4&size=150x150',
title: 'Robot Whiskers',
href: '/history/robot-whiskers',
},
'Robot Whiskers',
'/history/robot-whiskers',
)
},
})

cy.contains('#content', 'Robot Whiskers!')
cy.location('pathname').should('equal', '/history/robot-whiskers')
})

Setting up the application to load a robot cat

Nice!