Stub navigator API in end-to-end tests

How to stub navigator API methods from Cypress E2E test.

Battery status web app

In source repo bahmutov/demo-battery-api there is a web application forked from pazguille/demo-battery-api that uses navigator browser API to show the current battery status. You can try the demo of the application at http://pazguille.github.io/demo-battery-api/. It should look something like this:

Battery status web page

You can see the application JavaScript code in src/index.js. The main piece of code tries to grab battery status using either navigator.battery or navigator.getBattery properties.

src/index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if (navigator.battery) {
readBattery(navigator.battery)
} else if (navigator.getBattery) {
navigator.getBattery().then(readBattery)
} else {
document.querySelector('.not-support').removeAttribute('hidden')
}

window.onload = function () {
// show updated status when the battery changes
battery.addEventListener('chargingchange', function () {
readBattery()
})

battery.addEventListener('levelchange', function () {
readBattery()
})
}

Let's test this code a little bit to make sure it works as expected.

Simple test

Since we do not know anything about the computer running the end-to-end test, our first Cypress spec simple.js is pretty bare.

cypress/integration/simple.js
1
2
3
4
5
6
7
8
9
/// <reference types="Cypress" />

it('shows battery status', function () {
cy.visit('/')
// shows the actual battery percentage
// we can only assert that the percentage is visible,
// but not its value
cy.get('.battery-percentage').should('be.visible')
})

We can only check if the battery percentage element is visible - but not what is shows, because we don't know what value to expect there.

The simple test

Mocking navigator.battery property

It would be much better to make the test deterministic. For example, we could mock the navigator.battery property the application code checks first. The test can now check each displayed value to be properly rendered, as battery.js spec demonstrates.

cypress/integration/battery.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
/// <reference types="Cypress" />

context('navigator.battery', () => {
it('shows battery status of 50%', function () {
cy.visit('/', {
onBeforeLoad (win) {
// mock "navigator.battery" property
// returning mock charge object
win.navigator.battery = {
level: 0.5,
charging: false,
chargingTime: Infinity,
dischargingTime: 3600, // seconds
addEventListener: () => {}
}
}
})

// now we can assert actual text - we are charged at 50%
cy.get('.battery-percentage')
.should('be.visible')
.and('have.text', '50%')

// not charging means running on battery
cy.contains('.battery-status', 'Battery').should('be.visible')
// and has enough juice for 1 hour
cy.contains('.battery-remaining', '1:00').should('be.visible')
})
})

Mocked property allows us to check more DOM elements

This end-to-end test covers a larger surface of our application's user interface.

Mocking navigator.getBattery method

The previous test has confirmed that navigator.battery property is read by the application code and the values are rendered correctly. What about the case when the navigator.battery is unavailable and the application falls back to navigator.getBattery method to read the current energy status? Let's write a test in get-battery.js to confirm that it works too.

cypress/integration/get-battery.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
/// <reference types="Cypress" />

context('navigator.getBattery', () => {
const mockBatteryInfo = {
level: 0.75,
charging: true,
chargingTime: 1800, // seconds
dischargingTime: Infinity,
addEventListener: () => {}
}

it('shows battery status of 75%', function () {
cy.visit('/', {
onBeforeLoad (win) {
// application tries navigator.battery first
// so we delete this method
delete win.navigator.battery
// then the app tries navigator.getBattery
win.navigator.getBattery = () => Promise.resolve(mockBatteryInfo)
}
})
// check the display
cy.contains('.battery-percentage', '75%').should('be.visible')
cy.contains('.battery-status', 'Adapter').should('be.visible')
cy.contains('.battery-fully', '0:30').should('be.visible')
})
})

The DOM shows the mocked property values correctly.

`getBattery` resolves and DOM renders formatted values correctly

Any time we mock an existing application method, I prefer to create a Cypress method stub. The stub allows us to confirm that it was actually called (and with the right arguments if required). The second test in spec file get-battery.js confirms the DOM elements and that the navigator.getBattery was actually invoked.

cypress/integration/get-battery.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
it('calls navigator.getBattery', function () {
cy.visit('/', {
onBeforeLoad (win) {
delete win.navigator.battery
// we can create Cypress stub and check
// that is is really being called by the application code
win.navigator.getBattery = cy
.stub()
.resolves(mockBatteryInfo)
.as('getBattery')
}
})
cy.contains('.battery-percentage', '75%').should('be.visible')
// ensure our stub has been called by the application
cy.get('@getBattery').should('have.been.calledOnce')
})

Stubs are shown in their own section of the Command Log

Battery status updates

In the mocks above, we returned a no-op addEventListener method with the battery object. Let's return something meaningful and verify our application updates itself when a new battery event is emitted. The application attaches twice to the returned battery object in order to listen to the following events:

src/index.js
1
2
3
4
5
6
7
8
9
window.onload = function () {
battery.addEventListener('chargingchange', function () {
readBattery()
})

battery.addEventListener('levelchange', function () {
readBattery()
})
}

The test is a little bit longer now - I have included a lot of comments and additional steps to make the steps clear. In essence, we set the initial battery status, collect the functions passed by the application, verify them, then call them and verify that the DOM shows the changed values. The spec file updates.js contains this test.

cypress/integration/updates.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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/// <reference types="Cypress" />

context('navigator.getBattery updates', () => {
it('updates battery display', function () {
let appListener
const updateBattery = cy
.stub()
.callsFake((e, fn) => (appListener = fn))
.as('update')
const mockBatteryInfo = {
level: 0.3,
charging: true,
chargingTime: 1800, // seconds
dischargingTime: Infinity,
addEventListener: updateBattery
}

cy.visit('/', {
onBeforeLoad (win) {
delete win.navigator.battery
win.navigator.getBattery = () => Promise.resolve(mockBatteryInfo)
}
})
// initial display
cy.contains('.battery-percentage', '30%').should('be.visible')
cy.contains('.battery-status', 'Adapter').should('be.visible')

// application started listening for battery updates
// by attaching to two events
cy.get('@update')
.should('have.been.calledTwice')
// and check the first arguments to the calls
.and('have.been.calledWith', 'chargingchange')
.and('have.been.calledWith', 'levelchange')
// send a changed battery status event
.then(() => {
// verify the listener was set
expect(appListener).to.be.a('function')
mockBatteryInfo.level = 0.275
// log message for clarity
cy.log('Set battery at **27.5%**')
appListener()
})

// because all Cypress commands are automatically chained
// this "cy.contains" only runs AFTER
// previous ".then" completes
cy.contains('.battery-percentage', '27.5%')
.should('be.visible')
.then(() => {
// let's change a different propety
mockBatteryInfo.charging = false
appListener()
// log message for clarity
cy.log('Pulling the 🔌')
cy.contains('.battery-status', 'Battery').should('be.visible')
})
})
})

Application listens to the battery events

No battery API

Great, our application can use either navigator.battery property or navigator.getBattery method to show the initial battery charge status and listen for updates. But what if the browser does not have this API at all? The browser support for this API is really limited to Chrome only.

Battery status API support

Let us delete both navigator.battery and navigator.getBattery properties before running out test in no-battery.js spec. The test below shows that deleting navigator.getBattery does not work. The method remains there!

Update: you can directly delete the battery method from the navigator object - you just need to delete it from the prototype object! See "Cypress Tips & Tricks" Disable ServiceWorker section.

cypress/integration/no-battery.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

/// <reference types="Cypress" />

context('no battery', () => {
// this test fails on purpose
it('just deleting properties does not work', () => {
cy.visit('/', {
onBeforeLoad (win) {
delete win.navigator.battery

// how to delete navigator.getBattery method?
// deleting does not work
delete win.navigator.getBattery
}
})

// navigator.battery was deleted successfully
cy.window()
.its('navigator.battery')
.should('be.undefined')

// but navigator.getBattery happily remains there
cy.window()
.its('navigator.getBattery')
.should('be.undefined')
})
})

Deleted property `navigator.getBattery` happily remains there

Here is a trick - instead of running delete navigator.getBattery overwrite it using Object.defineProperty like this:

1
2
3
4
5
6
7
8
9
10
11
12
cy.visit('/', {
onBeforeLoad (win) {
delete win.navigator.battery

// how to delete navigator.getBattery method?
// deleting does not work
// but we can just overwrite it with undefined!
Object.defineProperty(win.navigator, 'getBattery', {
value: undefined
})
}
})

And it will stay undefined - and the test will fail because the application crashes 💥

cypress/integration/no-battery.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
it('should not crash', () => {
// but the application does crash
// if both navigator.battery and navigator.getBattery
// methods are missing

// Uncaught TypeError: Cannot read property 'addEventListener' of undefined
//
// window.onload = function () {
// battery.addEventListener('chargingchange', function () {
// readBattery()
// })
// ...

cy.visit('/', {
onBeforeLoad (win) {
delete win.navigator.battery

// how to delete navigator.getBattery method?
// deleting does not work
// delete win.navigator.getBattery

// but we can just overwrite it with undefined!
Object.defineProperty(win.navigator, 'getBattery', {
value: undefined
})
}
})
})

The source of the crash is easy to find - the code calls battery.addEventListener without checking first if the battery is defined.

Application crashes trying to attach the event listener

How do we prevent crashes like this from happening? How can we better target our tests to uncover all the edge cases? Do we need to wait for Cypress cross-browser support and run the same test in Firefox to discover the bug? Or do we randomly delete browser API methods hoping to emulate a real-world scenario?

I will show how we can collect code coverage during end-to-end tests and "discover" the missing code paths in our tests. But it will be a different blog post, so stay tuned by following @bahmutov on Twitter, or subscribe to this blog's RSS feed.

See also