Mocking browser APIs (fetch, localStorage, Dates...) the easy way with Jest

Ben Holmes - Jan 19 '21 - - Dev Community

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 the global 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()
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
        ...
}
Enter fullscreen mode Exit fullscreen mode

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')
})
Enter fullscreen mode Exit fullscreen mode

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()
})
Enter fullscreen mode Exit fullscreen mode

Oh, take a look at that meaty piece in the middle! There's some big takeaways from this:

  1. Jest gives you a nice global object to work with. More specifically, Jest gives you access to JSDOM out-of-the-box, which populates global (a standard in Node) with a treasure trove of APIs. As we've discovered, it includes our favorite browser APIs as well!
  2. We can use the prototype to mock functions inside a JS class. You're right to wonder why we need to mock Storage.prototype, rather than mocking localStorage 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 our jest.fn approach. But don't fret! You can mock the entire localStorage class as well if this prototype madness makes you feel uneasy 😁 Fair warning though: it's a bit harder to test whether class methods were called with toHaveBeenCalled 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')
})
Enter fullscreen mode Exit fullscreen mode

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 []
  }
}
Enter fullscreen mode Exit fullscreen mode

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])
})
Enter fullscreen mode Exit fullscreen mode

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([])
  })
Enter fullscreen mode Exit fullscreen mode

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()
})
Enter fullscreen mode Exit fullscreen mode

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 ❤️

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player