Écrire du code testable en JavaScript : un bref aperçu

Publié: 2022-03-11

Que nous utilisions Node associé à un framework de test comme Mocha ou Jasmine, ou que nous lancions des tests dépendants de DOM dans un navigateur sans tête comme PhantomJS, nos options pour les tests unitaires JavaScript sont meilleures que jamais.

Cependant, cela ne signifie pas que le code que nous testons est aussi facile pour nous que nos outils ! Organiser et écrire un code facilement testable demande des efforts et de la planification, mais il existe quelques modèles, inspirés des concepts de programmation fonctionnelle, que nous pouvons utiliser pour éviter de nous retrouver dans une situation difficile au moment de tester notre code. Dans cet article, nous allons passer en revue quelques astuces et modèles utiles pour écrire du code testable en JavaScript.

Séparez la logique métier et la logique d'affichage

L'une des principales tâches d'une application de navigateur basée sur JavaScript consiste à écouter les événements DOM déclenchés par l'utilisateur final, puis à y répondre en exécutant une logique métier et en affichant les résultats sur la page. Il est tentant d'écrire une fonction anonyme qui fait le gros du travail là où vous configurez vos écouteurs d'événements DOM. Le problème que cela crée est que vous devez maintenant simuler des événements DOM pour tester votre fonction anonyme. Cela peut créer une surcharge à la fois dans les lignes de code et dans le temps nécessaire à l'exécution des tests.

Au lieu de cela, écrivez une fonction nommée et transmettez-la au gestionnaire d'événements. De cette façon, vous pouvez écrire des tests pour des fonctions nommées directement et sans sauter à travers des cerceaux pour déclencher un faux événement DOM.

Cela s'applique cependant à plus que le DOM. De nombreuses API, à la fois dans le navigateur et dans Node, sont conçues pour déclencher et écouter des événements ou attendre que d'autres types de travaux asynchrones se terminent. En règle générale, si vous écrivez de nombreuses fonctions de rappel anonymes, votre code peut ne pas être facile à tester.

 // hard to test $('button').on('click', () => { $.getJSON('/path/to/data') .then(data => { $('#my-list').html('results: ' + data.join(', ')); }); }); // testable; we can directly run fetchThings to see if it // makes an AJAX request without having to trigger DOM // events, and we can run showThings directly to see that it // displays data in the DOM without doing an AJAX request $('button').on('click', () => fetchThings(showThings)); function fetchThings(callback) { $.getJSON('/path/to/data').then(callback); } function showThings(data) { $('#my-list').html('results: ' + data.join(', ')); }

Utiliser des rappels ou des promesses avec du code asynchrone

Dans l'exemple de code ci-dessus, notre fonction fetchThings refactorisée exécute une requête AJAX, qui effectue la majeure partie de son travail de manière asynchrone. Cela signifie que nous ne pouvons pas exécuter la fonction et tester qu'elle a fait tout ce que nous attendions, car nous ne saurons pas quand elle aura fini de s'exécuter.

La façon la plus courante de résoudre ce problème consiste à transmettre une fonction de rappel en tant que paramètre à la fonction qui s'exécute de manière asynchrone. Dans vos tests unitaires, vous pouvez exécuter vos assertions dans le rappel que vous passez.

Illustration : Utilisation d'une fonction de rappel comme paramètre dans les tests unitaires

L'API Promise est un autre moyen courant et de plus en plus populaire d'organiser le code asynchrone. Heureusement, $.ajax et la plupart des autres fonctions asynchrones de jQuery renvoient déjà un objet Promise, de sorte que de nombreux cas d'utilisation courants sont déjà couverts.

 // hard to test; we don't know how long the AJAX request will run function fetchData() { $.ajax({ url: '/path/to/data' }); } // testable; we can pass a callback and run assertions inside it function fetchDataWithCallback(callback) { $.ajax({ url: '/path/to/data', success: callback, }); } // also testable; we can run assertions when the returned Promise resolves function fetchDataWithPromise() { return $.ajax({ url: '/path/to/data' }); }

Évitez les effets secondaires

Écrivez des fonctions qui prennent des arguments et renvoient une valeur basée uniquement sur ces arguments, tout comme la saisie de nombres dans une équation mathématique pour obtenir un résultat. Si votre fonction dépend d'un état externe (les propriétés d'une instance de classe ou le contenu d'un fichier, par exemple), et que vous devez configurer cet état avant de tester votre fonction, vous devez faire plus de configuration dans vos tests. Vous devrez être sûr que tout autre code en cours d'exécution ne modifie pas le même état.

Illustration : effet en cascade causé par un état externe.

Dans le même ordre d'idées, évitez d'écrire des fonctions qui modifient l'état externe (comme écrire dans un fichier ou enregistrer des valeurs dans une base de données) pendant son exécution. Cela évite les effets secondaires qui pourraient affecter votre capacité à tester d'autres codes en toute confiance. En général, il est préférable de garder les effets secondaires aussi près que possible des bords de votre code, avec le moins de « surface » possible. Dans le cas de classes et d'instances d'objets, les effets secondaires d'une méthode de classe doivent être limités à l'état de l'instance de classe testée.

 // hard to test; we have to set up a globalListOfCars object and set up a // DOM with a #list-of-models node to test this code function processCarData() { const models = globalListOfCars.map(car => car.model); $('#list-of-models').html(models.join(', ')); } // easy to test; we can pass an argument and test its return value, without // setting any global values on the window or checking the DOM the result function buildModelsString(cars) { const models = cars.map(car => car.model); return models.join(','); }

Utiliser l'injection de dépendance

Un modèle courant pour réduire l'utilisation d'un état externe par une fonction est l'injection de dépendances - en passant tous les besoins externes d'une fonction en tant que paramètres de fonction.

 // depends on an external state database connector instance; hard to test function updateRow(rowId, data) { myGlobalDatabaseConnector.update(rowId, data); } // takes a database connector instance in as an argument; easy to test! function updateRow(rowId, data, databaseConnector) { databaseConnector.update(rowId, data); }

L'un des principaux avantages de l'utilisation de l'injection de dépendances est que vous pouvez transmettre des objets fictifs de vos tests unitaires qui ne provoquent pas d'effets secondaires réels (dans ce cas, la mise à jour des lignes de la base de données) et vous pouvez simplement affirmer que votre objet fictif a été utilisé. de la manière attendue.

Donnez à chaque fonction un but unique

Divisez les fonctions longues qui font plusieurs choses en une collection de fonctions courtes à usage unique. Cela rend beaucoup plus facile de tester que chaque fonction fait correctement sa part, plutôt que d'espérer qu'une grande fait tout correctement avant de renvoyer une valeur.

En programmation fonctionnelle, le fait d'enchaîner plusieurs fonctions à but unique s'appelle la composition. Underscore.js a même une fonction _.compose , qui prend une liste de fonctions et les enchaîne, prenant la valeur de retour de chaque étape et la passant à la fonction suivante en ligne.

 // hard to test function createGreeting(name, location, age) { let greeting; if (location === 'Mexico') { greeting = '!Hola'; } else { greeting = 'Hello'; } greeting += ' ' + name.toUpperCase() + '! '; greeting += 'You are ' + age + ' years old.'; return greeting; } // easy to test function getBeginning(location) { if (location === 'Mexico') { return 'Hola'; } else { return 'Hello'; } } function getMiddle(name) { return ' ' + name.toUpperCase() + '! '; } function getEnd(age) { return 'You are ' + age + ' years old.'; } function createGreeting(name, location, age) { return getBeginning(location) + getMiddle(name) + getEnd(age); }

Ne pas muter les paramètres

En JavaScript, les tableaux et les objets sont passés par référence plutôt que par valeur, et ils sont modifiables. Cela signifie que lorsque vous transmettez un objet ou un tableau en tant que paramètre dans une fonction, votre code et la fonction que vous avez transmis à l'objet ou au tableau ont la possibilité de modifier la même instance de ce tableau ou de cet objet en mémoire. Cela signifie que si vous testez votre propre code, vous devez avoir confiance qu'aucune des fonctions que votre code appelle ne modifie vos objets. Chaque fois que vous ajoutez un nouvel endroit dans votre code qui modifie le même objet, il devient de plus en plus difficile de savoir à quoi cet objet devrait ressembler, ce qui le rend plus difficile à tester.

Illustration : la mutation des paramètres peut entraîner des problèmes

Au lieu de cela, si vous avez une fonction qui prend un objet ou un tableau, faites-la agir sur cet objet ou ce tableau comme s'il était en lecture seule. Créez un nouvel objet ou tableau dans le code et ajoutez-y des valeurs en fonction de vos besoins. Ou, utilisez Underscore ou Lodash pour cloner l'objet ou le tableau passé avant de l'utiliser. Encore mieux, utilisez un outil comme Immutable.js qui crée des structures de données en lecture seule.

 // alters objects passed to it function upperCaseLocation(customerInfo) { customerInfo.location = customerInfo.location.toUpperCase(); return customerInfo; } // sends a new object back instead function upperCaseLocation(customerInfo) { return { name: customerInfo.name, location: customerInfo.location.toUpperCase(), age: customerInfo.age }; }

Écrivez vos tests avant votre code

Le processus d'écriture des tests unitaires avant le code qu'ils testent est appelé développement piloté par les tests (TDD). De nombreux développeurs trouvent que TDD est très utile.

En écrivant d'abord vos tests, vous êtes obligé de penser à l'API que vous exposez du point de vue d'un développeur qui la consomme. Cela permet également de s'assurer que vous n'écrivez que suffisamment de code pour respecter le contrat appliqué par vos tests, plutôt que de surconcevoir une solution inutilement complexe.

En pratique, TDD est une discipline qui peut être difficile à respecter pour tous vos changements de code. Mais quand cela vaut la peine d'essayer, c'est un excellent moyen de garantir que tout le code reste testable.

Emballer

Nous savons tous qu'il existe quelques pièges auxquels il est très facile de tomber lors de l'écriture et du test d'applications JavaScript complexes. Mais j'espère qu'avec ces conseils, et en nous rappelant de toujours garder notre code aussi simple et fonctionnel que possible, nous pouvons maintenir notre couverture de test élevée et la complexité globale du code faible !

En rapport:
  • Les 10 erreurs les plus courantes commises par les développeurs JavaScript
  • Le besoin de vitesse : une rétrospective Toptal JavaScript Coding Challenge