Tests unitaires avec des timers en Javascript

Sur le développement de Serenity, l’un de mes projets actuels, je suis tombé sur une situation où je veux créer une tâche de fond périodique.
J’essaye d’appliquer la méthode TDD. Je commence donc par créer un fichier de test. Et là, je m’aperçois que je n’ai pas souvenir d’avoir déjà fait des tests propres impliquant la gestion du temps avec NodeJs.


Ma situation

J’utilise principalement MochaJs pour mes tests, et je me contente du module inclus dans NodeJs pour les assertions. En plus de cela, j’ai quelques libs pour tester des points spécifiques (nock pour le réseau), mais je n’ai jamais utilisé de bibliothèque de Mock/Spies/Stubs.

En interne, j’utilise setTimeout(). Je veux pouvoir tester qu’une tâche est bien créée, tester l’exécution de cette tâche, mais aussi être sûr qu’il ne reste aucun timer à la fin d’un test.
Pour les petites durées, une solution est d’attendre que le temps passe. C’est envisageable pour des tâches de quelques millisecondes, mais ce n’est pas le cas ici.
La deuxième solution évidente est de coder à la main un mock des fonctions de timer. Mais cela compliquerait le code des tests unitaires: il y a plusieurs fonctions à remplacer et c’est propice aux erreurs. On veut garder le code des tests unitaires le plus simple possible.

Un tour des solutions existantes

  • Jasmine est un autre framework de test, un concurrent de Mocha avec plus de fonctionnalités. Il propose une classe Clock, qui permet de manipuler le temps: on peut définir la date actuelle, et faire “avancer” le temps. Dans nos tests, on va alors tester le résultat (Est-ce que la tâche a été exécutée à t=X ?) sans se préoccuper de l’implémentation interne. En fait, on n’a pas moyen de voir ce qu’il se passe en interne, l’API de Jasmine est trop limitée. Par exemple, il est impossible de savoir si un timer a été créé en arrière-plan sans faire avancer le temps pour exécuter ce timer.

  • Jest est un troisième framework de test, lui aussi batteries incluses. Sa gestion des mock timers permet plus de chose que Jasmine: on peut avancer dans le temps, mais aussi tester la présence d’appel aux méthodes natives des timers, et avancer jusqu’à exécution des timers existants.

Ces deux outils ne sont pas compatibles avec Mocha, mais c’est intéressant de voir ce qu’il se fait ailleurs.

Contrairement aux autres frameworks de tests, MochaJs ne possède que très peu d’outils pour faciliter les tests. L’utilisation habituelle est d’ajouter une bibliothèque dédiée aux Mock/Stubs/Spies. La plus populaire d’entre-elles est SinonJS. C’est celle que l’on utilisera.

  • SinonJS propose des objets Fake timers. La documentation, de prime abord pas très détaillée, cache en réalité une bibliothèque très complète de gestion du temps: Lolex. Il inclut toutes les fonctions que l’on peut souhaiter: en plus du mock des fonctions de timer, on retrouve le support de process.nextTick(), de requestAnimationFrame(), ainsi que des points particuliers comme la simulation du changement de l’heure du système.

Cas particulier de test: Tâche périodique et Promise

Ma tâche de fond exécute une fonction asynchrone de façon périodique. Cette fonction elle-même peut prendre du temps, et retourne donc une Promise. J’utilise setTimeout pour relancer la tâche à la fin de chaque exécution plutôt que setInterval, qui pourrait poser des problèmes si la tâche prend trop de temps à s’exécuter.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Version très simplifiée de ma tâche périodique
class MyPeriodicTask() {
constructor() {
setTimeout(3600 * 1000, this._executeTask());
}

_executeTask() {
let promise = this._task();
promise.then(_ => setTimeout(3600 * 1000, this._executeTask()));
}

async _task() {
console.log('Do stuff ...');
return Promise.resolve('done!');
}
}

L’un de mes tests unitaires va vérifier qu’un nouveau timer est bien créé à la fin de la tâche.
Voici l’implémentation naïve:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const sinon = require('sinon');
const MyPeriodicTask = require('../my_periodic_task.js')

describe('MyPeriodicTask', function() {

// On créé un nouveau contexte pour chaque test, qui va capturer les appels à `setTimeout`.
// Les timers existants à la fin d'un test sont supprimés.
let clock = null;
beforeEach(() => clock = sinon.useFakeTimers());
afterEach(() => clock.restore());

it('should set a new timer after the task has been executed', function() {
// Le 1er timer est créé
new MyPeriodicTask();

// On vérifie avec clock: il y a bien 1 timer enregistré.
assert.deepStrictEqual(clock.countTimers(), 1);

// On déclenche ce timer. Cela va appeler la task.
clock.runToLast();

// On veut que le prochain timer soit présent.
assert.deepStrictEqual(clock.countTimers(), 1);
});
});

Et là, … c’est le drame!

1
2
3
4
5
> mocha
1) MyPeriodicTask
should set a new timer after the task has been executed:

AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: 0 != 1

Le problème ? La tâche de fond est asynchrone. Le nouveau timer n’est créé que lorsque la Promise est résolue.

Pour tester correctement, je dois attendre la résolution de la Promise. Pour cela, je vais utiliser setImmediate(). En effet, cette fonction permet de placer une callback qui sera toujours appelée après la résolution de toutes les chaînes de Promises qui n’utilisent pas des fonctions bloquantes.
Oui, le nom de la fonction est trompeur.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

// Évidemment, 'lolex' va créer un fake 'setImmediate'.
// On garde une référence vers la véritable fonction.
let realSetImmediate = setImmediate;

function waitForPromiseResolution() {
// Cette fonction retourne elle-même une Promise, mais ...
// celle-ci ne sera résolue que lorsque toutes les Promises non bloquantes auront été exécutées.
return new Promise(r => realSetImmediate(r));
}


// NOTE: le test est maintenant une fonction async.
it('should set a new timer after the task has been executed', async function() {
// Le 1er timer est créé
new MyPeriodicTask();

// On vérifie avec clock: il y a bien 1 timer enregistré.
assert.deepStrictEqual(clock.countTimers(), 1);

// On déclenche ce timer. Cela va appeler la task.
clock.runToLast();

// On attend que toute les promises soient résolues.
await waitForPromiseResolution();

// Hourra! Le prochain timer est enregistré.
assert.deepStrictEqual(clock.countTimers(), 1);
});

L’astuce du setImmediate() ne marche que pour certains cas particuliers: quand la tâche de fond ne fait pas d’IO. On arrive là sur un problème de conception: il faut que le code sous-jacent devrait toujours être découpé et pensé pour être testable.
Dans mon cas d’utilisation réelle, la fonction _task() est ajoutée par injection de dépendance. La gestion des timers et la tâche elle-même sont deux fonctionnalités distinctes, elles sont donc découplées. Dans les tests, je passe la tâche que je veux.