Add Types To Your Local Storage During End-to-End Tests

Published on September 16, 2025

If your web application stores data locally in the localStorage object, you can easily set / verify the data from your Cypress end-to-end tests. For example:

cypress/e2e/players.cy.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
https://github.com/bahmutov/cy-spok
import spok from 'cy-spok'
// https://github.com/bahmutov/cypress-map
import 'cypress-map'

it('stores new players in local storage', () => {
cy.visit('/player/')
cy.get('[name=firstName]').should('be.focused').type('Joe')
cy.get('[name=lastName]').type('Smith')
cy.contains('button', 'Add player').click()

cy.get('li.player')
.should('have.length', 1)
.contains('li', 'Joe Smith')

cy.get('[name=firstName]').type('Gus')
cy.get('[name=lastName]').type('Smalls')
cy.contains('button', 'Add player').click()
cy.get('li.player').should('have.length', 2)

cy.window()
.its('localStorage')
.invoke('getItem', 'players')
.apply(JSON.parse)
.should(
spok([
{
id: spok.string,
firstName: 'Joe',
lastName: 'Smith',
},
{
id: spok.string,
firstName: 'Gus',
lastName: 'Smalls',
},
]),
)
})

The test above uses the page to add new soccer players and then verifies the local storage has the expected "shape" and data.

The players test

📦 The code examples in this blog post come from my private "Gametime" repo which has a web app for tracking the soccer games. I wrote this app to help me coach the Cambridge youth soccer teams. I am thinking how to better open source this application, but everyone can use the app to track time and game results: glebbahmutov.com/gametime/

We can also set the local storage before loading the page; this makes the tests run much faster, since they don't have to recreate the state via UI.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
it('loads players', () => {
const players = [
{
id: spok.string,
firstName: 'Joe',
lastName: 'Smith',
},
{
id: spok.string,
firstName: 'Gus',
lastName: 'Smalls',
},
]
window.localStorage.setItem('players', JSON.stringify(players))
cy.visit('/player/')
cy.get('#players li.player').should('have.length', 2)
players.forEach((player, k) => {
cy.get('#players li.player')
.eq(k)
.find('.name')
.should('have.text', `${player.firstName} ${player.lastName}`)
})
})

The test confirms that the page can read the players from the local storage on page visit and shows each player's name.

The page shows loaded players

Problems

The above test has a few issues:

  1. the window.localStorage.setItem method is synchronous and runs before any Cypress command executes. This can lead to the unexpected results. For example, we could test the page reload and it would NOT work as written
1
2
3
4
5
6
7
8
9
10
11
12
// 🚨 INCORRECT, THIS TEST WILL NOT AS EXPECTED

// two different lists of players
const players1 = [...]
const players2 = [...]
window.localStorage.setItem('players', JSON.stringify(players1))
cy.visit('/player/')
// confirm we see players from the list "players1"

window.localStorage.setItem('players', JSON.stringify(players2))
cy.reload()
// confirm we see the players from the list "players2"

Do you think this test works as intended? No. It will fail checking the players from the list "players1". For some reason, after the cy.visit command finishes you see the players ... from list 2! This is because the two localStorage.setItem commands execute immediately, while cy commands are queued up. So when the cy.visit command runs, the local storage already has the players2 list set.

  1. Another problem is having pieces of JSON objects across multiple tests and specs. Many tests in this web app have players, teams, and game in progress pieces of state set before we visit the page
1
2
3
4
5
6
window.localStorage.setItem('players', JSON.stringify(players))
window.localStorage.setItem('teams', JSON.stringify(teams))
window.localStorage.setItem(
'prepare-game',
JSON.stringify(preparedGame),
)

Some objects are simple, like a single player. Some are more complex and changing. For example, the "prepared game" becomes a game in progress with lots of fields:

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
const initialGameInProgress: GameInProgress = {
teamId,
teamName: '',
fieldPlayerIds: [] as string[],
subPlayerIds: [] as string[],
otherPlayerIds: [] as string[],
gameIsRunning: false,
// game start, ms since epoch
gameStartedAt: 0,
/**
* Time duration since the start of the game, ms
*/
gameClockMs: 0,
gameClockText: '00:00',
/**
* for each player id, the total time they played, ms
*/
playersGameTime: {} as Record<string, number>,
subsToDo: [] as SubToDo[],
gameFinished: false,
ourTeamGoals: 0,
opponentTeamGoals: 0,
/** notes about individual goals */
notes: [] as GameNote[],
opponentName: 'them',
/** current field player positions, like "Goalkeeper" or "Center striker" */
playerPositions: {} as Record<string, string>,
}

If the type GameInProgress changes, we would need to check all tests that use window.localStorage.setItem('prepare-game', ...) to ensure the value is a valid object.

Solution

My preferred solution to both problems is writing custom Cypress commands with explicit typed interface. Here is how it looks in practice. First, we will provide several simple Cypress commands wrapping localStorage access.

cypress/support/commands.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// 

Cypress.Commands.add(
'setLocalStorage',
(key: string, value: unknown) => {
window.localStorage.setItem(key, JSON.stringify(value))
},
)

Cypress.Commands.add('removeLocalStorage', (key: string) => {
window.localStorage.removeItem(key)
})

Cypress.Commands.addQuery('getLocalStorage', (key: string) => {
return () => {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : null
}
})

The commands file is included from the Cypress support file together with plugins

cypress/support/e2e.ts
1
2
3
4
import 'cypress-map'
import 'cypress-plugin-steps'

import './commands'

Using the custom Cypress commands automatically ensures the data is in the right order between other Cypress commands.

1
2
3
4
5
6
7
8
9
10
11
12
// ✅ THE CORRECT TEST

// two different lists of players
const players1 = [...]
const players2 = [...]
cy.setLocalStorage('players', players1)
cy.visit('/player/')
// confirm we see players from the list "players1"

cy.setLocalStorage('players', players2)
cy.reload()
// confirm we see the players from the list "players2"

In the test above, the second list players2 will be written into the local storage after all previous Cypress commands have finished.

Second, we describe the allowed types for these commands

cypress/support/index.d.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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// load types that include Cypress and included plugins
///
///
///

import type {
Player,
Team,
PreparedGame,
HistoricGame,
GameInProgress,
} from '../../src/model'

declare global {
namespace Cypress {
interface Chainable {
//
// provide custom type overrides
// for writing specific local storage items
//

// set items
setLocalStorage(
key: 'players',
value: Player[],
): Chainable<void>

setLocalStorage(key: 'teams', value: Team[]): Chainable<void>

setLocalStorage(
key: 'prepare-game',
value: PreparedGame | GameInProgress,
): Chainable<void>

setLocalStorage(
key: 'history',
value: HistoricGame[],
): Chainable<void>

// get items
getLocalStorage(key: 'players'): Chainable<Player[] | null>
getLocalStorage(key: 'teams'): Chainable<Team[] | null>
getLocalStorage(
key: 'prepare-game',
): Chainable<GameInProgress | null>

// remove items
removeLocalStorage(
key: 'players' | 'teams' | 'prepare-game' | 'history',
): Chainable<void>
}
}
}

export {}

Because my end-to-end tests reside in the same repo with the rest of the application code, I can reuse the "official" types and even TypeScript aliases.

1
2
3
4
5
6
7
import type {
Player,
Team,
PreparedGame,
HistoricGame,
GameInProgress,
} from '~/model'

Notice how I provide only specific method signatures. For example:

1
setLocalStorage(key: 'teams', value: Team[]): Chainable<void>

If someone passes an object that does not satisfy the Team interface when using cy.setLocalStorage('teams', ...), TypeScript compiler will complain. For example, if the team object I am trying to set in the local storage is missing the id property:

1
2
3
4
5
6
7
8
const teams = [
{
teamName: 'Team A',
// id: 'team1',
playerIds: ['101', '102'],
},
]
cy.setLocalStorage('teams', teams)

TypeScript check catches this:

TypeScript catches invalid JSON object the test tries to set

Great, the test data is guaranteed to at least have the right shape.