Testing timers with fakeAsync

September 17th 2021 Angular Unit Testing

Angular's fakeAsync zone is a great tool for unit testing asynchronous code. Not only does it make it easy to wait for promises and observables to resolve, but it also gives you control over the passage of time. This makes it a nice alternative to Jasmine's Clock when working with Angular.

Let us say I want to test a simple method for delaying a method call (basically just a wrapper around setTimeout):

public callWithDelay(callback: () => void, timeout: number): void {
  setTimeout(callback, timeout);
}

Time does not pass in the fakeAsync zone, so I can check state before the specified time passes:

it("should not call callback immediately", fakeAsync(() => {
  service.callWithDelay(callbackSpy, 50);

  expect(callbackSpy).not.toHaveBeenCalled();

  flush();
}));

The flush call at the end is required because the test will fail with the following error if there are any timers pending at the end of the test:

Error: 1 timer(s) still in the queue.

If I want to test the state after a certain period of time, I can call tick with the desired value:

it("should call callback after the timeout", fakeAsync(() => {
  service.callWithDelay(callbackSpy, 50);

  tick(50);

  expect(callbackSpy).toHaveBeenCalled();
}));

It gets a little more complicated when the timer can be triggered multiple times, as in the following method, which periodically calls a function until it fails:

public callWhileSucceeds(callback: () => boolean, timeout: number) {
  if (callback()) {
    setTimeout(() => this.callWhileSucceeds(callback, timeout), timeout);
  }
}

The tests for this method can be similar to those for the previous one:

it("should call callback once if it fails", fakeAsync(() => {
  callbackSpy.and.returnValue(false);
  service.callWhileSucceeds(callbackSpy, 50);

  tick(50);

  expect(callbackSpy).toHaveBeenCalledTimes(1);
}));

it("should call callback again if it succeeds", fakeAsync(() => {
  callbackSpy.and.returnValue(true);
  service.callWhileSucceeds(callbackSpy, 50);

  tick(50);

  expect(callbackSpy).toHaveBeenCalledTimes(2);
}));

However, in the current state, the second test will fail with the following error:

Error: 1 timer(s) still in the queue.

Of course, the method is not just called twice. Adding flush at the end should help, right?

it("should call callback again if it succeeds", fakeAsync(() => {
  callbackSpy.and.returnValue(true);
  service.callWhileSucceeds(callbackSpy, 50);

  tick(50);

  expect(callbackSpy).toHaveBeenCalledTimes(2);

  flush();
}));

Not really. The test will just fail with another error:

Error: flush failed after reaching the limit of 20 tasks. Does your code use a polling timeout?

The code is constantly creating new timers, so the flush method can never clear them all. The only way to fix this is to stop creating new timers. Fortunately, this can be accomplished by making the called method fail:

it("should call callback again if it succeeds", fakeAsync(() => {
  callbackSpy.and.returnValue(true);

  service.callWhileSucceeds(callbackSpy, 50);

  tick(50);

  expect(callbackSpy).toHaveBeenCalledTimes(2);

  callbackSpy.and.returnValue(false);
  flush();
}));

But what if the code under test does not have a way to stop creating timers? Well, in that case it should use setInterval instead of setTimeout:

public callPeriodically(callback: () => void, timeout: number) {
  setInterval(callback, timeout);
}

We can create a similar test for this method:

it("should call callback after the timeout", fakeAsync(() => {
  service.callPeriodically(callbackSpy, 50);

  tick(50);

  expect(callbackSpy).toHaveBeenCalled();
}));

This test will also fail, just with a slightly different error:

Error: 1 periodic timer(s) still in the queue.

Note the word periodic in the error message. Such timers cannot be deleted using the flush method. But there is another helper method for this case:

it("should call callback after the timeout", fakeAsync(() => {
  service.callPeriodically(callbackSpy, 50);

  tick(50);

  expect(callbackSpy).toHaveBeenCalled();

  discardPeriodicTasks();
}));

Now the test will succeed, because the discardPeriodicTasks method will delete all remaining timers created with setInterval.

A sample project with all the code from above is available in my GitHub repository.

Testing asynchronous code can be a challenge. Even more so when it uses timers. Angular's fakeAsync zone makes it much easier. Just make sure your code is not constantly creating new timers with setTimeout. If it does, use setInterval instead or create a way for the code to stop calling setTimeout.

Get notified when a new blog post is published (usually every Friday):

If you're looking for online one-on-one mentorship on a related topic, you can find me on Codementor.
If you need a team of experienced software engineers to help you with a project, contact us at Razum.
Copyright
Creative Commons License