If you live on the bleeding edge of browser technology, you are probably excited about new standard modules that soon (might) come built-in to modern browsers.
Built-in storage module
There is a TC39 proposal to include a set of standard modules in each browser. The goal is to prevent the need to bundle and ship JavaScript code if you only want to work with things like browser storage, popup messages, or other "standard" web application operations.
The first standard web module has shipped in Chrome version 74+. It is a storage abstraction called KV Storage. Its purpose is to modernize access to IndexedDB and minimize the use of localStorage
due to its slow performance.
Note: to enable std: ...
modules you need to use Chrome version 74+ and turn on experimental web platform features via the chrome://flags/#enable-experimental-web-platform-features
page.
The KV Storage module's API is similar to localStorage
, but a bit closer to JavaScript's Map
. For example, here is how you would store user-entered preferences.
import {storage} from 'std:kv-storage';
const main = async () => {
const oldPreferences = await storage.get('preferences');
document.querySelector('form').addEventListener('submit', async () => {
const newPreferences = Object.assign({}, oldPreferences, {
// Updated preferences go here...
});
await storage.set('preferences', newPreferences);
});
};
main();
Notice the import
at the top - the name of the module is special, it starts with an std:
prefix. In the future more standard modules will be added; I am particularly excited about std:elements/toast component.
Let's see how we can interact with the first published std:kv-storage
module from within our end-to-end Cypress tests.
The demo application
Note: you can find the source code for these example tests in the bahmutov/cypress-kv-storage-demo repository.
Our application will show a value loaded from the storage and increment it upon clicking a button. When we reload the page, the incremented value should persist.
Here is our web application in all its glory - notice that we are loading our script with type="module"
to make sure it runs in a modern web browser.
<body>
<div>Current count: <span id="counter"></span></div>
<button id="inc">Increment</button>
<!-- the code below only runs in modern browsers -->
<script type="module">
import {storage} from 'std:kv-storage';
const counter = document.getElementById('counter')
const button = document.getElementById('inc')
const getCounter = async () => {
let k = await storage.get('count')
if (isNaN(k)) {
// start with zero the very first time
k = 0
await storage.set('count', k)
}
return k
}
const showCounter = async () => {
counter.innerText = await getCounter()
}
const increment = async () => {
let k = await getCounter()
k += 1
await storage.set('count', k)
await showCounter()
}
// increment and show new count on button click
button.addEventListener('click', increment)
// show stored value at the start
showCounter()
</script>
</body>
First end-to-end test
Before we can start testing, we need to enable the experimental web platform features in the Chrome profile that Cypress runs within. We can do this via the browser launching API. Unfortunately KV Storage is only supported in our Chrome version 74+ browser and not in Cypress's Electron browser (as of Cypress version 3.3.1).
// cypress/plugins/index.js
module.exports = (on, config) => {
// https://on.cypress.io/browser-launch-api
on('before:browser:launch', (browser = {}, args) => {
// browser will look something like this
// {
// name: 'chrome',
// displayName: 'Chrome',
// version: '63.0.3239.108',
// path: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
// majorVersion: '63'
// }
if (browser.name === 'chrome') {
// `args` is an array of all the arguments
// that will be passed to Chrome when it launchers
args.push('--enable-experimental-web-platform-features')
return args
}
if (browser.name === 'electron') {
console.error('Electron browser does not support KV:Storage')
return args
}
})
}
We will start with a very basic test - just confirming that the counter is visible at the start when visiting the application.
/// <reference types="cypress" />
it('shows counter', () => {
cy.visit('/')
cy.get('#counter').should('be.visible')
})
Before the test runs, we need to make sure we are running the Chrome browser - not Electron. We can confirm this by using a before
hook placed in our cypress/support/index.js
file.
before(() => {
expect(Cypress.browser)
.to.have.property('family')
.equal('chrome')
expect(Cypress.browser)
.to.have.property('name')
.equal('chrome', 'this demo only runs in regular Chrome v74+')
// could check browser major version too
})
We can always switch the browser running the tests using the dropdown in the top right corner of the Cypress Test Runner.
Great, our first test confirms that the counter is displayed!
Let's confirm that the counter is incremented when clicking the 'Increment' button.
it('increments the counter on click', () => {
cy.visit('/')
cy.get('#counter').should('have.text', '0')
cy.get('#inc').click()
cy.get('#counter').should('have.text', '1')
})
Resetting the data
Everything is going great, except ... if I rerun the test - it fails. The std:kv-storage
uses IndexedDB to actually save the values and it is NOT reset between the tests.
We need to delete the database before each test, following Cypress's best practices.
beforeEach(() => {
indexedDB.deleteDatabase('kv-storage:default')
})
it('increments the counter on click', () => {
cy.visit('/')
cy.get('#counter').should('have.text', '0')
cy.get('#inc').click()
cy.get('#counter').should('have.text', '1')
})
The test now passes every time. Let's confirm the value stays after we reload the page using the cy.reload()
command.
it('increments the counter on click', () => {
cy.visit('/')
cy.get('#counter').should('have.text', '0')
cy.get('#inc').click()
cy.get('#counter').should('have.text', '1')
cy.reload()
cy.get('#counter').should('have.text', '1')
})
Setting data before load
Imagine we want to set the data in the storage before our application reads it. We could open IndexedDB and create the object store directly from our spec file, but it quickly becomes very verbose and messy. IndexedDB has a complex API - precisely why std:kv-storage
was written. Even opening a database requires wrapping callbacks with promises. Take a look at the amount of extra code needed just to get started.
it('starts with 100', () => {
// first open a database
// then visit the page
cy.wrap(
new Cypress.Promise((resolve, reject) => {
const req = indexedDB.open('kv-storage:default', 1)
req.onerror = reject
req.onsuccess = event => {
resolve(event.target.result)
}
}),
{ log: false }
).then(db => {
cy.log('Opened DB')
// TODO create object store
// TODO save "count" item to 100
})
cy.visit('/')
cy.get('#counter').should('have.text', '100')
})
Ughh, ugly. What if we could use the nice promise-returning std:kv-storage
methods from our spec file instead? Here is where our spec file bundling runs into troubles.
// DOES NOT WORK
// we cannot import from "std:kv-storage" directly
// the Cypress bundler throws an error
import { storage } from 'std:kv-storage'
it('starts with 100', () => {
cy.visit('/')
// TODO: use "storage" methods to set "count"
cy.get('#counter').should('have.text', '100')
})
There is no std:kv-storage
npm module to bundle - this module is only available in the browser. The Chrome documentation in the KV Storage announcement suggests using import maps
to declare paths to polyfills, but this is too complex for my taste.
Instead we can take advantage of the fact that Cypress tests run in the same browser as the application code. If the app can import std:kv-storage
then the spec code can access this storage module by reference. Here is how the application can do this.
<script type="module">
import {storage} from 'std:kv-storage';
if (window.Cypress) {
// pass storage module to the testing code
window.Cypress.storage = storage
}
// the rest of the application code
</script>
Now let's read the count
from the storage after incrementing it to confirm it gets saved correctly.
it('saves the count in storage', () => {
cy.visit('/').then(() => {
// confirm our application has passed us "Cypress.storage" reference
expect(Cypress).to.have.property('storage')
})
cy.get('#counter').should('have.text', '0')
cy.get('#inc')
.click()
// the promise returned by async storage.get
// is automatically part of Cypress chain
.then(() => Cypress.storage.get('count'))
// and the resolved value can be asserted against
.should('equal', 1)
cy.get('#inc')
.click()
.then(() => Cypress.storage.get('count'))
.should('equal', 2)
})
We can write two Cypress custom commands to abstract out setting and getting the count
value. This should make our tests even more readable.
Cypress.Commands.add('getCount', () => {
return Cypress.storage.get('count')
})
Cypress.Commands.add('setCount', n => {
return Cypress.storage.set('count', n)
})
it('saves the count in storage', () => {
cy.visit('/')
cy.get('#counter').should('have.text', '0')
cy.get('#inc')
.click()
.getCount()
.should('equal', 1)
cy.get('#inc')
.click()
.getCount()
.should('equal', 2)
})
it('reads count from storage', () => {
cy.visit('/')
// adding assertion ensures application has loaded
// and prevents race conditions
cy.get('#counter').should('have.text', '0')
// now we are ready to write our value
cy.setCount(100).reload()
// and check it is used after reloading
cy.get('#counter').should('have.text', '100')
})
But how can we set the initial count value using storage
at the application's start? If we set the count after cy.visit()
it's too late - the application has already read the value. We need to play a trick - get the storage
from the application code and immediately set the count
before the application continues. Luckily we can do this by asserting that the Cypress.storage
property is set and immediately calling "storage.set" on it.
it('starts with 100', () => {
let storage
// be ready to set "count" as soon as "window.Cypress.storage" is set
Object.defineProperty(Cypress, 'storage', {
get () {
return storage
},
set (s) {
// here is our moment to shine
// before application reads "count"
storage = s
storage.set('count', 100)
}
})
cy.visit('/')
cy.get('#counter').should('have.text', '100')
})
Here is the order of events:
- Our application loads the built-in
storage
module. - Our application executes the
window.Cypress.storage = storage
statement which calls the "setter" method we have defined in the spec file. - The "setter" method calls
storage.set('count', 100)
. Notice that we do NOT have to wait for this asynchronous method to resolve - like a good database, the last "write" wins. - Our application continues executing after setting the
Cypress.storage
and callsawait storage.get('count')
. By this point thecount
has been set to100
by the test code. - The value
100
is displayed on the page and the last assertion in the test passes.
Conclusions
If the TC39 proposal to build out a JavaScript standard library becomes a reality, modern web applications will become smaller, perform better, and in the end make users happier. With Cypress end-to-end tests we can also make developers happier by having automated tests for the very first module that shipped in Chrome 74+ - the storage abstraction std:kv-storage
.
More info
- Source code in bahmutov/cypress-kv-storage-demo
- Chrome KV Storage announcement
- I have used the same
Object.defineProperty
trick to pass a reference from the application code and to set the initial state in another blog post "Shrink the Untestable Code With App Actions And Effects"