开玩笑:计时器和承诺不能正常工作.(setTimeout和async函数)

Gut*_*nYe 25 javascript testing jestjs

关于此代码的任何想法

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)
}
Run Code Online (Sandbox Code Playgroud)

```

失败了

Expected mock function to have been called nine times, but it was called two times.
Run Code Online (Sandbox Code Playgroud)

但是,如果我await从LINE-A中删除,则测试通过.

Promise和Timer不能正常工作吗?

我认为开玩笑的原因是等待第二个承诺来解决.

Bri*_*ams 52

是的,你走在正确的轨道上.


怎么了

await simpleTimer(callback)将等待返回的Promise simpleTimer()解析,因此callback()第一次setTimeout()调用并被调用. jest.useFakeTimers() 替换setTimeout()为模拟,因此模拟记录它被调用[ () => { simpleTimer(callback) }, 1000 ].

jest.advanceTimersByTime(8000)运行() => { simpleTimer(callback) }(自1000 <8000),调用第二次setTimer(callback)调用callback()并返回由其创建的Promise await. setTimeout()不会再次运行,因为其余的setTimer(callback) 队列在PromiseJobs队列中并且没有机会运行.

expect(callback).toHaveBeenCalledTimes(9)报告失败callback()只被调用两次.


附加信息

这是一个很好的问题.它引起了人们对JavaScript的一些独特特性以及它如何工作的关注.

消息队列

JavaScript使用消息队列.在运行时返回队列以检索下一条消息之前,每条消息都会运行完成.功能包括setTimeout() 向队列添加消息.

工作队列

ES6引入Job Queues并且其中一个必需的作业队列PromiseJobs处理"作出对Promise结算的响应的作业".此队列中的所有作业在当前消息完成之后和下一条消息开始之前运行. then()PromiseJobs调用Promise时解析作业.

异步/等待

async / await 只是承诺和发电机的语法糖. async总是返回一个Promise,并且await基本上将函数的其余部分包含在then附加到Promise 的回调中.

定时器模拟

定时器嘲笑的工作更换喜欢的功能setTimeout()与嘲笑的时候jest.useFakeTimers()被调用.这些模拟记录了它们被调用的参数.然后,当jest.advanceTimersByTime()调用一个循环运行时,它会同步调用在已用时间内调度的任何回调,包括在运行回调时添加的任何回调.

换句话说,setTimeout()通常将必须等到当前消息完成的消息排队才能运行.定时器模拟允许回调在当前消息中同步运行.

这是一个演示上述信息的示例:

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' ]);
});
Run Code Online (Sandbox Code Playgroud)

如何让Timer Mocks和Promises玩得很好

Timer Mocks将同步执行回调,但这些回调可能导致作业排队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
});
Run Code Online (Sandbox Code Playgroud)

  • 很好的答案!谢谢布莱恩。另外,有些文章将 JavaScript“消息队列”和“作业队列”称为“宏任务”和“微任务”,只是为了避免混淆。 (2认同)
  • 优秀的答案 - 没想到通过这个“错误”学到了这么多,感谢您抽出时间。您现在也可以使用await jest.advanceTimersByTimeAsync (2认同)

Mol*_*Ice 29

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

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

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

相反,如果您的函数有多个awaits,则使用jwbay 的响应会更容易:

  1. 在某处创建这个函数
    function flushPromises() {
      return new Promise(resolve => setImmediate(resolve));
    }
    
    Run Code Online (Sandbox Code Playgroud)
  2. 现在调用await flushPromises()您本来会调用多个await Promise.resolve()s 的任何地方

  • 实际上,对于 Jest &gt;= v27 ,正确的函数是: `functionlushPromises() { new Promise(resolve =&gt; jest.requireActual('timers').setImmediate(resolve));}` (2认同)
  • Jest 26.6,这不适用于 `jest.useFakeTimers('modern')`,但适用于 `jest.useFakeTimers('legacy')`。请参阅[此答案](/sf/ask/3578875051/#63296414)。 (2认同)