Sinon fake timers and syncing ticks with unpredictable timers to guarantee order of execution

1.7k views Asked by At

I'm using Sinon fake timers to test some functions containing timers. The problem is that the tested function uses several asynchronous calls before the timer is called, and I find it hard to sync the test case so that clock.tickAsync() is guaranteed to be called after the tested function calls setTimeout(). I've seen other questions with similar problems, but they have been more regarding timers set from tests and how to sync them with ticks also called from the test. But if we consider our function other test to be a black box, and all we know is that after performing some async calls, it will call setTimeout, upon which we must advance the clock to prevent the timer from hanging, what would be a good way?

Example: Here, foo is the function we want to test. All we know is that it performs some async tasks, be it I/O or whatever, and then waits for 10 seconds (my real code actually has a loop where a condition is checked every second with a timeout of 10 seconds). The test calls foo and then advances the clock by 20 seconds to trigger the timer, but most of the time it will not work because the call to tickAsync() will be before foo has called setTimeout():

function foo() {
  await something();
  await something();
  await something();
  await something();
  await something();
  return new Promise(resolve => setTimeout(resolve, 10000));
}

let clock;
describe('Test with unpredictable timer', () => {
  beforeEach(() => {
    clock = FakeTimers.install();
  });
  afterEach(() => {
    clock.uninstall();
  });

  it('should advance timer', () => {
    const p = foo();
    // advance timer
    await clock.tickAsync(20000);
    return p;
  });
});

So, this test will usually fail because the timer is never triggered.

I realize that the most reliable way is probably if my test could be notified when setTimeout has been called, so I considered adding an event emitter that sends out "waiting" events, but that really clutters my production code. I also considered monkey-patching setTimeout (after Sinon has replaced it) to also emit an event. But this is also messy. Finally I resorted to making a loop of tickAsync() calls, ensuring that each await clock.tickAsync() gives the event loop a new run, and this works, but is inherently unreliable of course as it still relies on arbitrarily chosen number of loops etc:

function tickSteps(clock, duration, steps) {
  for (let i = 0; i < steps; i++) {
    await clock.tickAsync(duration / steps);
  }
}

This works e.g. for tickSteps(clock, 20000, 100) but may not work for a steps value of 50, and is obviously not a robust solution.

I also considered adding a "real" timer in my test code to have it actually wait 50 "real" milliseconds before ticking the fake clock, but that also proved messy.

Short of redesigning my code to either break it into smaller pieces or adding callbacks/events, is there any way Sinon itself can help with this? Essentially what I'd need is to tell Sinon to tick the clock, but hold it off until the next set timer (but I guess that could also be problematic if there are timers in use by the runtime etc). Or perhaps if there was a way to do "tick the fake clock but only after a real clock delay".

Is there a go-to solution for this problem or should I give up and redesign my tested code?

1

There are 1 answers

0
Elias On

As far as I understood the problem (I still can't grasp the use case where, given the finished async functions, a setTimeout to some fixed time range is useful) I would try running clock.runAll() after foo(). As stated in the documentation:

This runs all pending timers until there are none remaining. If new timers are added while it is executing they will be run as well.

It might be helpful in your case to also schedule the clock.runAll() later on to make sure that the setTimeout was already called.

Another solutions which came to my head would be:

  • Adding a callback to the foo function which is called before the timeout
  • Adding a spy on the last async function in foo to inform the test about when the timeout is called