使用 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()
}
}
这听起来很简单……但其中却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)
...
}
然后,我们的测试文件可以传递一些我们可以使用的有趣的模拟函数:
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')
})
这还不错。现在我们可以检查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()
})
瞧瞧中间那块肉!这里面蕴含着几个重要的信息:
- Jest 提供了一个非常方便的
global对象供你使用。更具体地说,Jest 让你能够开箱即用地访问JSDOMglobal,它(Node 的标准组件)包含丰富的 API。正如我们所发现的,它也包含了我们最喜欢的浏览器 API! - 我们可以使用 `
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')
})
既然我们已经模拟了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 []
}
}
看起来很简单!我们只是确保孜然、辣椒粉和辣椒粉能够被正确取用,并最终混合成各种辣椒香料🌶
正如你所预料的,我们采用了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])
})
还不错!你可能需要花点时间来处理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([])
})
而且因为我们只模拟一次实现,所以不用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()
})
总的来说,这与我们最初的方法几乎完全相同。唯一的区别在于语义;我们不再为这些全局函数赋予新的行为= 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