wato9902
wato9902

Reputation: 659

How to use JEST to test the time course of recursive functions

I write a test using JEST. I do not know how to test promise recursion in JEST.

In this test, the retry function that performs recursion is the target of the test until the promise is resolved.

export function retry<T>(fn: () => Promise<T>, limit: number = 5, interval: number = 1000): Promise<T> {
  return new Promise((resolve, reject) => {
    fn()
      .then(resolve)
      .catch((error) => {
        setTimeout(() => {
          // Reject if the upper limit number of retries is exceeded
          if (limit === 1) {
            reject(error);

            return;
          }
          // Performs recursive processing of callbacks for which the upper limit number of retries has not been completed
          try {
            resolve(retry(fn, limit - 1, interval));
          } catch (err) {
            reject(err);
          }
        }, interval);
      });
  });
}

Perform the following test on the above retry function.

  1. retry() is resolved in the third run. The first, second and third times are called every 1000 seconds respectively.

I thought it would be as follows when writing these in JEST.


jest.useFakeTimers();

describe('retry', () => {
  // Timer initialization for each test
  beforeEach(() => {
    jest.clearAllTimers();
  });
  // Initialize timer after all tests
  afterEach(() => {
    jest.clearAllTimers();
  });

  test('resolve on the third call', async () => {
    const fn = jest
      .fn()
      .mockRejectedValueOnce(new Error('Async error'))
      .mockRejectedValueOnce(new Error('Async error'))
      .mockResolvedValueOnce('resolve');

    // Test not to be called
    expect(fn).not.toBeCalled();
    // Mock function call firs execution
    await retry(fn);
    // Advance Timer for 1000 ms and execute for the second time
    jest.advanceTimersByTime(1000);
    expect(fn).toHaveBeenCalledTimes(2);
    // Advance Timer for 1000 ms and execute for the third time
    jest.advanceTimersByTime(1000);
    expect(fn).toHaveBeenCalledTimes(3);

    await expect(fn).resolves.toBe('resolve');
  });

});

As a result, it failed in the following error.

● retry › resolve on the third call
Timeout - Async callback was not invoked within the 30000ms timeout specified by jest.setTimeout.Error: 

    > 16 |   test('resolve on the third call', async () => {
         |   ^
      17 |     jest.useFakeTimers();
      18 |     const fn = jest
      19 |       .fn()

I think that it will be manageable in the setting of JEST regarding this error. However, fundamentally, I do not know how to test promise recursive processing in JEST.

Upvotes: 3

Views: 3481

Answers (3)

Keegan 82
Keegan 82

Reputation: 404

I agree with @hoangdv this recursive setTimeout pattern is very tricky to test. This is the only solution that worked for me after trying @hoangdv's (which gave me much inspiration).

Note I'm using a similar retry pattern around network Fetches. I'm using React Testing Library to look for an error message that only appears when the retry function ran 3 times.

it('Shows file upload error on timeout', async () => {
            jest.useFakeTimers();
            setupTest();
            // sets up various conditions that trigger the underlying fetch


            while (
                screen.queryByText(
                    'There appears to be a network issue. Please try again in a few minutes.'
                ) === null
            ) {
                jest.runOnlyPendingTimers(); // then, this line will be execute
                await Promise.resolve(); // allow any pending jobs in the PromiseJobs queue to run

            }
            await waitFor(() => {
                expect(
                    screen.getByText(
                        'There appears to be a network issue. Please try again in a few minutes.'
                    )
                ).toBeInTheDocument();
            });
            jest.useRealTimers();
        });

Upvotes: 0

hoangdv
hoangdv

Reputation: 16147

Your function is so hard to testing with timer.

When you call await retry(fn); this mean you will wait until retry return a value, but the setTimeout has been blocked until you call jest.advanceTimersByTime(1000); => this is main reason, because jest.advanceTimersByTime(1000); never been called.

You can see my example, it is working fine with jest's fake timers.

  test("timing", async () => {
    async function simpleTimer(callback) {
      await callback();
      setTimeout(() => {
        simpleTimer(callback);
      }, 1000);
    }

    const callback = jest.fn();
    await simpleTimer(callback); // it does not block any things
    for (let i = 0; i < 8; i++) {
      jest.advanceTimersByTime(1000); // then, this line will be execute
      await Promise.resolve(); // allow any pending jobs in the PromiseJobs queue to run
    }
    expect(callback).toHaveBeenCalledTimes(9);  // SUCCESS
  });

I think, you could skip test timer detail, just test about you logic: fn has been called 3 times, finally it return "resolve"

test("resolve on the third call", async () => {
    const fn = jest
      .fn()
      .mockRejectedValueOnce(new Error("Async error"))
      .mockRejectedValueOnce(new Error("Async error"))
      .mockResolvedValueOnce("resolve");

    // expect.assertions(3);

    // Test not to be called
    expect(fn).not.toBeCalled();
    // Mock function call firs execution

    const result = await retry(fn);

    expect(result).toEqual("resolve");

    expect(fn).toHaveBeenCalledTimes(3);
  });

Note: remove all your fake timers - jest.useFakeTimers

Upvotes: 4

Eponyme Web
Eponyme Web

Reputation: 976

The documentation of jest suggests that testing promises is fairly straightforward ( https://jestjs.io/docs/en/asynchronous )

They give this example (assume fetchData is returning a promise just like your retry function)

test('the data is peanut butter', () => {
  return fetchData().then(data => {
    expect(data).toBe('peanut butter');
  });
});

Upvotes: 1

Related Questions