When creating automated unit tests in Ionic and Angular applications we would typically follow a process like this:
- Set up the testing environment
- Run some code
- Make assertions as to what should have happened
This process is also commonly referred to as AAA (Arrange, Act, Assert). I don’t plan to provide an introduction to unit testing in this tutorial (this serves as a good starting point for testing Ionic applications), but a typical test might look something like this:
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { TestBed, ComponentFixture, async } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('Component: Root Component', () => {
let comp: AppComponent;
let fixture: ComponentFixture<AppComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [AppComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
providers: [],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
});
afterEach(() => {
fixture.destroy();
comp = null;
});
it('some thing should happen when we do some thing', () => {
someObject.doSomething();
expect(someThing).toBe(thisValue);
});
});
The testing environment is arranged using Angular’s TestBed so that we can test this component in isolation, and we make sure to reset the component before each test (and destroy it afterward). Then we act inside the actual test:
someObject.doSomething();
this gets our application into the state that we want to test, and then we make our assertion:
expect(someThing).toBe(thisValue);
At this point we expect that someThing
will be thisValue
and if it is not the test should fail. These are just some made up values to illustrate a point.
This works fine in a case like this where everything is executed synchronously, but what happens if our test contains some asynchronous code? Something like this:
it('some thing should happen when we do some thing', () => {
let flag = false;
let testPromise = new Promise((resolve) => {
// do some stuff
});
testPromise.then((result) => {
flag = true;
});
expect(flag).toBe(true);
});
If you’re familiar with asynchronous code (and if you’re not, you should watch this) then you would know that since flag = true
is being called from within a promise handler (i.e. it is asynchronous) the expect
statement is going to run before flag = true
does. This means that our test will fail because it hasn’t had time to finish executing properly.
The flow of our test would look something like this:
- Set
flag
tofalse
- Create promise
- Set up handler for promise
- Expect that
flag
istrue
- Run promise handler, which sets
flag
totrue
This is a problem, what we really want is:
- Set
flag
tofalse
- Create promise
- Set up handler for promise
- Run promise handler, which sets
flag
totrue
- Expect that
flag
istrue
One quick solution to this problem would be to simply add the expect
statement inside of the promise callback, and that’s probably how we would deal with a similar situation in our actual application, but it is not ideal for tests. Victor Savkin goes into more detail about why this is the case, as well as a lot more detail in general about this topic, in his article Controlling Time with Zone.js and FakeAsync.
The better solution to this problem is to use the fakeAsync
helper that Angular provides, which essentially gives you an easy way to run asynchronous code before your assertions.
Introducing FakeAsync, flushMicrotasks, and tick
Depending on your familiarity level with Angular, you may or may not have heard of Zone.js
. Zone.js
is included in Angular and patches the browser so that we can detect when asynchronous code like Promises
and setTimeout
complete. This is used by Angular to trigger its change detection, which is a weird, complex, and wonderful process I tried to cover somewhat in this article. It’s important that Angular knows when asynchronous operations complete, because perhaps one of those operations might change some property binding that now needs to be reflected in a template.
So, the zone that Angular uses allows it to detect when asynchronous functions complete. FakeAsync
is similar in concept, except that it kind of “catches” any asynchronous operations. Any asynchronous code that is triggered is added to an array, but it is never executed… until we tell it to be executed. Our tests can then wait until those operations have completed before we make our assertions.
When a test is running within a fakeAsync
zone, we can use two functions called flushMicrotasks
and tick
. The tick
function will advance time by a specified number of milliseconds, so tick(100)
would execute any asynchronous tasks that would occur within 100ms. The flushMicrotasks
function will clear any “microtasks” that are currently in the queue.
I’m not going to attempt to explain what a microtask is here, so I would highly recommend giving Tasks, microtasks, queues, and schedules a read.
In short, a microtask is created when we perform asynchronous tasks like setting up a handler for a promise. However, not all asynchronous code is added as microtasks, some things like setTimeout
are added as normal tasks or macrotasks. This is an important difference because flushMicrotasks
will not execute timers like setTimeout
.
To use fakeAsync
, flushMicrotasks
, and tick
in your tests, all you need to do is import them:
import {
TestBed,
ComponentFixture,
inject,
async,
fakeAsync,
tick,
flushMicrotasks,
} from '@angular/core/testing';
and then wrap your tests with fakeAsync
:
it('should test some asynchronous code', fakeAsync(() => {}));
this will cause your tests to be executed in the fakeAsync
zone. Now inside of those tests you can call flushMicrotasks
to run any pending microtasks, or you can call tick()
with a specific number of milliseconds to execute any asynchronous code that would occur within that timeframe.
Examples of Testing Asynchronous Code in Ionic and Angular
If you’ve read this far, hopefully, the general concept makes at least some sense. Basically, we wrap the test in fakeAsync
and then we call either flushMicrotasks()
or tick
whenever we want to run some asynchronous code before making an assertion in the test.
There are a few subtleties that can trip you up, though, so I want to go through a few examples and discuss the results. Seeing a few tests in action should also help solidify the concept.
Let’s start with a basic example that is available in the Angular documentation:
it('should test some asynchronous code', fakeAsync(() => {
let flag = false;
setTimeout(() => {
flag = true;
}, 100);
expect(flag).toBe(false); // PASSES
tick(50);
expect(flag).toBe(false); // PASSES
tick(50);
expect(flag).toBe(true); // PASSES
}));
The flag
is initially false
, but we have a setTimeout
(a macrotask, not a microtask) that changes the flag after 100ms
. Time is progressed by 50ms
and we expect the flag to still be false
, it is then progressed by another 50ms
and we expect the flag
to be true
. This test will pass all of these expectations, because when we expect that the flag is true
, 100ms
of time has passed and the setTimeout
has had time to execute its code.
If we were to do this instead:
it('should test some asynchronous code', fakeAsync(() => {
let flag = false;
setTimeout(() => {
flag = true;
}, 100);
expect(flag).toBe(false);
flushMicrotasks();
expect(flag).toBe(true); // FAILS
}));
The test would fail because a setTimeout
is not a microtask, and flushMicrotasks()
will not cause it to execute. Let’s take a look at an example with a promise:
it('should test some asynchronous code', fakeAsync(() => {
let flag = false;
Promise.resolve(true).then((result) => {
flag = true;
});
flushMicrotasks();
expect(flag).toBe(true); // PASSES
}));
This time we switch the flag
to true
inside of a promise handler. Since the promise handler is a microtask, it will be executed when we call flushMicrotasks
and so our test will pass. We could also use tick
instead of flushMicrotasks
and it would still work.
What about two promises?
it('should test some asynchronous code', fakeAsync(() => {
let flagOne = false;
let flagTwo = false;
Promise.resolve(true).then((result) => {
flagOne = true;
});
Promise.resolve(true).then((result) => {
flagTwo = true;
});
flushMicrotasks();
expect(flagOne).toBe(true); // PASSES
expect(flagTwo).toBe(true); // PASSES
}));
This test has two flags, and it switches their values inside of two separate promises. We call flushMicrotasks
(but we could also call tick
) and then expect them both to be true
. This test will also work, because flushMicrotasks
will clear all of the microtasks that are currently in the queue.
What about Observables?
import { from } from 'rxjs';
//...snip
it('should test some asynchronous code', fakeAsync(() => {
let testObservable = from(Promise.resolve(true));
let flag = false;
testObservable.subscribe((result) => {
flag = true;
});
flushMicrotasks();
expect(flag).toBe(true); // PASSES
}));
Yep! That will also work. But now let’s take a look at a more complicated example that won’t work:
it('should test some asynchronous code', fakeAsync(() => {
let flag: any = false;
let testPromise = new Promise((resolve) => {
setTimeout(() => {
resolve(true);
}, 3000);
});
testPromise.then((result) => {
flag = result;
});
expect(flag).toBe(false); // PASSES
flushMicrotasks();
expect(flag).toBe(true); // FAILS
}));
We are switching the value of flag
inside of a promise again, so you might think that this would work if we called flushMicrotasks
. But, inside of the promise we are triggering a new macrotask
and the promise will not resolve until the setTimeout
triggers, which happens after 3000ms
.
In order for this code to work, we would need to use the tick
function instead:
it('should test some asynchronous code', fakeAsync(() => {
let flag: any = false;
let testPromise = new Promise((resolve) => {
setTimeout(() => {
resolve(true);
}, 3000);
});
testPromise.then((result) => {
flag = result;
});
expect(flag).toBe(false); // PASSES
tick(3000);
expect(flag).toBe(true); // PASSES
}));
Summary
As complex and confusing as this may seem at first, it is actually quite an elegant way to deal with a complicated problem. By using fakeAsync
we can ensure that all of our asynchronous code has run before we make assertions in our tests, and we even have fine tuned control over how we want to advance time throughout the test.