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.
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])
})
})
})
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])
})
})
})
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!
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])
})
})
})
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!
Trending now