Angular testing: flush vs. flushMicrotasks
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.