Jest:Timer和Promise不太好用,(setTimeout和async函数)

wlzqhblo  于 2023-06-04  发布在  Jest
关注(0)|答案(8)|浏览(208)

对这段代码有什么想法吗

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`,则测试通过。
Promise和Timer不好用吗?
我想原因可能是在等待第二个承诺的解决。
bkkx9g8r

bkkx9g8r1#

是的,你的方向是对的。

发生了什么

await simpleTimer(callback)将等待simpleTimer()返回的Promise解析,因此callback()第一次被调用,setTimeout()也被调用。jest.useFakeTimers()用一个mock替换了setTimeout(),这样mock就记录了它是用[ () => { simpleTimer(callback) }, 1000 ]调用的。
jest.advanceTimersByTime(8000)运行() => { simpleTimer(callback) }(因为1000 < 8000),它调用setTimer(callback)setTimer(callback)第二次调用callback(),并返回await创建的Promise。setTimeout()不会第二次运行,因为setTimer(callback)的其余部分在PromiseJobs队列中排队,并且没有机会运行。
expect(callback).toHaveBeenCalledTimes(9)失败,报告callback()仅被调用两次。

附加信息

这是一个好问题。它吸引了人们对JavaScript的一些独特特性以及它是如何在后台工作的注意。

消息队列

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

作业队列

ES6引入了Job Queues,其中一个所需的作业队列是PromiseJobs,它处理“对Promise结算的响应作业”。此队列中的任何作业都将 * 在当前消息完成后、下一消息开始前 * 运行。then()PromiseJobs中对一个作业进行排队,当调用它的Promise解析时。

async / await

async / await只是promise和generators上的语法糖。async总是返回一个Promise,而await实际上将函数的其余部分 Package 在一个附加到Promise的then回调中。

定时器模拟

Timer Mocks的工作原理是在调用jest.useFakeTimers()时用mocks替换setTimeout()等函数。这些模拟记录了调用它们时使用的参数。然后,当调用jest.advanceTimersByTime()时,将运行一个循环,该循环将同步调用在经过的时间内调度的任何回调,包括在运行回调时添加的任何回调。
换句话说,setTimeout()通常对必须等待到当前消息完成才能运行的消息进行排队。TimerMock允许回调在当前消息中同步运行。
下面是一个演示上述信息的示例:

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

htzpubme2#

Brian Adamsanswer是当场。
但是调用await Promise.resolve()似乎只能解决一个挂起的promise。
在真实的世界中,测试具有多个异步调用的函数将是痛苦的,如果我们必须在每次迭代中反复调用这个表达式。
相反,如果你的函数有多个await,这样做会更容易:
1.在某处创建此函数
Jest < v27

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

Jest >= v27

function flushPromises() {
  return new Promise(jest.requireActual("timers").setImmediate)
}

1.现在,在需要调用多个await Promise.resolve()的地方调用await flushPromises()
关于this GitHub问题的更多详细信息。

t30tvxxf

t30tvxxf3#

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

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来解决提前。

rmbxnbpk

rmbxnbpk4#

我也遇到了同样的问题,最后直接使用了@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/
});
lztngnrs

lztngnrs5#

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

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

blmhpbnm6#

我有一个带超时模式的重试:等待具有超时的promise若干次。我最终得到了以下解决方案,基于布莱恩·亚当斯的回答,如果它能对任何人有任何帮助的话。

/**
 * 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)
})
4xy9mtcn

4xy9mtcn7#

上面的真的很有帮助!对于那些试图用React钩子来做这件事的人来说(!下面的代码为我们工作:

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

evrscar28#

自Jestv29.5.0起可以使用jest.advanceTimersByTimeAsync(msToRun)
jest.advanceTimersByTime(msToRun)的异步等效项。它允许在运行计时器之前执行任何预定的promise回调。

...
await jest.advanceTimersByTimeAsync(1000);
expect(callback).toHaveBeenCalled

相关问题