Testing the first built-in browser module, KV Storage, using Cypress

July 2, 2019

By Gleb Bahmutov

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')
})
The test passes ✅

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')
})
The test passes ✅

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)
})
The test passes ✅

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 calls await storage.get('count'). By this point the count has been set to 100 by the test code.
  • The value 100 is displayed on the page and the last assertion in the test passes.
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