Jest:Timer 和 Promise 效果不佳。(setTimeout 和异步函数)

IT技术 javascript testing jestjs
2021-02-07 16:23:38

关于此代码的任何想法

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.

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

Promise 和 Timer 不能正常工作吗?

我认为可能开玩笑的原因是等待第二个Promise解决。

5个回答

是的,你在正确的轨道上。


怎么了

await simpleTimer(callback)将等待返回的 PromisesimpleTimer()来解决,因此callback()第一次setTimeout()被调用并且也会被调用。 jest.useFakeTimers() 替换setTimeout()为模拟,因此模拟记录了它被调用的情况[ () => { simpleTimer(callback) }, 1000 ]

jest.advanceTimersByTime(8000)运行() => { simpleTimer(callback) }(因为 1000 < 8000) 调用setTimer(callback)which 调用callback()第二次并返回由await. setTimeout()不会第二次运行,因为其余部分setTimer(callback) 已在PromiseJobs队列中排队并且没有机会运行。

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


附加信息

这是一个很好的问题。它引起了人们对 JavaScript 的一些独特特征及其背后工作方式的关注。

消息队列

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

作业队列

ES6 引入了Job Queues一个必需的作业队列,PromiseJobs它处理“作为对 Promise 解决的响应的作业”。此队列中的所有作业在当前消息完成之后和下一条消息开始之前运行当调用它的 Promise 解决时,then()将作业排队PromiseJobs

异步/等待

async / await 只是 promises 和 generators 的语法糖async总是返回一个 Promise 并且await本质上将函数的其余部分包装在then附加到它给定的 Promise回调中。

定时器模拟

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

换句话说,setTimeout()通常将必须等待当前消息完成后才能运行的消息排队。Timer Mocks 允许回调在当前消息中同步运行。

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

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' ]);
});

如何让 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
});
这似乎是关于将 Promise 与 Jest 假计时器混合的主题的最佳答案,但我根本不明白,这些东西对我来说没有任何意义。有人有更多资源可以帮助我理解吗?Jest 文档分别处理Promise和时间,但我需要测试混合情况。
2021-03-17 16:23:38
具体来说,这对我有用await Promise.resolve(); // allow any pending jobs in the PromiseJobs queue to run,谢谢!
2021-04-03 16:23:38
这么好的,彻底的答案。拯救了我的理智!
2021-04-08 16:23:38
很好的答案!谢谢布莱恩。还有一些文章将 JavaScriptmessage queuejob queue称为macro tasksand micro tasks,只是为了避免混淆。
2021-04-10 16:23:38

布赖恩·亚当斯( Brian Adams ) 的回答恰到好处。

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

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

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

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

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

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

除非计时器提前,否则基本上该操作不会解决。感觉这里是一个循环依赖:promise 需要定时器来提前解决,假定时器需要Promise来解决才能提前。

@RobinElvin 你能用那个技巧向我展示你完整的工作代码吗?我已经被几乎相同的轮询样式代码困住了几个小时。非常感激 :)
2021-03-21 16:23:38
@RobinElvin 非常感谢您的努力。根据您的见解,我昨晚(实际上是凌晨 2:30 😂)设法使用相同的构造使其工作。由于我无法真正确定何时必须“启动”第一个await Promise.resolve()以在我的轮询函数中进行屈服,因此我必须while像@nemo 指向的链接中的循环那样循环执行此操作。另外,同时使用nock对我来说也是有问题的以下是可能对未来读者有用的片段:gist.github.com/dwiyatci/740a52a08eb6147baa1f0be9b4f38785 😌
2021-03-23 16:23:38
@GlennMohammad 我似乎再也找不到那个代码了。但本质上,您await Promise.resolve();在测试中使用屈服于其他代码,以便它可以处理队列。它有助于了解超时和Promise的确切顺序,以便您可以编排测试。在我的示例中,我让步让挂起的Promise解决,然后能够运行挂起的计时器,这反过来又产生了另一个可以通过再次等待“刷新”的Promise。
2021-03-25 16:23:38
找到了这个解决方法,似乎是唯一稳定的工作方法:github.com/facebook/jest/issues/7151#issuecomment-463370069
2021-03-31 16:23:38
我有非常相似的代码完全相同的问题。我通过这样做await Promise.resolve(); jest.runAllTimers(); await Promise.resolve();让测试中的代码将第一个放入setTimeout队列来使其工作然后,您可以像往常一样提前计时器来执行测试。
2021-04-11 16:23:38

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

tickAsync() 还将中断事件循环,允许在运行计时器之前执行任何计划的Promise回调。

工作示例现在变为:

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/
});

以上真的很有帮助!对于那些尝试使用 React hooks(!) 执行此操作的人,以下代码对我们有用:

// 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
  })
});