发布于 2026-01-06 0 阅读
0

使用 Jest 轻松模拟浏览器 API(fetch、localStorage、Dates 等)

使用 Jest 轻松模拟浏览器 API(fetch、localStorage、Dates 等)

最近我在测试一个localStorage用 React 编写的辅助函数时遇到了一些问题。弄清楚如何测试所有状态和渲染更改当然很容易(一如既往地感谢React Testing Library 🐐)。

但很快,我就开始思考……有没有什么简单的方法可以“模拟”浏览器 API,比如存储?或者更确切地说,我应该如何测试使用 X API 的任何函数?

希望你饿了!我们要去探索一番。

  • 🚅 为什么依赖注入并非万能灵药
  • 📦 如何localStorage使用global对象进行模拟
  • 📶 进一步模拟fetchAPI 的方法
  • 🔎 另一种使用方法jest.spyOn

前进!

我们先吃点东西吧。

这是一个简单(且美味)的函数示例,值得测试:

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

这听起来很简单……但其中却localStorage暗藏着一些疯狂之处。我们或许可以先采用“一股脑儿全注入”的策略(TM)来解决这个问题:

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

然后,我们的测试文件可以传递一些我们可以使用的有趣的模拟函数:

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

这还不错现在我们可以检查localStorage函数是否被调用,并验证我们发送的值是否正确。

不过,这里还是有点问题:我们为了写出更简洁的测试而重构了代码!不知道你们怎么想,反正我总觉得把函数内部实现移到一组参数里有点不妥。而且,如果几年后单元测试迁移或者重写怎么办?这样一来,我们又得把这个奇怪的设计留给下一个开发者了😕

📦 如果我们可以直接模拟浏览器存储呢?

没错,模拟我们自己编写的模块函数确实很困难。但模拟原生 API 却出奇地简单!让我来搅动一下局面🥘

// 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

瞧瞧中间那块肉!这里面蕴含着几个重要的信息:

  1. Jest 提供了一个非常方便的global对象供你使用。更具体地说,Jest 让你能够开箱即用地访问JSDOMglobal ,它(Node 的标准组件)包含丰富的 API。正如我们所发现的,它也包含了我们最喜欢的浏览器 API!
  2. 我们可以使用 ` prototypemock` 来模拟 JS 类中的函数。你可能会好奇为什么我们需要模拟 `mock`Storage.prototype而不是localStorage直接模拟 `Storage` 类,这是正确的。简而言之:`mock`localStorage实际上是`Storage` 类的一个实例遗憾的是,模拟类实例上的方法(例如 `mock` localStorage.getItem)在我们的方法中行不通jest.fn。不过别担心!如果你觉得这种方法不太合适,你也可以模拟整个localStorage😁 不过需要提醒的是:与普通的 `mock` 相比prototype,使用 `mock` 来测试类方法是否被调用会稍微困难一些toHaveBeenCalledjest.fn

💡注意:此策略将使用同一组函数localStorage同时模拟两者sessionStorage。如果您需要分别模拟它们,则可能需要拆分测试套件,或者像之前建议的那样模拟存储类

现在,我们可以测试我们最初的无注入函数了!

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

既然我们已经模拟了global值,几乎不需要任何设置。只需记住清理一下那个模块的厨房afterAll一切就绪👍

📶 那么我们还能嘲讽什么呢?

既然我们已经开始使用起酥油了,那就让我们尝试一些其他功能global。API非常适合用来做这件事fetch

// 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

看起来很简单!我们只是确保孜然、辣椒粉和辣椒粉能够被正确取用,并最终混合成各种辣椒香料🌶

正如你所预料的,我们采用了global与之前相同的策略:

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

还不错!你可能需要花点时间来处理Promise我们模拟的这个双重嵌套(记住,fetch它会返回另一个Promise 来表示json结果!)。不过,我们的测试在全面测试函数的同时,仍然保持了相当简洁的风格。

你还会注意到我们mockImplementationOnce在这里使用了……。当然,我们可以使用beforeAll之前相同的技术,但我们可能需要模拟不同的实现方式,fetch以便在遇到错误情况时进行参考。以下是模拟示例:

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

而且因为我们只模拟一次实现,所以不用afterAll担心后续的清理工作!用完碗碟就赶紧洗干净,这可是明智之举🧽

🔎 附录:使用“间谍”

在结束之前,我想指出另一种方法:global使用Jest spies进行模拟。

让我们重构一下localStorage之前的例子:

...
// 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

总的来说,这与我们最初的方法几乎完全相同。唯一的区别在于语义;我们不再为这些全局函数赋予新的行为= jest.fn()(例如),而是拦截对这些函数的请求并使用我们自己的实现。

对某些人来说,这可能感觉更“安全”一些,因为我们不再显式地覆盖这些函数的行为。但只要你注意代码afterAll块中的清理工作,两种方法都是有效的😁

学到点东西?

太棒了!如果你错过了,我推出了我的“网络魔法”简报,里面会分享更多类似的知识宝典!

这篇文章探讨了Web 开发的“基本原理”。换句话说,它揭示了那些让所有 Web 项目得以运行的浏览器 API、CSS 规则和半可访问的 HTML 代码背后的原理。如果你想超越框架的局限,那么这篇文章就是为你准备的,亲爱的 Web 魔法师🔮

点击这里订阅哦!我保证只发布教学内容,绝不发送垃圾信息❤️

文章来源:https://dev.to/bholmesdev/mocking-browser-apis-fetch-localstorage-dates-the-easy-way-with-jest-4kph