Tester les effets secondaires de la promesse avec Async/Await

promesse de test

Vous avez peut-être rencontré des situations dans lesquelles vous appelez du code asynchrone dans un rappel d'un framework et vous devez tester leurs effets secondaires. Par exemple, vous effectuez peut-être des appels d'API dans le fichier d'un composant React. componentDidMount() rappel qui appellera à son tour setState() lorsque la requête est terminée et que vous souhaitez affirmer que le composant est dans un certain état. Cet article présente des techniques pour tester ces types de scénarios.
Prenons un exemple simplifié. Nous avons une classe appelée PromisesHaveFinishedIndicator. Le constructeur prend en compte une liste de promesses. Lorsque toutes les promesses ont été résolues, l'instance finished la propriété est définie sur true:

class PromisesHaveFinishedIndicator { constructor(promises) { this.finished = false;    Promise.all(promises).then(() => { this.finished = true; });  } }

Un bon scénario de test impliquerait d'appeler le constructeur avec plusieurs promesses dont nous pouvons contrôler le timing de résolution, et d'écrire les attentes concernant la valeur de this.finished à mesure que chaque promesse est résolue.
Afin de contrôler les délais de résolution des promesses dans les tests, nous utilisons Deferred objets qui exposent le resolve et reject méthodes:

class Deferred { constructor() { this.promise = new Promise((resolve, rejet) => { this.resolve = solve; this.reject = rejet; });  } }

Avec cela, nous pouvons mettre en place un test pour PromisesHaveFinishedIndicator. Nous utilisons le il y a framework de test dans cet exemple, mais la technique peut également être appliquée à d'autres frameworks de test :

test('définit fini sur vrai une fois que toutes les promesses ont été résolues', () => { const d1 = new Deferred(); const d2 = new Deferred(); const indicateur = new PromisesHaveFinishedIndicator([d1.promise, d2.promise] ); attendre(indicateur.terminé).toBe(false); d2.resolve(); attendre(indicateur.fini).toBe(false d1.resolve(); });

Ce test échouera en fait car les rappels de promesse sont asynchrones, donc tous les rappels présents dans la file d'attente seront exécutés après la dernière instruction de ce test en raison de courir jusqu'à la fin sémantique. En d'autres termes, le rappel de promesse pour le Promise.all appel: () => { this.finished = true; } aura été exécuté après la fin de ce test !
Jest (et d'autres frameworks de test) fournit un moyen de gérer l'asynchronie en empêchant la fermeture du test après la dernière instruction. Il faudrait appeler le fournisseur done fonction afin d'informer le coureur que l'épreuve est terminée. Maintenant, vous pensez peut-être que quelque chose comme ceci fonctionnerait :

test('définit la fin sur true une fois que toutes les promesses ont été résolues', (done) => { const d1 = new Deferred(); const d2 = new Deferred(); const indicateur = new PromisesHaveFinishedIndicator([d1.promise, d2.promise ]); attendre(indicateur.fini).toBe(false); d2.resolve(); d2.then(() => { attendre(indicateur.fini).toBe(false); d1.resolve(); d1. then(() => { expect(indicator.finished).toBe(true); done(); } });

Cependant, cela échouera également. La raison réside dans la mise en œuvre de Promise.all. Quand resolve est appelé d1 (Et d2 ainsi que), Promise.all planifie un rappel qui vérifie si toutes les promesses ont été résolues. Si cette vérification renvoie vrai, elle résoudra la promesse renvoyée par le Promise.all appel qui mettrait alors en file d'attente le () => { this.finished = true; } rappeler. Ce rappel est toujours dans la file d'attente au moment où done est appelé!
Maintenant, la question est de savoir comment effectuer le rappel qui définit this.finished à true courir avant d'appeler done? Pour répondre à cette question, nous devons comprendre comment les rappels de promesses sont planifiés lorsque les promesses sont résolues ou rejetées. L'article de Jake Archibald sur Tâches, microtâches, files d'attente et plannings aborde en profondeur exactement ce sujet et je recommande fortement de le lire.
En résumé : les rappels de promesses sont mis en file d'attente dans la file d'attente des microtâches et les rappels d'API telles que setTimeout(fn) et setInterval(fn) sont mis en file d'attente dans la file d'attente des macrotâches. Les rappels placés dans la file d'attente des microtâches sont exécutés juste après que la pile se soit vidée, et si une microtâche planifie une autre microtâche, ils seront continuellement retirés de la file d'attente avant de céder à la file d'attente des macrotâches.
Forts de ces connaissances, nous pouvons réussir ce test en utilisant setTimeout au lieu de then():

test('définit la fin sur true une fois que toutes les promesses ont été résolues', (done) => { const d1 = new Deferred(); const d2 = new Deferred(); const indicateur = new PromisesHaveFinishedIndicator([d1.promise, d2.promise ]); attendre(indicateur.fini).toBe(false); d2.resolve(); setTimeout(() => { attendre(indicateur.fini).toBe(false); d1.resolve(); setTimeout(() => { attendre(indicateur.fini).toBe(true done(); 0 }, 0 });

La raison pour laquelle cela fonctionne est qu'à la seconde setTimeout le rappel s'exécute, nous savons que ces rappels de promesse ont été exécutés :

  • Le rappel dans l'implémentation de Promise.all qui vérifie que toutes les promesses ont été résolues, puis résout la promesse renvoyée.
  • Le rappel qui définit this.finished = true.

Avoir un tas de setTimeout(fn, 0) dans notre code est pour le moins inesthétique. Nous pouvons nettoyer cela avec le nouveau async/await syntaxe:

function flushPromises() { return new Promise((resolve, rejet) => setTimeout(resolve, 0)); } test('définit fini sur vrai une fois que toutes les promesses ont été résolues', async () => { const d1 = new Deferred(); const d2 = new Deferred(); const indicateur = new PromisesHaveFinishedIndicator([d1.promise, d2. promesse]); attendre(indicateur.fini).toBe(false); d2.resolve(); attendre flushPromises(); attendre(indicateur.fini).toBe(false d1.resolve(); attendre(indicateur.fini).toBe(true });

Si vous voulez être plus sophistiqué, vous pouvez utiliser setImmediate au lieu de setTimeout dans certains environnements (Node.js). C'est plus rapide que setTimeout mais s'exécute toujours après des microtâches :

function flushPromises() { return new Promise(resolve => setImmediate(resolve)); }

Lors de l'écriture de tests impliquant des promesses et de l'asynchronie, il est utile de comprendre comment les rappels sont planifiés et les rôles que jouent les différentes files d'attente dans la boucle d'événements. Avoir cette connaissance nous permet de raisonner avec le code asynchrone que nous écrivons.