Unit Testing in Ionic

Testing Asynchronous Code with FakeAsync in Angular



·

When creating automated unit tests in Ionic and Angular applications we would typically follow a process like this:

  1. Set up the testing environment
  2. Run some code
  3. 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 { TestBed, ComponentFixture, async } from '@angular/core/testing';
import { IonicModule } from 'ionic-angular';
import { MyApp } from './app.component';
import { LoginPage } from '../pages/login/login';
 
let comp: MyApp;
let fixture: ComponentFixture<MyApp>;
 
describe('Component: Root Component', () => {
 
    beforeEach(async(() => {
 
        TestBed.configureTestingModule({
 
            declarations: [MyApp],
 
            providers: [
 
            ],
 
            imports: [
                IonicModule.forRoot(MyApp)
            ]
 
        }).compileComponents();
 
    }));
 
    beforeEach(() => {
 
        fixture = TestBed.createComponent(MyApp);
        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 of course 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 callback (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:

  1. Set flag to false
  2. Create promise
  3. Set up callback for promise
  4. Expect that flag is true
  5. Run promise callback, which sets flag to true

This is a problem, what we really want is:

  1. Set flag to false
  2. Create promise
  3. Set up callback for promise
  4. Run promise callback, which sets flag to true
  5. Expect that flag is true

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 do so. 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, though, a microtask is created when we perform asynchronous tasks like setting up a callback 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. 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 callback. Since the promise callback 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?

it('should test some asynchronous code', fakeAsync(() => {

    let testObservable = Observable.of(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 = 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 = 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.

What to watch next...