Angular testing: flush vs. flushMicrotasks

July 2nd 2021 Angular Unit Testing Async

Much of the Angular code is asynchronous. The fakeAsync function is very useful when testing such code, especially when not all promises and observables are publicly accessible. You can use the flush function instead of awaiting them individually. I recently learned that this does not work in all cases.

I had a service to manage an array of items that were automatically removed after a certain time. The following code is a simplified version of that:

export class ToastsService {
  private readonly timeoutMs = 5 * 1000;
  private readonly toasts: Toast[] = [];

  public getToasts(): Toast[] {
    return this.toasts;
  }

  public addToast(message: string): void {
    const toast: Toast = {
      message,
      timestamp: Date.now(),
    };
    this.toasts.push(toast);

    setTimeout(() => {
      const index = this.toasts.indexOf(toast);
      if (index >= 0) {
        this.toasts.splice(index, 1);
      }
    }, this.timeoutMs);
  }
}

Testing the synchronous addToast method is easy because the test completes before the timeout expires and the element is removed:

it("should add a toast", () => {
  const toasts = service.getToasts();

  service.addToast("Test message");

  expect(toasts.length).toBe(1);
  expect(toasts[0].message).toBe("Test message");
});

It becomes more complicated when this method is called asynchronously from another location:

export class NetworkService {
  constructor(private toastsService: ToastsService) {}

  public async refreshData(): Promise<void> {
    await Promise.resolve(); // fake network call
    this.toastsService.addToast("Data refreshed.");
  }
}

Synchronous testing of the side effect will no longer work because the test will complete before the element is added:

it("should add a toast", () => {
  const toastsService = TestBed.inject(ToastsService);
  const toasts = toastsService.getToasts();

  service.refreshData();

  // the following assertions fail
  expect(toasts.length).toBe(1);
  expect(toasts[0].message).toBe("Data refreshed.");
});

This is where fakeAsync can help by allowing you to explicitly flush the pending asynchronous code. However, the following test still failed:

it("should add a toast", fakeAsync(() => {
  const toastsService = TestBed.inject(ToastsService);
  const toasts = toastsService.getToasts();

  service.refreshData();

  flush();

  // the following assertions fail
  expect(toasts.length).toBe(1);
  expect(toasts[0].message).toBe("Data refreshed.");
}));

Why? Because it also flushes the pending timeout. So while the synchronous test failed because the element has not yet been added to the array, this test fails because it has already been removed.

The test can be fixed by using flushMicrotasks instead of flush:

it("should add a toast", fakeAsync(() => {
  const toastsService = TestBed.inject(ToastsService);
  const toasts = toastsService.getToasts();

  service.refreshData();

  flushMicrotasks();

  // the following assertions will pass
  expect(toasts.length).toBe(1);
  expect(toasts[0].message).toBe("Data refreshed.");

  // without flush at the end, the test will fail with
  // Error: 1 timer(s) still in the queue.
  flush();
}));

The difference between flushMicrotasks and flush is that the former only processes the pending microtasks ( promise callbacks), but not the (macro) tasks ( scheduled callbacks), while flush processes both. If you want to learn more about microtasks and tasks, I recommend reading the excellent post by Jake Archibald.

If you want to check the full code and run the test yourself, you can find a working example in my GitHub repository.

In most cases, the fakeAsync tests work the same whether you use flush or flushMicrotasks, but there are cases where they don't behave the same. To identify these cases, it's important to understand the browser's event loop and the difference between microtasks and tasks.

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

Copyright
Creative Commons License