异步支持

在前端开发中,我们会遇到很多异步代码,那么就需要测试框架对异步必须支持,那如何支持呢?
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')})})// asynctest('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 promiseexpect(callback).toHaveBeenCalledTimes(1)})})

async函数会返回一个promise,我们在promise前面加一个await,那么后面的代码就相当于:

await promiseexpect(callback).toHaveBeenCalledTimes(1)等价于promise.then(() => {expect(callback).toHaveBeenCalledTimes(1)})

所以,这个时候就能正确的测试。

总结

Jest 对于异步的支持有两种方式:回调函数和promise。其中回调函数执行后,后面必须执行done函数,表示此时测试才结束。同理,promise的方式必须要通过return返回。

Jest 对时间函数的支持是接管真正的时间函数,把回调函数添加到一个数组中,当调用runAllTimers()时就执行数组中的回调函数。

最后通过一个典型案例,结合异步和setTimeout来实践真实的测试。

现在我邀请你进入我们的软件测试学习交流群:746506216】,备注“入群”, 大家可以一起探讨交流软件测试,共同学习软件测试技术、面试等软件测试方方面面,还会有免费直播课,收获更多测试技巧,我们一起进阶Python自动化测试/测试开发,走向高薪之路。

喜欢软件测试的小伙伴们,如果我的博客对你有帮助、如果你喜欢我的博客内容,请 “点赞” “评论” “收藏” 一 键三连哦!