loading...
Cover image for Testing your IndexedDB code with Jest

Testing your IndexedDB code with Jest

andyhaskell profile image &y H. Golang (he/him) Updated on ・10 min read

In my last IndexedDB tutorial, we looked at the basics of IndexedDB’s API for building a small web app. However, although IndexedDB is a powerful API for giving your web apps a client-side database, it definitely took me a while to figure out how to give an IndexedDB app automated test coverage so we know it works how we expect it to.

If everything is asynchronous in IndexedDB's API, how would we write some tests for our IndexedDB database layer? And how do we get our tests to use IndexedDB when it's a browser API? In this tutorial, we're going take two asynchronous IndexedDB functions and see how to test them out with Jest.js.

This tutorial does assume you know the basics of IndexedDB and of automated testing in JavaScript.

Checking out our code:

Inside our db.js file, you can see the code we're running (commit 1), which is a sticky note database based on the code in the last tutorial. There are functions that talk directly to IndexedDB:

  • setupDB is used for our database. We store the IndexedDB database object in a global variable called db, which is initialized once setupDB completes.
  • addStickyNote takes in a string and adds sticky note of that message to the database.
  • getNotes retrieves all of the sticky notes in the database, either in forward or reverse order.

Since these functions are how we talk to IndexedDB, one of the things we'll want to test out in our database tests is that if we put some sticky notes into the database with addStickyNote, we can get all of them back in the correct order with getNotes. So the test code we want might look something like this:

setupDB();
addStickyNote("SLOTHS");
addStickyNote("RULE");
let notes = getNotes();
// Run assertions that we got back the sticky notes we wanted

However, remember that IndexedDB is an asynchronous API, so when we run those calls to addStickyNote, the JavaScript runtime starts the database transactions, but it doesn't wait for them to finish. Because of that, the two calls to addStickyNote aren't necessarily done when we're running getNotes. Not only that, but setupDB isn't necessarily done when we start addStickyNote, so it's possible that addStickyNote could be run while the db variable is still undefined!

So in order to run our IndexedDB functions so that each one runs in order, the code in this tutorial is designed to have each IndexedDB function take in a callback function as one of its parameters.

Chaining our IndexedDB functions with callbacks

To see callbacks on our IndexedDB functions, let's take a look at the flow of setupDB:

function setupDB(callback) {
  // If setupDB has already been run and the database was set up, no need to
  // open the database again; just run our callback and return!
  if (db) {
    callback();
    return;
  }

  let dbReq = indexedDB.open('myDatabase', 2);

  // Fires when the version of the database goes up, or the database is created
  // for the first time
  dbReq.onupgradeneeded = function(event) {
    db = event.target.result;

    // Create an object store named notes, or retrieve it if it already exists.
    // Object stores in databases are where data are stored.
    let notes;
    if (!db.objectStoreNames.contains('notes')) {
      db.createObjectStore('notes', {autoIncrement: true});
    }
  }

  // Fires once the database is opened (and onupgradeneeded completes, if
  // onupgradeneeded was called)
  dbReq.onsuccess = function(event) {
    // Set the db variable to our database so we can use it!
    db = event.target.result;
    callback();
  }

  // Fires when we can't open the database
  dbReq.onerror = function(event) {
    alert('error opening database ' + event.target.errorCode);
  }
}

Just like in the last tutorial, this code makes a request to open our database. If the database is being created for the first time, then we run the request's onupgradedneeded event handler to create our object store. Then, based on whether the request succeeds or fails, we either run the request's onsuccess event handler to populate our db global variable, or we alert that there was an error opening the database.

Something to draw your attention to, though, is how we use the callback parameter. There are two places in the code to run the callback:

if (db) {
  callback();
  return;
}
  • If db isn't undefined, then that means setupDB has already been called once and we have our database, so we don't need to do anything to set up our database; we can just run the callback that was passed in.
dbReq.onsuccess = function(event) {
  // Set the db variable to our database so we can use it!
  db = event.target.result;
  callback();
}
  • The other place callback can be called is in our database request's onsuccess event handler, which is called when our database is completely set up.

In both cases, we only call callback once our database is set up. What that does for us is that by having each of our IndexedDB functions take in a callback parameter, we know that when the callback runs, that function's work is completed. We can then see this in action in index.html, where we use that callback parameter to run one IndexedDB function after another:

<script type="text/javascript">
  setupDB(getAndDisplayNotes);
</script>

We run setupDB, and then since we know we now have a db variable set, we can run getAndDisplayNotes as setupDB's callback to display any existing sticky notes in the web app.

So with those callbacks, we have a strategy for our tests to run IndexedDB functions in order, running one database action as the last action's callback. So our test would look like this:

setupDB(function() {
  addStickyNote("SLOTHS", function() {
    addStickyNote("RULE", function() {
      getNotes(reverseOrder=false, function(notes) {
        //
        // Now that we have retrieved our sticky notes, in here we test that
        // we actually got back the sticky notes we expected
        //
      });
    });
  });
});

The callback pyramid is a bit hard to follow, and in a later tutorial I'll show how we can refactor IndexedDB's callback-based API to be promise-based instead, but for now, we've got a way to guarantee that one IndexedDB action happens after the last one, so with that, we've got a way to test our IndexedDB code, so let's dive into the test!

Writing the test

The code changes for this section are in commit 2

The first thing we'll need for our IndexedDB tests is to install a testing framework as one of our project's dependencies. We'll use Jest for this tutorial, but you can use really any testing framework that supports testing asynchronous functions; an IndexedDB test in Mocha + Chai for example would have a similar structure overall to one in Jest.

yarn add --dev jest

Now that we've got our test program, we can make our db.test.js file to run our test in, but we'll need one extra line of code in db.js so that db.test.js can import its functions.

module.exports = {setupDB, addStickyNote, getNotes};

NOTE: This line does mean index.html can no longer use db.js as-is since the browser can't currently recognize module.exports. So for this code to still be used in our web page, we will need a code bundler like webpack. We won't go into depth on how to get that set up, but if you are learning webpack and looking for a step by step webpack tutorial, you can check out my tutorial on it here, and you can check out my code to get this webpack-ready at commit #5.

Now here goes. In db.test.js, add this code:

let {setupDB, addStickyNote, getNotes} = require('./db');

test('we can store and retrieve sticky notes', function(done) {
  setupDB(function() {
    addStickyNote('SLOTHS', function() {
      addStickyNote('RULE!', function() {
        // Now that our sticky notes are both added, we retrieve them from
        // IndexedDB and check that we got them back in the right order.
        getNotes(reverseOrder=false, function(notes) {
          expect(notes).toHaveLength(2);
          expect(notes[0].text).toBe('SLOTHS');
          expect(notes[1].text).toBe('RULE!');
          done();
        });
      });
    });
  });
});

At the beginning of the code, we're importing our code for talking to IndexedDB. Then, we run our test:

test('we can store and retrieve sticky notes', function(done) {

test is the Jest function for running our test case and the function we pass into test is where we run our code and check that it does what we expect it to do.

As you can see, that anonymous function takes in an argument called done, and that's because since we're testing IndexedDB, this is an asynchronous test. In a regular Jest test, the anonymous function doesn't have any arguments, so when that function returns or reaches the closing curly brace, the test is over and Jest can move on to the next text. But in asynchronous tests, when we get to the right brace of the anonymous function, we're still waiting for our IndexedDB code to finish, so we instead call done() when it's time to tell Jest that this test is over.

setupDB(function() {
  addStickyNote('SLOTHS', function() {
    addStickyNote('RULE!', function() {

Inside our anonymous function, we run setupDB, then in its callback, we know that our database is open, so we can add a sticky note that says "SLOTHS" into IndexedDB with addStickyNote, and then add another one after it that says "RULE".

Since each callback is only run after the last IndexedDB action had completed, when we get to getNotes, we already know that our two sticky notes are in the database, so we run getNotes and in its callback, we check that we got back the sticky notes in the right order.

getNotes(reverseOrder=false, function(notes) {
  expect(notes).toHaveLength(2);
  expect(notes[0].text).toBe('SLOTHS');
  expect(notes[1].text).toBe('RULE!');
  done();
});

Inside getNotes's callback, we check that we got back two sticky notes, the first one says "SLOTHS", and the second one says "RULE!" Finally, we call the done() function in our test's anonymous function so we can tell Jest that the test is over.

Run the test with npx jest and...

Test failed; ReferenceError: indexedDB is not defined

Fake-indexeddb to the rescue!

The reason why our test didn't work is because indexedDB is undefined in the global namespace; IndexedDB is a browser API, so does exist in the global namespace in a browser's window object, but in a Node environment, the global object does not have an IndexedDB.

Luckily, there is a JavaScript package that we can use to get a working IndexedDB implementation into our code: fake-indexeddb!

yarn add --dev fake-indexeddb

Fake-indexeddb is a completely in-memory implementation of the IndexedDB spec, and that means we can use it in our tests to use IndexedDB just like we'd use it in a browser. How to we use it, though? Head over to db.test.js and add this code (commit 3):

require("fake-indexeddb/auto");

Then run npx jest again and...

Our test in the command line now passes!

With just one line, IndexedDB is up and running and our test works just as expected! That one import, fake-indexeddb/auto, populates Node.js's global object with an indexeddb variable, as well as types like its IDBKeyRange object for free! 🔥

To test against an actual browser's IndexedDB implementation, to the best of my knowledge you'd need an in-browser testing framework, such as with Selenium, but fake-indexeddb implements the same IndexedDB spec, so that still gives us good mileage for unit tests; real-browser testing is at the end-to-end test level.

Namespacing our tests

Let's add one more test case. getNotes has a reverse-order parameter for getting our notes in reverse order, and testing it has the same structure; open the database, add two sticky notes, then run getNotes, this time with reverseOrder being true.

test('reverse order', function(done) {
  setupDB(function() {
    addStickyNote('REVERSE', function() {
      addStickyNote('IN', function() {
        getNotes(reverseOrder=true, function(notes) {
          expect(notes).toHaveLength(2);
          expect(notes[0].text).toBe('IN');
          expect(notes[1].text).toBe('REVERSE');
          done();
        });
      });
    });
  });
});

However, when we run our tests, we get this error:

Reverse order test fails; expected length 2, received length 4

Our second test failed because our notes object store in the myDatabase IndexedDB database had the sticky notes from the first test. So how can we make sure for each test, we're only working with the database items from our that test case?

What if we were using a different IndexedDB database for each test? The forward-order test could be running code with the notes store for a database named myDatabase_FORWARD, while the reverse-order one would use myDatabase_REVERSE. This technique of running each database test in a database with a different name is called namespacing, and we can namespace our tests with just a couple code changes in setupDB.

let db;
let dbNamespace;

function setupDB(namespace, callback) {
  if (namespace != dbNamespace) {
    db = null;
  }
  dbNamespace = namespace;

  // If setupDB has already been run and the database was set up, no need to
  // open the database again; just run our callback and return!
  if (db) {
    callback();
    return;
  }

We add a new global variable to db.js, dbNamespace, which is the namespace for the IndexedDB database we are currently using. Then, in setupDB, we have a new parameter, namespace; if we use a namespace different from what dbNamespace was already set to, then we set db to null so we will have to open a new IndexedDB database (commit 4).

let dbName = namespace == '' ? 'myDatabase' : 'myDatabase_${namespace}';
let dbReq = indexedDB.open(dbName, 2);

Now, we pick the name of the database we want to open based on what we passed into namespace; if we pass in a non-blank string as our namespace, such as REVERSE_TEST, then we are opeining the database myDatabase_REVERSE_TEST, so if each test uses a different namespace, we won't have to worry about leftover database items from the last test.

Now, our forward getNotes test will start like this:

test('we can store and retrieve sticky notes', function(done) {
  setupDB('FORWARD_TEST', function() {

Our reverse test looks like:

test('reverse order', function(done) {
  setupDB('REVERSE_TEST', function() {

And finally, in our web app, we set up the database with no namespace by running:

setupDB('', getAndDisplayNotes);

With both of our test cases now using databases with different namespaces, one test case doesn't interfere with another, so run npx jest and you will see...

Both tests now pass in the command line

A PASSING TEST!

Lola the Micropanda smiling about the test passing

We've given our web app test coverage for a couple test cases in Jest using callbacks, and with namespacing in the setupDB function, we've got a sustainable way to keep our tests from colliding with each other if we kept on adding features to the app. However, there is still one issue with the codebase, all these pyramids of callbacks can be tough to reason about in our code. So for my next tutorial, we're going to look into how we can take our callback-based IndexedDB code and turn it into promise/async/await-based IndexedDB code. Until next time,

Three-toed sloth climbing a tree

STAY SLOTHFUL!

Posted on by:

andyhaskell profile

&y H. Golang (he/him)

@andyhaskell

Software engineer at Salesforce (prev MIT), Google Developer Expert in Go, organizer at Boston Golang, resident #sloth enthusiast at everywhere

Discussion

markdown guide