异步支持
在前端开发中,我们会遇到很多异步代码,那么就需要测试框架对异步必须支持,那如何支持呢?
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来实践真实的测试。
现在我邀请你进入我们的软件测试学习交流群:【
746506216
】,备注“入群”, 大家可以一起探讨交流软件测试,共同学习软件测试技术、面试等软件测试方方面面,还会有免费直播课,收获更多测试技巧,我们一起进阶Python自动化测试/测试开发,走向高薪之路。
喜欢软件测试的小伙伴们,如果我的博客对你有帮助、如果你喜欢我的博客内容,请 “点赞” “评论” “收藏” 一 键三连哦!