It is quite common to have code that is asynchronous and the question becomes, how do we write efficient unit tests that will not only check the results to be correct, but also allow us to speed time?
In this post, I want to go over a features provided by Angular and jasmine to get this done. Here is an example code:
import {Injectable} from '@angular/core';
Injectable({
providedIn: 'root'
})
export class DirectoryService {
getFavorites(): Promise<string[]> {
return new Promise<string[]>((resolve, reject) => {
setTimeout(() => {
return ['A', 'B', 'C', 'D']
}, 5000);
});
}
}
This service has one method that returns a promise. The promise is resolved after 5 seconds and the value returned is a simple array of string.
Because the promise resolves after 5 seconds, writing a test without consideration to the asynchronous nature of this method will only give us false positives - meaning, the test will pass, but it will not really execute our expect statement.
// Faulty test - don't do this for async calls
it('should get favorites', () => {
directoryService.getFavorites().then(value => {
expect(value).toEqual(['A', 'B', 'C', 'D']);
})
});
If we run this test, all checks will show green and there will be no errors. However, there will be an important warning message:
'Spec 'DirectoryService should get favorites' has no expectations.'
The warning tells us that we were able to execute our spec, but it completed before our promise could get resolved. In other words, the test did not wait for the promise to return. This is actually dangerous - we don't want our test to pass if it did not execute our expectations!
fakeAsync
The fakeAsync function wraps our assertion callback and throws us an error if there is anything left in our queue that has yet to be executed. Let's try the same code but this time wrap it with the fakeAsync function.
// Faulty test - will throw an error
it('should get favorites', fakeAsync(() => {
directoryService.getFavorites().then(value => {
expect(value).toEqual(['A', 'B', 'C', 'D']);
})
}));
As we expected, the promise does not return until after 5 seconds because of the setTimeout call within the getFavorites function. Our spec finished executing and unlike before, this time we get an exception thrown to us:
Error: 1 timer(s) still in the queue.
at UserContext.fakeAsyncFn (node_modules/zone.js/fesm2015/zone-testing.js:1979:1)
at ZoneDelegate.invoke (node_modules/zone.js/fesm2015/zone.js:372:1)
at ProxyZoneSpec.onInvoke (node_modules/zone.js/fesm2015/zone-testing.js:287:1)
at ZoneDelegate.invoke (node_modules/zone.js/fesm2015/zone.js:371:1)
at Zone.run (node_modules/zone.js/fesm2015/zone.js:134:1)
at runInTestZone (node_modules/zone.js/fesm2015/zone-testing.js:567:1)
at UserContext.<anonymous> (node_modules/zone.js/fesm2015/zone-testing.js:582:1)
at <Jasmine>
We need to find out a way to clear out queue without waiting for 5 seconds.
Using the tick function
Calling the tick() function does the following:
- Executes the microTasks queue (think of promises) at the beginning of the call
- Simulates the passage of time for timers in the fakeAsync zone. This passage can be specified in milliseconds. If the amount is not specified, it will attempt to clear all items in the queue.
- Executes all the macroTasks queue items for us
- Executes the microTasks queue again after any timer callback has been executed.
To clarify how the tick function works, let's take some example tests:
// Example 1: Works - tick cleared our macroTasks queue
// Our expectations ran successfully
it('should get favorites', fakeAsync(() => {
setTimeout(() => {
expect(1).toEqual(1);
})
tick();
}));
Calling tick moved our time , clearing MacroTasks queue items- in this case, our seTimeout callback. In other words, setTimeout executed and the expect statement ran. You can give it a try.
Here is another, more involved example:
// Example 2: Works - tick cleared our macroTasks queue
// Our expectations ran successfully
it('should get favorites', fakeAsync(() => {
setTimeout(() => {
expect(1).toEqual(1);
setTimeout(() => {
expect(2).toEqual(2);
})
})
tick();
}));
This should run successfully too. We first have a single item in our queue, and it gets executed. Execution of this creates another item in the queue, which, again, gets executed (think of it as recursive execution of items in the queue).
How about this example?
// Example 3: Will it work?
it('should get favorites', fakeAsync(() => {
setTimeout(() => {
expect(1).toEqual(1);
setTimeout(() => {
expect(2).toEqual(2);
})
})
tick(1, { processNewMacroTasksSynchronously: false});
}));
Notice that we have a new parameter passed to the tick function - processNewMacroTasksSynchronously.
The test above will fail. The option passed to the tick function tells it that it should not execute any new items in the queue. It only clears the outer setTimeout (by executing it), but any new additions to the queue are not executed and cleared. If you try to comment the second setTimeout, the test will pass.
Note that by default, the value is true and so all new macroTask queue items are executed.
Coming back to our example now, how do we make our test pass? Below is the answer:
it('should get favorites', fakeAsync(() => {
directoryService.getFavorites().then(value => {
expect(value).toEqual(['A', 'B', 'C', 'D']);
})
tick(5000);
}));
Alternatives to tick
There two other functions that might be useful instead of using tick.
flush();
flushMicrotasks()
flush
flush takes an optional maxTurns parameter that specifies how times the scheduler tries to clear the queue. If this parameter is left out, the scheduler keeps on trying to clear the queue items until it's all done.
Below are a few examples to clear this idea:
// Test fails with error:
// flush failed after reaching the limit of 1 tasks.
// Does your code use a polling timeout?
it('should get favorites', fakeAsync(() => {
setTimeout(() => {
expect(1).toEqual(1);
}, 5000)
setTimeout(() => {
expect(1).toEqual(1);
}, 5000)
flush(1);
}));
Setting the flush parameter to 2 or leaving it empty fixes the test.
How about this test? Will it pass or fail?
it('should get favorites', fakeAsync(() => {
setTimeout(() => {
expect(1).toEqual(1);
setTimeout(() => {
expect(2).toEqual(2);
})
})
flush(1);
}));
If you answered that it will fail, you are correct. There are two setTimeout items, and flush will attempt to clear the queue item once. We need to remove the optional parameter or set the value to 2.
One last thing to note about flush is that it will give you back the simulated time elapsed when it completes.
flushMicrotasks
This function flushes any pending microtask queue items - i.e, items your javascript code created (think about promise). The function doesn't take any parameters and does not return values.
If you are asking about async and await keywords, remember that those are, behind the scenes, promises. Therefore, you need to think about the microtask queue - which is affected by both tick and flushMicrotasks functions.
In the next post, we will talk about testing components.