Would you like to TALK about it?

Cypress testing your IndexedDb contents with @this-dot/cypress-indexeddb

January 06, 2022 | Originally published on the ThisDot blog

cover

IndexedDb is a browser API for storing significant amounts of structured data locally. It is very useful when you are working on a PWA and you need to support offline mode. Developers have been using IndexedDb to store the data changes while the user is offline and send those to the server when the user goes online again. But that is not the only use-case. Sometimes, there are very long forms that need to be saved locally so if the user refreshes or comes back later the previously entered data is not lost. The same scenario can be useful for a web shop’s checkout logic, so users can go back to their abandoned shopping carts weeks later even.

Whether you implement your IndexedDb logic or you use a wrapper library, such as localforage to handle IndexedDb for you, it is not that straightforward to test these implementations. If you would like to check out the source code, feel free to visit our repository.

Our example application

For our example, we have a simple user form with a few input fields. This form saves every entry that comes from the user. It uses a debounce logic that waits for 1 second before saving the form contents into indexedDb. When the user submits the form, it waits for the last write operation. After that finishes it submits the form and then clears the indexedDb. If the proper fields are saved to the database, if the user opens this page, those values are loaded.

Our example form

This example application uses localforage to populate IndexedDb. The database name is FORM_CACHE that will have a keyvaluepairs object-store created by localforage. Inside the object-store, we will use the user_form key to store the values. Let’s write our tests!

Installing the plugin

To use the helper functions we need to install the package first.

npm install @this-dot/cypress-indexeddb

After the install finishes, inside your cypress support folder, add the following line to your commands.ts file:

import '@this-dot/cypress-indexeddb';

Create a cypress-indexeddb-namespace.ts file inside the same support folder, and add the following typings:

  declare namespace Cypress {
    interface Chainable<Subject> {
      clearIndexedDb(databaseName: string): void;
      openIndexedDb(databaseName: string, version?: number): Chainable<IDBDatabase>;
	  getIndexedDb(databaseName: string): Chainable<IDBDatabase>;
      createObjectStore(storeName: string): Chainable<IDBObjectStore>;
      getStore(storeName: string): Chainable<IDBObjectStore>;
      createItem(key: string, value: unknown): Chainable<IDBObjectStore>;
      readItem<T = unknown>(key: IDBValidKey | IDBKeyRange): Chainable<T>;
      updateItem(key: string, value: unknown): Chainable<IDBObjectStore>;
      deleteItem(key: string): Chainable<IDBObjectStore>;
    }
  }

Finally, import the cypress-indexeddb-namespace.ts contents in the index.ts file of the same support folder.

import './commands';
import './namespace';

Now you are all set up, and you can use the commands in your tests.

Test setup

Since IndexedDb stores data locally, it is imperative to delete previous databases before every test run. That way we can make sure that the tests run in isolation and data from other tests won’t leak out. After deleting the database, we also want to set up the connections so we can access and manipulate the database in our tests later.

describe(`User Form`, () => {

  beforeEach(() => {
    cy.clearIndexedDb('FORM_CACHE');
    cy.openIndexedDb('FORM_CACHE')
      .as('formCacheDB')
      .createObjectStore('keyvaluepairs')
      .as('objectStore');
  })

});

When we open a connection to the FORM_CACHE database and create the keyvaluepairs object-store, we can use the .as() method to alias them for later access. The .get() command could not be overwritten, so we have the getIndexedDb and the getStore methods to retrieve them.

Reading from the database

We have our before hook, so let’s test the saving functionality of the form. We enter some values into the form with cypress, then we wait for the debounced write operation. After that, we simply access the database, read the contents and assert them.

describe(`User Form`, () => {

  // beforeEach

  it(`entering data into the form saves it to the indexedDb`, () => {
    cy.visit('/user-form');

    cy.get('#firstName').should('be.visible').type('Hans');
    cy.get('#lastName').should('be.visible').type('Gruber');
    cy.get('#country').should('be.visible').type('Germany');
    cy.get('#city').should('be.visible').type('Berlin');

    cy.log('Waiting for the debounceTime to start a db write').wait(1100);

    cy.getStore('@objectStore').readItem('user_form').should('deep.equal', {
      firstName: 'Hans',
      lastName: 'Gruber',
      country: 'Germany',
      city: 'Berlin',
      address: '',
      addressOptional: '',
    });
  });

});

We can use the .getStore('@objectStore') method to retrieve a previously aliased store instance for ourselves. The .readItem(key) method retrieves the contents of that key in the store. We can use the .should() method to assert the retrieved value. Here we use the deep.equal assertion.

Deleting from the database

Sometimes users delete everything that is stored locally. They can do it from the developer tools or maybe they run clean-up software that removes these for them. Anyways, if something like that happens they cannot get back to the state they left the form in. Let’s simulate it by deleting items from the database.

describe(`User Form`, () => {

  // beforeEach

  it(`when the indexedDb is deleted manually and then the page reloaded, the form does not populate`, () => {
    cy.visit('/user-form');

    cy.get('#firstName').should('be.visible').type('Hans');
    cy.get('#lastName').should('be.visible').type('Gruber');
    cy.get('#country').should('be.visible').type('Germany');
    cy.get('#city').should('be.visible').type('Berlin');

    cy.log('Waiting for the debounceTime to start a db write').wait(1100);

    cy.log('user manually clears the IndexedDb')
      .getStore('@objectStore')
      .deleteItem('user_form')
      .reload();

    cy.get('#firstName').should('be.visible').and('have.value', '');
    cy.get('#lastName').should('be.visible').and('have.value', '');
    cy.get('#country').should('be.visible').and('have.value', '');
    cy.get('#city').should('be.visible').and('have.value', '');
  });

});

We again get the previously aliased store and we call the .deleteItem(key) method on it. After that, we can chain other cypress methods off, for example, we reload the page in this particular test.

Adding data to the database

We can see that if we empty the database, the values won’t load into the form. We should write a test to populate these values before loading the page and test if they are loaded into the form properly.

describe(`User Form`, () => {

  // beforeEach

  it(`when there is relevant data in the indexedDb, the form gets populated when the page opens`, () => {
    cy.getStore('@objectStore').createItem('user_form', {
      firstName: 'John',
      lastName: 'McClane',
      country: 'USA',
      city: 'New York',
    });

    cy.visit('/user-form');

    cy.get('#firstName').should('be.visible').and('have.value', 'John');
    cy.get('#lastName').should('be.visible').and('have.value', 'McClane');
    cy.get('#country').should('be.visible').and('have.value', 'USA');
    cy.get('#city').should('be.visible').and('have.value', 'New York');
  });

});

We can call the .createItem(key, value) method to populate the provided key in an object-store. We can use any value.

Testing if our code clears the database properly

We can create/update and read data in our database. We can even delete values from it using our library, but what about testing our code? We have a wrapper class around localforage in the codebase that handles database deletion on submit. We should test that functionality as well!

describe(`User Form`, () => {

  // beforeEach

  it(`submitting the form clears the indexedDb`, () => {
    cy.getStore('@objectStore').createItem('user_form', {
      firstName: 'John',
      lastName: 'McClane',
      country: 'USA',
      city: 'New York',
    });

    cy.visit('/user-form');

    cy.get('#address').type('23rd Street 12');
    cy.get(`[data-test-id="submit button"]`).should('be.visible').and('not.be.disabled').click();

    cy.log('Waiting for the save event and DB write to occur').wait(1100);

    cy.getStore('@objectStore').readItem('user_form').should('be.undefined');
  });

});

We use the .createItem() method to make sure there are values populated into the database. Then we use cypress to make our app put some other values into the database. After we submit the form we wait for the debounce handlers to handle the operations and lastly, we validate that the value that we previously populated should now be undefined.

How to run the above tests

If you would like to see it in action, please check out our git repository. After running npm install you should be able to start the tests by running the npm run e2e:debug command.


This Dot Labs is a modern web consultancy focused on helping companies realize their digital transformation efforts. For expert architectural guidance, training, or consulting in React, Angular, Vue, Web Components, GraphQL, Node, Bazel, or Polymer, visit thisdotlabs.com.

This Dot Media is focused on creating an inclusive and educational web for all. We keep you up to date with advancements in the modern web through events, podcasts, and free content. To learn, visit thisdot.co.


Balázs Tápai

Written by Balázs Tápai.
I will make you believe that I'm secretly three senior engineers in a trench-coat. I overcome complex issues with ease and complete tasks at an impressive speed. I consistently guide teams to achieve their milestones and goals. I have a comprehensive skill set spanning from requirements gathering to front-end, back-end, pipelines, and deployments. I do my best to continuously grow and increase my capabilities.

You can follow me on Twitter or Github.