Jest: Таймер и обещание не работают хорошо. (функция setTimeout и async)

Любые идеи по этому коду

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, тест пройдет.

Не работает ли обещание и таймер?

Я думаю, что причина может быть, шутка ждет второго обещания решить.

Ответ 1

Да, вы на правильном пути.


Что просходит

await simpleTimer(callback) будет ожидать разрешения Promise, возвращаемого simpleTimer() поэтому callback() simpleTimer() в первый раз, а 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 которая обрабатывает "Задания, которые являются ответами на урегулирование Обещания". Все задания в этой очереди выполняются после завершения текущего сообщения и до начала следующего сообщения. then() PromiseJobs задание в PromiseJobs в PromiseJobs когда Promise, для которого оно вызывается, разрешается.

асинхронный/ожидание

async/await - это просто синтаксический сахар над обещаниями и генераторами. async всегда возвращает обещание и await, по существу оборачивает остальную часть функции в then обратном вызове, прикрепленные к Promise оно дано.

Таймер издевается

Mock-таймеры работают, заменяя функции вроде setTimeout() на jest.useFakeTimers() когда jest.useFakeTimers(). Эти издевательства записывают аргументы, с которыми они были вызваны. Затем, когда jest.advanceTimersByTime() запускается цикл, который синхронно вызывает любые обратные вызовы, которые были бы запланированы за истекшее время, включая любые, которые добавляются во время выполнения обратных вызовов.

Другими словами, setTimeout() обычно ставит в очередь сообщения, которые должны дождаться завершения текущего сообщения, прежде чем они смогут работать. Таймер 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 очередь оставшуюся часть теста в конце очереди 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
});

Ответ 2

Есть вариант использования, который я просто не смог найти решение:

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

По существу, действие не будет разрешено, пока не будет достигнут таймер. Здесь ощущается круговая зависимость: обещание требует таймера, чтобы продвинуться, чтобы разрешить, поддельный таймер нужно обещать, чтобы разрешить, чтобы продвинуться.