详解Jest 如何支持异步及时间函数实现示例
目录
- 异步支持
- 回调函数 callback
- promise
- Mock Timer
- 基本使用
- 模拟时钟的机制
- 典型案例
- 问题分析
- 解决方法
- 总结
异步支持
在前端开发中,我们会遇到很多异步代码,那么就需要测试框架对异步必须支持,那如何支持呢?
Jest 支持异步有两种方式:回调函数及 promise(async/await)
。
回调函数 callback
const fetchUser = (cb) => { setTimeout(() => { cb('hello') }, 100) } // 必须要使用done,done表示执行done函数后,测试结束。如果没有done,同步代码执行完后,测试就执行完了,测试不会等待异步代码。 test('test callback', (done) => { fetchUser((data) => { expect(data).toBe('hello') done() }) })
需要注意的是,必须使用 done 来告诉测试用例什么时候结束,即执行 done() 之后测试用例才结束。
promise
const userPromise = () => Promise.resolve('hello') test('test promise', () => { // 必须要用return返回出去,否则测试会提早结束,也不会进入到异步代码里面进行测试 return userPromise().then(data => { expect(data).toBe('hello') }) }) // async test('test async', async () => { const data = await userPromise() expect(data).toBe('hello') })
针对 promise,Jest 框架提供了一种简化的写法,即 expect 的resolves
和rejects
表示返回的结果:
const userPromise = () => Promise.resolve('hello') test('test with resolve', () => { return expect(userPromise()).resolves.toBe('hello') }) const rejectPromise = () => Promise.reject('error') test('test with reject', () => { return expect(rejectPromise()).rejects.toBe('error') })
Mock Timer
基本使用
假如现在有一个函数 src/utils/after1000ms.ts
,它的作用是在 1000ms 后执行传入的 callback
:
const after1000ms = (callback) => { console.log("准备计时"); setTimeout(() => { console.log("午时已到"); callback && callback(); }, 1000); };
如果不 Mock 时间,那么我们就得写这样的用例:
describe("after1000ms", () => { it("可以在 1000ms 后自动执行函数", (done) => { after1000ms(() => { expect(...); done(); }); }); });
这样我们得死等 1000 毫秒才能跑这完这个用例,这非常不合理,现在来看看官方的解决方法:
const fetchUser = (cb) => { setTimeout(() => { cb('hello') }, 1000) } // jest用来接管所有的时间函数 jest.useFakeTimers() jest.spyOn(global, 'setTimeout') test('test callback after one second', () => { const callback = jest.fn() fetchUser(callback) expect(callback).not.toHaveBeenCalled() // setTimeout被调用了,因为被jest接管了 expect(setTimeout).toHaveBeenCalledTimes(1) expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 1000) // 跑完所有的时间函数 jest.runAllTimers() expect(callback).toHaveBeenCalled() expect(callback).toHaveBeenCalledWith('hello') })
runAllTimers是对所有的timer的进行执行,但是我们如果需要更细粒度的控制,可以使用 runOnlyPendingTimers
:
const loopFetchUser = (cb: any) => { setTimeout(() => { cb('one') setTimeout(() => { cb('two') }, 2000) }, 1000) } jest.useFakeTimers() jest.spyOn(global, 'setTimeout') test('test callback in loop', () => { const callback = jest.fn() loopFetchUser(callback) expect(callback).not.toHaveBeenCalled() // jest.runAllTimers() // expect(callback).toHaveBeenCalledTimes(2) // 第一次时间函数调用完的时机 jest.runOnlyPendingTimers() expect(callback).toHaveBeenCalledTimes(1) expect(callback).toHaveBeenCalledWith('one') // 第二次时间函数调用 jest.runOnlyPendingTimers() expect(callback).toHaveBeenCalledTimes(2) expect(callback).toHaveBeenCalledWith('two') })
我们还可以定义时间来控制程序的运行:
// 可以自己定义时间的前进,比如时间过去500ms后,函数调用情况 test('test callback with advance timer', () => { const callback = jest.fn() loopFetchUser(callback) expect(callback).not.toHaveBeenCalled() jest.advanceTimersByTime(500) jest.advanceTimersByTime(500) expect(callback).toHaveBeenCalledTimes(1) expect(callback).toHaveBeenCalledWith('one') jest.advanceTimersByTime(2000) expect(callback).toHaveBeenCalledTimes(2) expect(callback).toHaveBeenCalledWith('two') })
模拟时钟的机制
Jest 是如何模拟 setTimeout
等时间函数的呢?
我们从上面这个用例多少能猜得出:Jest "好像" 用了一个数组记录 callback
,然后在 jest.runAllTimers
时把数组里的 callback
都执行, 伪代码可能是这样的:
setTimeout(callback) // Mock 的背后 -> callbackList.push(callback) jest.runAllTimers() // 执行 -> callbackList.forEach(callback => callback())
可是话说回来,setTimeout
本质上不也是用一个 "小本本" 记录这些 callback
,然后在 1000ms
后执行的么?
那么,我们可以提出这样一个猜想:调用 jest.useFakeTimers
时,setTimeout
并没有把 callback
记录到 setTimeout
的 "小本本" 上,而是记在了 Jest 的 "小本本" 上!
所以,callback
执行的时机也从 "1000ms
后" 变成了 Jest 执行 "小本本" 之时 。而 Jest 提供给我们的就是执行这个 "小本本" 的时机就是执行runAllTimers
的时机。
典型案例
学过 Java 的同学都知道 Java 有一个 sleep
方法,可以让程序睡上个几秒再继续做别的。虽然 JavaScript 没有这个函数, 但我们可以利用 Promise
以及 setTimeout
来实现类似的效果。
const sleep = (ms: number) => { return new Promise(resolve => { setTimeout(resolve, ms); }) }
理论上,我们会这么用:
console.log('开始'); // 准备 await sleep(1000); // 睡 1 秒 console.log('结束'); // 睡醒
在写测试时,我们可以写一个 act
内部函数来构造这样的使用场景:
import sleep from "utils/sleep"; describe('sleep', () => { beforeAll(() => { jest.useFakeTimers() jest.spyOn(global, 'setTimeout') }) it('可以睡眠 1000ms', async () => { const callback = jest.fn(); const act = async () => { await sleep(1000) callback(); } act() expect(callback).not.toHaveBeenCalled(); jest.runAllTimers(); expect(callback).toHaveBeenCalledTimes(1); }) })
上面的用例很简单:在 "快进时间" 之前检查 callback
没有被调用,调用 jest.runAllTimers
后,理论上 callback
会被执行一次。
然而,当我们跑这个用例时会发现最后一行的 expect(callback).toHaveBeenCalledTimes(1);
会报错,发现根本没有调用,调用次数为0:
问题分析
这就涉及到 javascript 的事件循环机制了。
首先来复习下 async / await
, 它是 Promise
的语法糖,async
会返回一个 Promise
,而 await
则会把剩下的代码包裹在 then
的回调里,比如:
await hello() console.log(1) // 等同于 hello().then(() => { console.log(1) })
重点:await后面的代码相当于放在promise.then的回调中
这里用了 useFakeTimers
,所以 setTimeout
会替换成了 Jest 的 setTimeout
(被 Jest 接管)。当执行 jest.runAllTimers()
后,也就是执行resolve
:
const sleep = (ms: number) => { return new Promise(resolve => { setTimeout(resolve, ms); }) }
此时会把 await
后面的代码推入到微任务队列中。
然后继续执行本次宏任务中的代码,即expect(callback).toHaveBeenCalledTimes(1)
,这时候callback
肯定没有执行。本次宏任务执行完后,开始执行微任务队列中的任务,即执行callback
。
解决方法
describe('sleep', () => { beforeAll(() => { jest.useFakeTimers() jest.spyOn(global, 'setTimeout') }) it('可以睡眠 1000ms', async () => { const callback = jest.fn() const act = async () => { await sleep(1000) callback() } const promise = act() expect(callback).not.toHaveBeenCalled() jest.runAllTimers() await promise expect(callback).toHaveBeenCalledTimes(1) }) })
async
函数会返回一个promise
,我们在promise
前面加一个await
,那么后面的代码就相当于:
await promise expect(callback).toHaveBeenCalledTimes(1) 等价于 promise.then(() => { expect(callback).toHaveBeenCalledTimes(1) })
所以,这个时候就能正确的测试。
总结
Jest 对于异步的支持有两种方式:回调函数和promise
。其中回调函数执行后,后面必须执行done
函数,表示此时测试才结束。同理,promise
的方式必须要通过return
返回。
Jest 对时间函数的支持是接管真正的时间函数,把回调函数添加到一个数组中,当调用runAllTimers()
时就执行数组中的回调函数。
最后通过一个典型案例,结合异步和setTimeout
来实践真实的测试。
以上就是详解Jest 如何支持异步及时间函数实现示例的详细内容,更多关于Jest 支持异步时间函数的资料请关注我们其它相关文章!