5

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

skyboyer
  • 22,209
  • 7
  • 57
  • 64
VivekN
  • 1,562
  • 11
  • 14

1 Answers1

7

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.

Brian Adams
  • 43,011
  • 9
  • 113
  • 111
  • Thank you @brian-lives-outdoors. I kind of followed the similar approach and it worked for me. But what I did was exactly what you mentioned in this answer. Thank you for that link. I will go through it and read more about promises and fake timers. Just one more question, if I have multiple then block all chained, for now I have to write multiple Promise.resolve to reach all those then blocks. For example if I have three then chained, to reach the third then I have to do Promise.resolve three times. Is there a better way to do this or is this right? – VivekN Sep 24 '18 at 09:42
  • You can await multiple `PromiseJobs` cycles on the same line by chaining `.then()` calls to the end of the `Promise.resolve`. For example, to wait three cycles do `await Promise.resolve().then().then();`. Once again, returning Promises and awaiting them directly is always ideal, but for situations where that is not possible this approach will work. – Brian Adams Sep 24 '18 at 14:45
  • need your help again. I have replaced axios with fetch and struggling with the same issue again. onSuccess is not getting called. Any way I can contact you other than SO, twitter may be? Thank you – VivekN Oct 08 '18 at 10:45
  • @VivekN just create a new SO question and I'll take a look – Brian Adams Oct 08 '18 at 17:22