VivekN
VivekN

Reputation: 1602

Testing a Recursive polling function with jest and fake timers

I have created a polling service which recursively calls an api and on success of the api if certain conditions are met, keeps polling again.

/**
   * start a timer with the interval specified by the user || default interval
   * we are using setTimeout and not setinterval because a slow back end server might take more time than our interval time and that would lead to
   * a queue of ajax requests with no response at all.
   * -----------------------------------------
   * This function would call the api first time and only on the success response of the api we would poll again after the interval
   */
  runPolling() {
    const { url, onSuccess, onFailure, interval } = this.config;
    const _this = this;
    this.poll = setTimeout(() => {
      /* onSuccess would be handled by the user of service which would either return true or false
      * true - This means we need to continue polling
      * false - This means we need to stop polling
      */
      api
        .request(url)
        .then(response => {
          console.log('I was called', response);
          onSuccess(response);
        })
        .then(continuePolling => {
          _this.isPolling && continuePolling ? _this.runPolling() : _this.stopPolling();
        })
        .catch(error => {
          if (_this.config.shouldRetry && _this.config.retryCount > 0) {
            onFailure && onFailure(error);
            _this.config.retryCount--;
            _this.runPolling();
          } else {
            onFailure && onFailure(error);
            _this.stopPolling();
          }
        });
    }, interval);
  }

While trying to write the test cases for it, I am not very sure as to how can simulate fake timers and the axios api response.

This is what I have so far

import PollingService from '../PollingService';
import { statusAwaitingProduct } from '@src/__mock_data__/getSessionStatus';
import mockAxios from 'axios';

describe('timer events for runPoll', () => {
    let PollingObject,
    pollingInterval = 3000,
    url = '/session/status',
    onSuccess = jest.fn(() => {
      return false;
    });
    beforeAll(() => {
      PollingObject = new PollingService({
        url: url,
        interval: pollingInterval,
        onSuccess: onSuccess
      });
    });
    beforeEach(() => {
      jest.useFakeTimers();
    });
    test('runPolling should be called recursively when onSuccess returns true', async () => {
      expect.assertions(1);
      const mockedRunPolling = jest.spyOn(PollingObject, 'runPolling');
      const mockedOnSuccess = jest.spyOn(PollingObject.config, 'onSuccess');
      mockAxios.request.mockImplementation(
        () =>
          new Promise(resolve => {
            resolve(statusAwaitingProduct);
          })
      );

      PollingObject.startPolling();
      expect(mockedRunPolling).toHaveBeenCalledTimes(1);
      expect(setTimeout).toHaveBeenCalledTimes(1);
      expect(mockAxios.request).toHaveBeenCalledTimes(0);
      expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), pollingInterval);

      jest.runAllTimers();
      expect(mockAxios.request).toHaveBeenCalledTimes(1);
      expect(mockedOnSuccess).toHaveBeenCalledTimes(1);
      expect(PollingObject.isPolling).toBeTruthy();
      expect(mockedRunPolling).toHaveBeenCalledTimes(2);
    });
  });
});

Here even though mockedOnsuccess is called but jest expect call fails saying it was called 0 times instead being called 1times.

Can someone please help? Thanks

Upvotes: 5

Views: 5841

Answers (1)

Brian Adams
Brian Adams

Reputation: 45830

Issue

There might be other issues with your test as well, but I will address the specific question you asked about expect(mockedOnSuccess).toHaveBeenCalledTimes(1); failing with 0 times:

jest.runAllTimers will synchronously run any pending timer callbacks until there are no more left. This will execute the anonymous function scheduled with setTimeout within runPolling. When the anonymous function executes it will call api.request(url) but that is all that will happen. Everything else in the anonymous function is contained within then callbacks that get queued in the PromiseJobs Jobs Queue introduced with ES6. None of those jobs will have executed by the time jest.runAllTimers returns and the test continues.

expect(mockAxios.request).toHaveBeenCalledTimes(1); then passes since api.request(url) has executed.

expect(mockedOnSuccess).toHaveBeenCalledTimes(1); then fails since the then callback that would have called it is still in the PromiseJobs queue and hasn't executed yet.

Solution

The solution is to make sure the jobs queued in PromiseJobs have a chance to run before asserting that mockedOnSuccess was called.

Fortunately, it is very easy to allow any pending jobs in PromiseJobs to run within an async test in Jest, just call await Promise.resolve();. This essentially queues the rest of the test at the end of PromiseJobs and allows any pending jobs in the queue to execute first:

test('runPolling should be called recursively when onSuccess returns true', async () => {
  ...
  jest.runAllTimers();
  await Promise.resolve();  // allow any pending jobs in PromiseJobs to execute
  expect(mockAxios.request).toHaveBeenCalledTimes(1);
  expect(mockedOnSuccess).toHaveBeenCalledTimes(1); // SUCCESS
  ...
}

Note that ideally an asynchronous function will return a Promise that a test can then wait for. In your case you have a callback scheduled with setTimeout so there isn't a way to return a Promise for the test to wait on.

Also note that you have multiple chained then callbacks so you may need to wait for the pending jobs in PromiseJobs multiple times during your test.

More details about how fake timers and Promises interact here.

Upvotes: 7

Related Questions