How to Test LocalStorage with Vitest

LocalStorage is one of the key-value Storage objects available in the browser. We can use it as a cache or even build a whole app on top of it. In this tutorial, we will learn how to test it with Vitest when writing unit tests.

As a prerequisite, use jsdom or happy-dom as the test environment in vite.config.js so that we have access to a window object:

import { defineConfig } from 'vite'

export default defineConfig({
  test: {
    environment: 'jsdom',
  },
})

Example Usage

As an example, we will develop unit tests for a service that stores todos in LocalStorage:

const TODOS_KEY = 'todos'

export const getTodos = () => JSON.parse(localStorage.getItem(TODOS_KEY) || '[]')

export const addTodo = (todo) => {
  const todos = getTodos()

  todos.push(todo)

  localStorage.setItem(TODOS_KEY, JSON.stringify(todos))
}

The addTodo function will get the current Todos and add an item, then stores the updated list again, overriding the current one.

And the getTodos function returns the list from LocalStorage. If the value is null(nothing is saved), it returns an empty array.

Since LocalStorage saves the values in UTF-16 string format, it doesn’t understand JS Objects and Arrays. Hence, we have to use JSON.parse and JSON.stringify.

Let’s now write the unit tests for these two functions!

Test LocalStorage getItem

We will start with the getTodos function. We will test two things here. First, the result returned after calling it. Second, the getItems storage method has been called.

Let’s get started with the first assertion. Here is how the test should look like

import { describe, expect, test, vi } from 'vitest'
import { getTodos } from './todos.service'

const TODOS_KEY = 'todos'

describe('Todos Service', () => {
  describe('getTodos', () => {
    test('gets todos from LocalStorage', () => {
      const todo = {
        id: 1,
        text: 'Write unit tests',
      }

      expect(getTodos()).toStrictEqual([todo])

    })
  })
})

Currently, if we run this we will see that it fails, that’s because the storage is empty.

failing initial test
oops!

Let’s set some data then to fix this:

import { describe, expect, test } from 'vitest'
import { getTodos } from './todos.service'

const TODOS_KEY = 'todos'

describe('Todos Service', () => {
  describe('getTodos', () => {
    test('gets todos from LocalStorage', () => {
      const todo = {
        id: 1,
        text: 'Write unit tests',
      }

      localStorage.setItem(TODOS_KEY, JSON.stringify([todo]))

      expect(getTodos()).toStrictEqual([todo])
    })
  })
})
localstorage vitest Initial expect passing

Now it works!

Finally, since we have added data, and since we have a single storage instance, we have to clear it. Because if another test relies on data being present to pass, this test will be influencing that. Unit tests should be isolated, and shouldn’t rely on each other for data, that way they can be stable.

Now here is an example of the issue:

import { describe, expect, test } from 'vitest'
import { getTodos } from './todos.service'

const TODOS_KEY = 'todos'

describe('Todos Service', () => {
  describe('getTodos', () => {
    test('gets todos from LocalStorage', () => {
      const todo = {
        id: 1,
        text: 'Write unit tests',
      }

      localStorage.setItem(TODOS_KEY, JSON.stringify([todo]))

      expect(getTodos()).toStrictEqual([todo])
    })

    test('gets todos from LocalStorage without setting them :o', () => {
      const todo = {
        id: 1,
        text: 'Write unit tests',
      }

      expect(getTodos()).toStrictEqual([todo])
    })
  })
})
false positive?
hmmmmmmm

Convinced? Nice, then use afterEach to clear it by calling localStorage.clear:

import { afterEach, describe, expect, test } from 'vitest'
import { getTodos } from './todos.service'

const TODOS_KEY = 'todos'

describe('Todos Service', () => {
  afterEach(() => {
    localStorage.clear()
  })

  describe('getTodos', () => {
    test('gets todos from LocalStorage', () => {
      const todo = {
        id: 1,
        text: 'Write unit tests',
      }

      localStorage.setItem(TODOS_KEY, JSON.stringify([todo]))

      expect(getTodos()).toStrictEqual([todo])
    })
  })
})

We have covered the first expectation, which is asserting the value. But do we really know that it’s coming from LocalStorage?

We can assert that by checking if the getItem has been called. For this, we need a spy!

vitest spyOn
Image by wayhomestudio

First, spy on the method, so we can track its call history by calling vi.spyOn on the said method:

import { afterEach, describe, expect, test, vi } from 'vitest'
import { getTodos } from './todos.service'

const TODOS_KEY = 'todos'

describe('Todos Service', () => {

  // assign the spy instance to a const
  const getItemSpy = vi.spyOn(Storage.prototype, 'getItem')
 
  // with happy dom we can do:
  // const getItemSpy = vi.spyOn(localStorage, 'getItem')

  afterEach(() => {
    getItemSpy.mockClear() // clear call history
    localStorage.clear()
  })

  describe('getTodos', () => {
    test('gets todos from LocalStorage', () => {
      const todo = {
        id: 1,
        text: 'Write unit tests',
      }

      localStorage.setItem(TODOS_KEY, JSON.stringify([todo]))

      expect(getTodos()).toStrictEqual([todo])
    })
  })
})

Notice that we are latching the spy on Storage.prototype and not localStorage itself. It’s because of an issue with jsdom. To overcome this limitation use happy-dom or spy on the Prototype itself. Moreover, we are clearing the spy so that in each test we don’t have any call history.

Afterward, add another expect and use toHaveBeenCalledWith:

import { afterEach, describe, expect, test, vi } from 'vitest'
import { getTodos } from './todos.service'

const TODOS_KEY = 'todos'

describe('Todos Service', () => {
  const getItemSpy = vi.spyOn(Storage.prototype, 'getItem')

  afterEach(() => {
    localStorage.clear()
  })

  describe('getTodos', () => {
    test('gets todos from LocalStorage', () => {
      const todo = {
        id: 1,
        text: 'Write unit tests',
      }

      localStorage.setItem(TODOS_KEY, JSON.stringify([todo]))

      expect(getTodos()).toStrictEqual([todo])
      expect(getItemSpy).toHaveBeenCalledWith(TODOS_KEY)
    })
  })
})

We use toHaveBeenCalledWith to assert that we are using the correct key 'todos' for retrieving the list.

Test LocalStorage setItem

To test LocalStorage setItem we follow the same pattern. write the test, we can use getTodos to check if the todo was added or not. Then add the spy to check that setItems was called with the correct parameters. Finally, clear the spy:

import { afterEach, describe, expect, test, vi } from 'vitest'
import { addTodo, getTodos } from './todos.service'

const TODOS_KEY = 'todos'

describe('Todos Service', () => {
  const getItemSpy = vi.spyOn(Storage.prototype, 'getItem')
  const setItemSpy = vi.spyOn(Storage.prototype, 'setItem')

  afterEach(() => {
    localStorage.clear()
    getItemSpy.mockClear()
    setItemSpy.mockClear()
  })

  describe('getTodos', () => {
    test('gets todos from LocalStorage', () => {
      const todo = {
        id: 1,
        text: 'Write unit tests',
      }

      localStorage.setItem(TODOS_KEY, JSON.stringify([todo]))

      expect(getTodos()).toStrictEqual([todo])
      expect(getItemSpy).toHaveBeenCalledWith(TODOS_KEY)
    })
  })

  describe('addTodo', () => {
    test('adds new todo', () => {
      const todo = {
        id: 2,
        text: 'Write more unit tests',
      }

      addTodo(todo)

      expect(setItemSpy).toHaveBeenCalledWith(TODOS_KEY, JSON.stringify([todo]))
      expect(getTodos()).toStrictEqual([todo])
    })
  })
})
localStorage tests passing yaay!
Done 😎

Conclusion

Now you have become a LocalStorage Ninja, and also you learned about spying, wow, goes well together!

I hope you have benefited from the Tutorial. Make sure to follow me on LinkedIn and/or Twitteur where I share coding nuggets!

Amenallah Hsoumi
Amenallah Hsoumi

Senior Software Engineer, Indie Hacker, building cool stuff on the web!

Articles: 19