Search code examples
javascripttypescripttestingjestjstdd

How properly test setTimeout after Promise resolve


How to propertly test this function.

  1. Set timeout
  2. Callback after timeout
  3. And recursive call
export function initScheduler(timeout: number, callback: () => Promise<void>): void {
  setTimeout(() => {
    callback().then(() => {
      initScheduler(timeout, callback);
    });
  }, timeout);
}

I tried something like

describe('initScheduler', () => {
  it('should call on schedule', () => {
    jest.useFakeTimers();

    const timeout: number = 60000;
    const callback: jest.Mock = jest.fn().mockResolvedValue(undefined);

    initScheduler(timeout, callback);

    expect(setTimeout).toHaveBeenCalledTimes(1);
    expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), timeout);
    expect(callback).not.toBeCalled();

    jest.advanceTimersByTime(timeout);

    expect(callback).toBeCalled();
    expect(setTimeout).toHaveBeenCalledTimes(2);
  });
});

But last expectation returns 1


Solution

  • Don't concentrate on assertions against setTimeout. Concentrate on what your function is expected to do.

    describe('', () => {
      jest.useFakeTimers();
      const timeout = 300;
      const callback = jest.fn();
    
      beforeEach(() => {
        jest.clearAllTimers();
        callback
          .mockClear()
          .mockReturnValue(Promise.resolve()); 
      });
    
      it('runs callback only after delay given', () => {
        initScheduler(timeout, callback);
        jest.advanceTimersByTime(timeout - 1);
        expect(callback).not.toHaveBeenCalled();
        jest.advanceTimersByTime(2);
        expect(callback).toHaveBeenCalledTimes(1);
      });
    
      it('reruns scheduler if callback been resolved successfully', async () => {
        initScheduler(timeout, callback);
        expect(callback).not.toHaveBeenCalled();
        jest.advanceTimersByTime(timeout);
        await Promise.resolve();
        jest.advanceTimersByTime(timeout);
        await Promise.resolve();
        jest.advanceTimersByTime(timeout);
        await Promise.resolve();
        expect(callback).toHaveBeenCalledTimes(3);
      });
    
      it('stops scheduler if callback rejected', async () => {
        callback.mockReturnValue(Promise.reject());
        initScheduler(timeout, callback);
        jest.advanceTimersByTime(timeout);
        await Promise.resolve();
        jest.advanceTimersByTime(timeout);
        await Promise.resolve();
        expect(callback).toHaveBeenCalledTimes(1);
      });
    });
    

    Some details.

    1. I'm surprised but timers are not cleared between it() to me without jest.clearAllTimers(). Probably, it depends on jsdom implementation or jest version, I don't know.
    2. without await Promise.resolve() your mock for callback dos not run .then part. Actually it could be await <anything else>, I just see await Promise.resolve(); looking less magic than await 42;. Anyway, its purpose to flush microtasks queue while jest itself does not provide straightforward API on that.