Unit-Test Asynchrone

"Async Gotcha"

describe('new planet rules', () => {

    it('should agree that 1 is equal to 2', () => {

        setTimeout(() => {
            expect(1).toEqual(2);
        });

    });

});

WTF !? 🤔

Success !!!

La fonction de "callback" associée au setTimeout n'est appelée qu'au prochain "tick", après l'exécution de la "spec". L'assertion est donc ignorée car Jasmine n'arrive à l'associer à aucun test.

Solution n°1 : Fonction done 👎

Pour indiquer à Jasmine que la "spec" est asynchrone, il faut ajouter le paramètre done à la fonction de "spec".

describe('new planet rules', () => {

    it('should agree that 1 is equal to 2', (done) => {
        // Nothing 😪
    });

});

On obtient alors l'erreur suivante :

Timeout - Async callback was not invoked within timeout specified by jasmine.DEFAULT_TIMEOUT_INTERVAL.

La fonction done doit être appelée explicitement à la fin de la "spec".

describe('new planet rules', () => {

    it('should agree that 1 is equal to 2', (done) => {

        setTimeout(() => {
            expect(1).toEqual(2);
            done();
        });

    });

});

Cette fois-ci, la "spec" échoue rapidement à cause de l'assertion.

Solution n°2 : Promise et async / await ✌️

Plutôt que d'utiliser la fonction done, la fonction de "spec" peut retourner une Promise dont la résolution signale la fin.

describe('new planet rules', () => {

    it('should not affect arithmetic rules', () => {

        const promise1 = new Promise(resolve => {

            setTimeout(() => {
                expect(1).not.toEqual(2);
                resolve();
            });

        });

        const promise2 = new Promise(resolve => {

            setTimeout(() => {
                expect(1).not.toEqual(3);
                resolve();
            });

        });

        return Promise.all([promise1, promise2]);

    });

});

Plus simple et plus sexy, il est possible d'utiliser async / await.

describe('new planet rules', () => {

    it('should not affect arithmetic rules', async () => {

        await new Promise(resolve => {

            setTimeout(() => {
                expect(1).not.toEqual(2);
                resolve();
            });

        });

        await new Promise(resolve => {

            setTimeout(() => {
                expect(1).not.toEqual(3);
                resolve();
            });

        });

    });

});

Solution n°3 : Fonction async() 🤟

La fonction Angular async (à ne pas confondre avec la syntaxe ECMAScript async / await) est une fonction dédiée aux tests Angular.

En créant une "Zone" (Cf. Zone.JS) autour de la "spec" et grâce au "Monkey Patching" de toutes les sources d'exécution asynchrone (setTimeout etc...), la fonction async retourne une fonction de "spec" asynchrone et appelle la fonction done quand tous les traitements asynchrones détectées sont terminés (i.e. la queue de l'Event Loop et vide et plus aucun traitement en attente).

import { async } from '@angular/core/testing';

describe('new planet rules', () => {

    it('should not affect arithmetic rules', async(() => {

        setTimeout(() => {
            expect(1).not.toEqual(2);
        });

    }));

});

Avantages

Cette approche a pour avantage :

  • d'être simple d'utilisation,

  • d'être moins "Error-Prone" que les approches précédentes,

  • de garantir que Jasmine ne passera à la "spec" suivante que quand tous les traitements asynchrones seront terminés.

Limitations

Testons cette station météo capricieuse :

import { Observable, timer } from 'rxjs';
import { filter, map, mapTo } from 'rxjs/operators';

class PickyWeatherStation {

    getTemperature(city): Observable<number> {

        return timer(1000)
            .pipe(
                mapTo(city),
                filter(_city => _city !== 'Paris'),
                map(_city => 100 / _city.length)
            );

    }

}
describe('PickyWeatherStation', () => {

    let weatherStation: PickyWeatherStation;

    beforeEach(() => {
        weatherStation = new PickyWeatherStation();
    });

    it('should give temperature', async(() => {

        weatherStation.getTemperature('Paris')
            .subscribe(temperature => {
                expect(temperature).toEqual(-10);
            });

    }));

});

Bien que l'assertion expect(temperature).toEqual(-10) soit erronée, la "spec" réussit.

Solution n°4 : Fonction fakeAsync() 💪

La fonction Angular fakeAsync permet de contrôler l'"Event Loop" et le "Timer" 🎉.

Elle utilise également une "Zone" (Cf. Zone.JS) mais contrairement à la fonction async, celle-ci n'attend pas la fin d'exécution des traitements asynchrones mais déclenche des erreurs à la fin de la "spec" si des traitements sont encore en attente.

import { fakeAsync } from '@angular/core/testing';

...

    it('should give temperature', fakeAsync(() => {

        weatherStation.getTemperature('Paris')
            .subscribe(temperature => {
                expect(temperature).toEqual(-10);
            });

    }));

La "spec" s'exécute plus rapidement car elle n'attend pas le délai d'une seconde mais elle produit rapidement l'erreur suivante :

1 periodic timer(s) still in the queue.

tick() & flush()

La fonction fakeAsync est accompagnée des deux fonctions tick et flush qui permettent de contrôler la "Fake Event Loop" créée par la fonction fakeAsync.

tick()

La fonction tick permet de déclencher le prochain traitement en attente dans la "queue" de l'"Event Loop" :

    it('should trigger next tick', fakeAsync(() => {

        let value;

        setTimeout(() => value = 'VALUE');

        tick();

        expect(value).toEqual('VALUE');

    }));

tick(ms)

La fonction tick permet également de simuler l'attente en lui donnant en paramètre le nombre de millisecondes à simuler.

it('should control time', fakeAsync(() => {

    let value;

    setTimeout(() => value = 'VALUE', 1000);

    tick(999);

    expect(value).toEqual(undefined);

    tick(1000);

    expect(value).toEqual('VALUE');

}));

flush()

Et finalement, la fonction flush déclenche les (ou le) traitements en attente et retourne la valeur en millisecondes du temps d'attente simulé.

it('should trigger next tick', fakeAsync(() => {

    let valueList = [];

    setTimeout(() => valueList = [...valueList, 'WISH'], 1000);
    setTimeout(() => valueList = [...valueList, 'TACK'], 2000);

    expect(valueList).toEqual([]);

    const duration = flush();

    expect(valueList.join('')).toEqual('WISHTACK');

    expect(duration).toEqual(2000);

}));

Grâce à la fonction fakeAsync, on peut donc remédier aux limitations associées à la fonction async ainsi :

it('should give temperature', fakeAsync(() => {

    let temperature;

    weatherStation.getTemperature('Paris')
        .subscribe(_temperature => temperature = _temperature);

    tick(1000);

    expect(temperature).toBe(20);

}));

Le test échoue alors car la "callback" n'a pas été appelée et temperature vaut donc undefined.

Detection des effets de bord

Dernière mise à jour