定时器和承诺不能很好地工作

对这个密码有什么想法吗

jest.useFakeTimers()


it('simpleTimer', async () => {
async function simpleTimer(callback) {
await callback()    // LINE-A without await here, test works as expected.
setTimeout(() => {
simpleTimer(callback)
}, 1000)
}


const callback = jest.fn()
await simpleTimer(callback)
jest.advanceTimersByTime(8000)
expect(callback).toHaveBeenCalledTimes(9)
}

```

失败了

Expected mock function to have been called nine times, but it was called two times.

但是,如果我从 LINE-A 中删除 await,测试就会通过。

承诺和计时器是否不能很好地工作?

我觉得开玩笑的原因可能是等待第二个承诺来解决。

46855 次浏览

是的,你的方向是对的。


发生了什么

await simpleTimer(callback)将等待 simpleTimer()返回的承诺解决,因此 callback()将首次被调用,setTimeout()也将被调用。所以模拟记录它是用 [ () => { simpleTimer(callback) }, 1000 ]调用的。

jest.advanceTimersByTime(8000)运行 () => { simpleTimer(callback) }(从1000 < 8000) ,它调用 setTimer(callback)setTimer(callback)第二次调用 callback()并返回由 await创建的承诺。自从 setTimer(callback)PromiseJobs队列中排队的其余部分以来,setTimeout()不再运行第二次,也没有机会运行。

expect(callback).toHaveBeenCalledTimes(9)不能报告 callback()只被调用了两次。


其他资料

这是个好问题。它提请注意 JavaScript 的一些独特特性以及它在底层是如何工作的。

消息队列

JavaScript 使用 消息队列。在运行时返回到队列以检索下一条消息之前,每条消息都是 运行到完成。像 setTimeout() 向队列添加消息这样的函数。

工作排队

ES6引入了 Job Queues ,其中一个必需的作业队列是 PromiseJobs,它处理“对承诺的结算作出响应的作业”。此队列中的任何作业运行 在当前消息完成之后和下一个消息开始之前then()PromiseJobs中排队等待一个作业,这时在结算时对它的允诺被调用。

异步/等待

async / await 只不过是语法糖而不是承诺和发电机.async总是返回一个  诺言,而 await基本上将函数的其余部分封装在一个附加到该承诺的 then回调函数中。

定时器模拟器

jest.useFakeTimers()被调用时,Timer Mocks 用模拟代替像 setTimeout()这样的函数工作。这些模拟记录调用它们的参数。然后,当调用 jest.advanceTimersByTime()时,运行一个循环,该循环同步调用在运行时间内计划的任何回调,包括在运行回调时添加的任何回调。

换句话说,setTimeout()通常对必须等到当前消息完成才能运行的消息进行排队。TimerMocks 允许在当前消息中同步运行回调。

下面的例子说明了上述信息:

jest.useFakeTimers();


test('execution order', async () => {
const order = [];
order.push('1');
setTimeout(() => { order.push('6'); }, 0);
const promise = new Promise(resolve => {
order.push('2');
resolve();
}).then(() => {
order.push('4');
});
order.push('3');
await promise;
order.push('5');
jest.advanceTimersByTime(0);
expect(order).toEqual([ '1', '2', '3', '4', '5', '6' ]);
});

如何让计时器嘲笑和承诺发挥好

TimerMocks 将同步执行回调,但这些回调可能导致作业在 PromiseJobs中排队。

幸运的是,实际上很容易让 PromiseJobs中的所有挂起作业在 async测试中运行,所有您需要做的就是调用 await Promise.resolve()。这实际上将测试的其余部分排队到 PromiseJobs队列的末尾,并让队列中已经存在的所有内容首先运行。

考虑到这一点,这里有一个测试的工作版本:

jest.useFakeTimers()


it('simpleTimer', async () => {
async function simpleTimer(callback) {
await callback();
setTimeout(() => {
simpleTimer(callback);
}, 1000);
}


const callback = jest.fn();
await simpleTimer(callback);
for(let i = 0; i < 8; i++) {
jest.advanceTimersByTime(1000);
await Promise.resolve(); // allow any pending jobs in the PromiseJobs queue to run
}
expect(callback).toHaveBeenCalledTimes(9);  // SUCCESS
});

有一个用例我就是找不到解决方案:

function action(){
return new Promise(function(resolve, reject){
let poll
(function run(){
callAPI().then(function(resp){
if (resp.completed) {
resolve(response)
return
}
poll = setTimeout(run, 100)
})
})()
})
}

这个测试看起来是这样的:

jest.useFakeTimers()
const promise = action()
// jest.advanceTimersByTime(1000) // this won't work because the timer is not created
await expect(promise).resolves.toEqual(({completed:true})
// jest.advanceTimersByTime(1000) // this won't work either because the promise will never resolve

基本上,除非计时器提前,否则动作不会解决。这里感觉像一个循环依赖: 承诺需要计时器提前解决,虚假的计时器需要承诺提前解决。

布莱恩 · 亚当斯的 回答是正确的。

但调用 await Promise.resolve()似乎只能解决一个悬而未决的承诺。

在现实世界中,如果我们必须在每次迭代中一遍又一遍地调用这个表达式,那么测试具有多个异步调用的函数将是非常痛苦的。

相反,如果你的函数有多个 await,这样做会更容易:

  1. 在某处创建此函数

    玩笑 < v27

    function flushPromises() {
    return new Promise(resolve => setImmediate(resolve));
    }
    

    = v27

    function flushPromises() {
    return new Promise(jest.requireActual("timers").setImmediate)
    }
    
  2. 现在调用 await flushPromises(),不管你在哪里调用多个 await Promise.resolve()

关于 这个 GitHub 问题的更多细节。

上面的内容真的很有帮助!对于那些试图这样做与反应挂钩(!)以下代码适用于我们:

// hook
export const useApi = () => {
const apis = useCallback(
async () => {
await Promise.all([
new Promise((resolve) => {
api().then(resolve);
}),
new Promise((resolve) => {
return setTimeout(() => {
resolve();
}, 10000);
}),
]);
},
[],
);
return [apis];
}


// test
import { renderHook, act } from '@testing-library/react-hooks';
function flushPromises() {
return new Promise((resolve) => setImmediate(resolve))
}


it('tests useApi', async () => {
jest.useFakeTimers();
const { result } = renderHook(() => useApi());
api.mockReturnValue(Promise.resolve());
await act(async () => {
const promise = result.current[0]()
await flushPromises()
jest.runAllTimers()


return promise
})
});

我偶然发现了同样的问题,最后直接使用了 @sinonjs/fake-timers,因为它提供了 clock.tickAsync()功能,根据文档:

TicAsync ()也将中断事件循环,允许在运行计时器之前执行任何预定的承诺回调。

现在的实例是:

const FakeTimers = require('@sinonjs/fake-timers');
const clock = FakeTimers.install()


it('simpleTimer', async () => {
async function simpleTimer(callback) {
await callback()
setTimeout(() => {
simpleTimer(callback)
}, 1000)
}


const callback = jest.fn()
await simpleTimer(callback)
await clock.tickAsync(8000)
expect(callback).toHaveBeenCalledTimes(9) // SUCCESS \o/
});

我更喜欢在复杂的测试中使用我自己的假定时器。

export const useCustomTimer = () => {


var time = 0;
var timers: {callback: () => void, ms: number}[] = [];


const setCustomTimer = (callback: () => void, ms: number = 0) => {
if(ms<=time){
callback();
return;
}
timers.push({callback, ms})
timers.sort((a,b) => a.ms - b.ms);
}


const advanceTimersByTime = (ms: number) => {
time += ms;
timers = timers.reduce((acc, val) => {
if(val.ms<=time) {
val.callback();
}
else acc.push(val);
return acc;
}, []);
}


const advanceTimersToNextTimer = () => {
if(timers.length) advanceTimersByTime(timers[0].ms - time);
}


return {
setCustomTimer,
advanceTimersByTime,
advanceTimersToNextTimer
}
}

测试:

test('should demonstrate custom timer', async () => {
const {setCustomTimer, advanceTimersByTime, advanceTimersToNextTimer} = useCustomTimer();


const values = [];
values.push(0);


const promiseAll = Promise.all([
new Promise<void>((res) => setCustomTimer(() => { values.push(2); res(); }, 5)),
new Promise<void>((res) => setCustomTimer(() => { values.push(4); res(); }, 12)),
new Promise<void>((res) => setCustomTimer(() => { values.push(6); res(); }, 20)),
])
.then(() => {
values.push(7);
})


values.push(1);


advanceTimersToNextTimer(); // OR advanceTimersByTime(5);


values.push(3);


advanceTimersToNextTimer(); // OR advanceTimersByTime(7);


values.push(5);


advanceTimersToNextTimer(); // OR advanceTimersByTime(8);


await promiseAll;


values.push(8);


expect(values).toEqual([ 0, 1, 2, 3, 4, 5, 6, 7, 8]);
})

我使用超时模式重试了一次: 多次等待带有超时的承诺。 根据布莱恩 · 亚当斯的回答,如果对任何人有帮助的话,我最终得出了以下解决方案。


/**
* Execute an async function while flushing timers in a loop as long as the promise is still pending
*
* @param fn an async function
* @returns fn return type
*
* @see {@link https://stackoverflow.com/questions/52177631/jest-timer-and-promise-dont-work-well-settimeout-and-async-function}
*/
const executeWhileFlushingPromisesAndTimers = async <F extends () => Promise<any>>(
fn: F,
maxLoopNb = 100,
): Promise<Awaited<ReturnType<F>>> => {
let pending = true
let result: Awaited<ReturnType<F>>
let error: Error
let times = 0


fn()
.then((res) => {
result = res
pending = false
})
.catch((err) => {
error = err
})
.finally(() => {
pending = false
})


while (pending && times < maxLoopNb) {
await Promise.resolve()
jest.runAllTimers()
await new Promise((resolve) => (jest.requireActual('timers') as any).setTimeout(resolve, 0))
times++
}


if (pending) {
throw new Error(
`From executeFlushingPromisesAndTimers - promise still pending after ${maxLoopNb} (maxLoopNb) jest.runAllTimes. Make sure to mock the asynchronous code.`,
)
}
if (error) {
throw error
}
return result
}


test('async retry with timeout', () => {
expect(await executeWhileFlushingPromisesAndTimers(initSW)).toBe(false)
})