Skip to content

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

This article was written over 18 months ago and may contain information that is out of date. Some content may be relevant but please refer to the relevant official documentation or available resources for the latest information.

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 to 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, and 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 and 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, go inside your Cypress support folder, and 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>;
      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 that you are all set up, 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 that 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.

Let's innovate together!

We're ready to be your trusted technical partners in your digital innovation journey.

Whether it's modernization or custom software solutions, our team of experts can guide you through best practices and how to build scalable, performant software that lasts.

Prefer email? hi@thisdot.co