I hit a snag recently trying to test a localStorage
helper written in React. Figuring out how to test all my state and render changes was certainly the easy part (thanks as always React Testing Library ๐).
But soon, I found myself wondering... is there an easy way to "mock" a browser API like storage? Or better yet, how should I test any function using X API?
Well, hope you're hungry! We're gonna explore
- ๐ Why dependency injection doesn't feel like a silver bullet
- ๐ฆ How we can mock
localStorage
using theglobal
object - ๐ถ Ways to go further mocking the
fetch
API - ๐ An alternative approach using
jest.spyOn
Onwards!
Let's grab something to eat first
Here's a simple (and tasty) example of a function worth testing:
function saveForLater(leftoverChili) {
try {
const whatsInTheFridge = localStorage.getItem('mealPrepOfTheWeek')
if (whatsInTheFridge === undefined) {
// if our fridge is empty, chili's on the menu ๐ถ
localStorage.setItem('mealPrepOfTheWeek', leftoverChili)
} else {
// otherwise, we'll just bring it to our neighbor's potluck ๐ฝ
goToPotluck(leftoverChili)
}
} catch {
// if something went wrong, we're going to the potluck empty-handed ๐ฌ
goToPotluck()
}
}
This is pretty straightforward... but it's got some localStorage
madness baked-in. We could probably start with the inject all the things strategy (TM) to address this:
function saveForLater(leftoverChili, {
// treat our storage calls as parameters to the function,
// with the default value set to our desired behavior
getFromStorage = localStorage.getItem('mealPrepOfTheWeek'),
setInStorage = (food) => localStorage.setItem('mealPrepOfTheWeek', food)
}) => {
try {
// then, sub these values into our function
const whatsInTheFridge = getFromStorage()
...
setInStorage(leftoverChili)
...
}
Then, our test file can pass some tasty mock functions we can work with:
it('puts the chili in the fridge when the fridge is empty', () => {
// just make some dummy functions, where the getter returns undefined
const getFromStorage = jest.fn().mockReturnValueOnce(undefined)
// then, make a mock storage object to check
// whether the chili was put in the fridge
let mockStorage
const setInStorage = jest.fn((value) => { mockStorage = value })
saveForLater('chili', { getFromStorage, setInStorage })
expect(setInStorage).toHaveBeenCalledOnce()
expect(mockFridge).toEqual('chili')
})
This isn't too bad. Now we can check whether our localStorage
functions get called, and verify that we're sending the right values.
Still, there's something a bit ugly here: we just restructured our code for the sake of cleaner tests! I don't know about you, but I feel a little uneasy moving the internals of my function to a set of parameters. And what if the unit tests move away or get rewritten years down the line? That leaves us with another odd design choice to pass onto the next developer ๐
๐ฆ What if we could mock browser storage directly?
Sure, mocking module functions we wrote ourselves is pretty tough. But mocking native APIs is surprisingly straightforward! Let me stir the pot a little ๐ฅ
// let's make a mock fridge (storage) for all our tests to use
let mockFridge = {}
beforeAll(() => {
global.Storage.prototype.setItem = jest.fn((key, value) => {
mockFridge[key] = value
})
global.Storage.prototype.getItem = jest.fn((key) => mockFridge[key])
})
beforeEach(() => {
// make sure the fridge starts out empty for each test
mockFridge = {}
})
afterAll(() => {
// return our mocks to their original values
// ๐จ THIS IS VERY IMPORTANT to avoid polluting future tests!
global.Storage.prototype.setItem.mockReset()
global.Storage.prototype.getItem.mockReset()
})
Oh, take a look at that meaty piece in the middle! There's some big takeaways from this:
-
Jest gives you a nice
global
object to work with. More specifically, Jest gives you access to JSDOM out-of-the-box, which populatesglobal
(a standard in Node) with a treasure trove of APIs. As we've discovered, it includes our favorite browser APIs as well! -
We can use the
prototype
to mock functions inside a JS class. You're right to wonder why we need to mockStorage.prototype
, rather than mockinglocalStorage
directly. In short:localStorage
is actually an instance of a Storage class. Sadly, mocking methods on a class instance (i.e.localStorage.getItem
) doesn't work with ourjest.fn
approach. But don't fret! You can mock the entirelocalStorage
class as well if thisprototype
madness makes you feel uneasy ๐ Fair warning though: it's a bit harder to test whether class methods were called withtoHaveBeenCalled
compared to a plan ole'jest.fn
.
๐ก Note: This strategy will mock both localStorage
and sessionStorage
with the same set of functions. If you need to mock these independently, you might need to split up your test suites or mock the storage class as previously suggested.
Now, we're good to test our original function injection-free!
it('puts the chili in the fridge when the fridge is empty', () => {
saveForLater('chili')
expect(global.Storage.prototoype.setItem).toHaveBeenCalledOnce()
expect(mockStorage['mealPrepOfTheWeek']).toEqual('chili')
})
There's almost no setup to speak of now that we're mocking global
values. Just remember to clean the kitchen in that afterAll
block, and we're good to go ๐
๐ถ So what else could we mock?
Now that we're cooking with crisco, let's try our hand at some more global
functions. The fetch
API is a great candidate for this:
// let's fetch some ingredients from the store
async function grabSomeIngredients() {
try {
const res = await fetch('https://wholefoods.com/overpriced-organic-spices')
const { cumin, paprika, chiliPowder } = await res.json()
return [cumin, paprika, chiliPowder]
} catch {
return []
}
}
Seems simple enough! We're just making sure that the Cumin, Paprika, and Chili Powder get fetched and returned in an array of chili spices ๐ถ
As you might expect, we're using the same global
strategy as before:
it('fetches the right ingredients', async () => {
const cumin = 'McCormick ground cumin'
const paprika = 'Smoked paprika'
const chiliPowder = 'Spice Islands Chili Powder'
let spices = { cumin, paprika, chiliPowder, garlicSalt: 'Yuck. Fresh garlic only!' }
global.fetch = jest.fn().mockImplementationOnce(
() => new Promise((resolve) => {
resolve({
// first, mock the "json" function containing our result
json: () => new Promise((resolve) => {
// then, resolve this second promise with our spices
resolve(spices)
}),
})
})
)
const res = await grabSomeIngredients()
expect(res).toEqual([cumin, paprika, chiliPowder])
})
Not too bad! You'll probably need a second to process that doubly-nested Promise
we're mocking (remember, fetch
returns another promise for the json
result!). Still, our test stayed pretty lean while thoroughly testing our function.
You'll also notice that we used mockImplementationOnce
here. Sure, we could have used the same beforeAll
technique as before, but we probably want to mock different implementations for fetch
once we get into the error scenarios. Here's how that might look:
it('returns an empty array on bad fetch', async () => {
global.fetch = jest.fn().mockImplementationOnce(
() => new Promise((_, reject) => {
reject(404)
})
)
const res = await fetchSomething()
// if our fetch fails, we don't get any spices!
expect(res).toEqual([])
})
it('returns an empty array on bad json format', async () => {
global.fetch = jest.fn().mockImplementationOnce(
() => new Promise((resolve) => {
resolve({
json: () => new Promise((_, reject) => reject(error)),
})
})
)
const res = await fetchSomething()
expect(res).toEqual([])
})
And since we're mocking implementation once, there's no afterAll
cleanup to worry about! Pays to clean your dishes as soon as you're done with them ๐งฝ
๐ Addendum: using "spies"
Before wrapping up, I want to point out an alternative approach: mocking global
using Jest spies.
Let's refactor our localStorage
example from before:
...
// first, we'll need to make some variables to hold onto our spies
// we'll use these for clean-up later
let setItemSpy, getItemSpy
beforeAll(() => {
// previously: global.Storage.prototype.setItem = jest.fn(...)
setItemSpy = jest
.spyOn(global.Storage.prototype, 'setItem')
.mockImplementation((key, value) => {
mockStorage[key] = value
})
// previously: global.Storage.prototype.getItem = jest.fn(...)
getItemSpy = jest
.spyOn(global.Storage.prototype, 'getItem')
.mockImplementation((key) => mockStorage[key])
})
afterAll(() => {
// then, detach our spies to avoid breaking other test suites
getItemSpy.mockRestore()
setItemSpy.mockRestore()
})
Overall, this is pretty much identical to our original approach. The only difference is in the semantics; instead of assigning new behavior to these global functions (i.e. = jest.fn()
), we're intercepting requests to these functions and using our own implementation.
This might feel a little "safer" to some people, since we're not explicitly overwriting the behavior of these functions anymore. But as long as you pay attention to your cleanup in the afterAll
block, either approach is valid ๐
Learn a little something?
Awesome. In case you missed it, I launched an my "web wizardry" newsletter to explore more knowledge nuggets like this!
This thing tackles the "first principles" of web development. In other words, what are all the janky browser APIs, bent CSS rules, and semi-accessible HTML that make all our web projects tick? If you're looking to go beyond the framework, this one's for you dear web sorcerer ๐ฎ
Subscribe away right here. I promise to always teach and never spam โค๏ธ
Top comments (2)
Doesn't Jest automatically use JSDOM which has support for stuff like
localStorage
?Ah, right you are! I was confused reading up on Node's
global
object; Turns out JSDOM is the culprit for all these APIs being visible. I'll update that callout!