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:
1 | https://github.com/bahmutov/cy-spok |
The test above uses the page to add new soccer players and then verifies the local storage has the expected "shape" and data.
📦 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 | it('loads players', () => { |
The test confirms that the page can read the players from the local storage on page visit and shows each player's name.
Problems
The above test has a few issues:
- 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 | // 🚨 INCORRECT, THIS TEST WILL NOT AS EXPECTED |
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.
- 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 | window.localStorage.setItem('players', JSON.stringify(players)) |
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 | const initialGameInProgress: GameInProgress = { |
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.
1 | /// <reference types="cypress" /> |
The commands file is included from the Cypress support file together with plugins
1 | import 'cypress-map' |
Using the custom Cypress commands automatically ensures the data is in the right order between other Cypress commands.
1 | // ✅ THE CORRECT TEST |
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
1 | // load types that include Cypress and included plugins |
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 | import type { |
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 | const teams = [ |
TypeScript check catches this:
Great, the test data is guaranteed to at least have the right shape.