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()
, derequestAnimationFrame()
, 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 | // Version très simplifiée de ma tâche périodique |
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 | const sinon = require('sinon'); |
Et là, … c’est le drame!
1 | > mocha |
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 |
|
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.