Un guide Node.js pour effectuer réellement des tests d'intégration
Publié: 2022-03-11Les tests d'intégration ne sont pas quelque chose qui devrait être redouté. Ils sont essentiels pour que votre application soit entièrement testée.
Lorsque nous parlons de test, nous pensons généralement aux tests unitaires où nous testons un petit morceau de code de manière isolée. Cependant, votre application est plus grande que ce petit morceau de code et presque aucune partie de votre application ne fonctionne de manière isolée. C'est là que les tests d'intégration prouvent leur importance. Les tests d'intégration reprennent là où les tests unitaires échouent et ils comblent l'écart entre les tests unitaires et les tests de bout en bout.
Dans cet article, vous apprendrez à écrire des tests d'intégration lisibles et composables avec des exemples dans des applications basées sur des API.
Bien que nous utiliserons JavaScript/Node.js pour tous les exemples de code de cet article, la plupart des idées abordées peuvent être facilement adaptées aux tests d'intégration sur n'importe quelle plate-forme.
Tests unitaires vs tests d'intégration : vous avez besoin des deux
Les tests unitaires se concentrent sur une unité de code particulière. Il s'agit souvent d'une méthode spécifique ou d'une fonction d'un composant plus important.
Ces tests sont effectués de manière isolée, où toutes les dépendances externes sont généralement supprimées ou simulées.
En d'autres termes, les dépendances sont remplacées par un comportement préprogrammé, garantissant que le résultat du test n'est déterminé que par l'exactitude de l'unité testée.
Vous pouvez en savoir plus sur les tests unitaires ici.
Les tests unitaires sont utilisés pour maintenir un code de haute qualité avec une bonne conception. Ils nous permettent également de couvrir facilement les cas d'angle.
L'inconvénient, cependant, est que les tests unitaires ne peuvent pas couvrir l'interaction entre les composants. C'est là que les tests d'intégration deviennent utiles.
Essais d'intégration
Si les tests unitaires sont définis en testant les plus petites unités de code de manière isolée, alors les tests d'intégration sont tout le contraire.
Les tests d'intégration sont utilisés pour tester plusieurs unités plus grandes (composants) en interaction, et peuvent parfois même couvrir plusieurs systèmes.
Le but des tests d'intégration est de trouver des bogues dans les connexions et les dépendances entre divers composants, tels que :
- Passage d'arguments invalides ou mal ordonnés
- Schéma de base de données cassé
- Intégration de cache invalide
- Des failles dans la logique métier ou des erreurs dans le flux de données (parce que les tests sont désormais effectués à partir d'une vue plus large).
Si les composants que nous testons n'ont pas de logique compliquée (par exemple, des composants avec une complexité cyclomatique minimale), les tests d'intégration seront bien plus importants que les tests unitaires.
Dans ce cas, les tests unitaires seront principalement utilisés pour imposer une bonne conception de code.
Alors que les tests unitaires aident à garantir que les fonctions sont correctement écrites, les tests d'intégration aident à garantir que le système fonctionne correctement dans son ensemble. Ainsi, les tests unitaires et les tests d'intégration ont chacun leur propre objectif complémentaire, et les deux sont essentiels à une approche de test complète.
Les tests unitaires et les tests d'intégration sont comme les deux faces d'une même pièce. La pièce n'est pas valable sans les deux.
Par conséquent, les tests ne sont pas terminés tant que vous n'avez pas terminé les tests d'intégration et les tests unitaires.
Configurer la suite pour les tests d'intégration
Bien que la configuration d'une suite de tests pour les tests unitaires soit assez simple, la configuration d'une suite de tests pour les tests d'intégration est souvent plus difficile.
Par exemple, les composants des tests d'intégration peuvent avoir des dépendances extérieures au projet, telles que des bases de données, des systèmes de fichiers, des fournisseurs de messagerie, des services de paiement externes, etc.
Parfois, les tests d'intégration doivent utiliser ces services et composants externes, et parfois ils peuvent être supprimés.
Lorsqu'ils sont nécessaires, cela peut entraîner plusieurs défis.
- Exécution de test fragile : les services externes peuvent être indisponibles, renvoyer une réponse non valide ou être dans un état non valide. Dans certains cas, cela peut entraîner un faux positif, d'autres fois, cela peut entraîner un faux négatif.
- Exécution lente : la préparation et la connexion à des services externes peuvent être lentes. Habituellement, les tests sont exécutés sur un serveur externe dans le cadre de CI.
- Configuration de test complexe : les services externes doivent être dans l'état souhaité pour les tests. Par exemple, la base de données doit être préchargée avec les données de test requises, etc.
Instructions à suivre lors de l'écriture des tests d'intégration
Les tests d'intégration n'ont pas de règles strictes comme les tests unitaires. Malgré cela, il y a quelques directives générales à suivre lors de l'écriture de tests d'intégration.
Tests répétables
L'ordre des tests ou les dépendances ne doivent pas modifier le résultat du test. L'exécution du même test plusieurs fois devrait toujours renvoyer le même résultat. Cela peut être difficile à réaliser si le test utilise Internet pour se connecter à des services tiers. Cependant, ce problème peut être contourné par des stubs et des moqueries.
Pour les dépendances externes sur lesquelles vous avez plus de contrôle, la configuration d'étapes avant et après un test d'intégration vous aidera à vous assurer que le test est toujours exécuté à partir d'un état identique.
Tester les actions pertinentes
Pour tester tous les cas possibles, les tests unitaires sont une bien meilleure option.
Les tests d'intégration sont plus orientés sur la connexion entre les modules, donc tester des scénarios heureux est généralement la voie à suivre car il couvrira les connexions importantes entre les modules.
Test compréhensible et affirmation
Une vue rapide du test doit informer le lecteur de ce qui est testé, de la configuration de l'environnement, de ce qui est stub, du moment où le test est exécuté et de ce qui est affirmé. Les assertions doivent être simples et utiliser des assistants pour une meilleure comparaison et une meilleure journalisation.
Configuration de test facile
Amener le test à l'état initial doit être aussi simple et compréhensible que possible.
Évitez de tester le code tiers
Bien que des services tiers puissent être utilisés dans les tests, il n'est pas nécessaire de les tester. Et si vous ne leur faites pas confiance, vous ne devriez probablement pas les utiliser.
Laisser le code de production sans code de test
Le code de production doit être propre et simple. Mélanger le code de test avec le code de production entraînera le couplage de deux domaines non connectables.
Journalisation pertinente
Les tests échoués ne sont pas très utiles sans une bonne journalisation.
Lorsque les tests réussissent, aucune journalisation supplémentaire n'est nécessaire. Mais lorsqu'ils échouent, une journalisation extensive est vitale.
La journalisation doit contenir toutes les requêtes de base de données, les demandes et les réponses de l'API, ainsi qu'une comparaison complète de ce qui est affirmé. Cela peut grandement faciliter le débogage.
De bons tests semblent propres et compréhensibles
Un test simple qui suit les directives ci-dessous pourrait ressembler à ceci :
const co = require('co'); const test = require('blue-tape'); const factory = require('factory'); const superTest = require('../utils/super_test'); const testEnvironment = require('../utils/test_environment_preparer'); const path = '/v1/admin/recipes'; test(`API GET ${path}`, co.wrap(function* (t) { yield testEnvironment.prepare(); const recipe1 = yield factory.create('recipe'); const recipe2 = yield factory.create('recipe'); const serverResponse = yield superTest.get(path); t.deepEqual(serverResponse.body, [recipe1, recipe2]); }));
Le code ci-dessus teste une API ( GET /v1/admin/recipes
) qui s'attend à ce qu'elle renvoie un tableau de recettes enregistrées en réponse.
Vous pouvez voir que le test, aussi simple soit-il, s'appuie sur de nombreux utilitaires. Ceci est courant pour toute bonne suite de tests d'intégration.
Les composants d'assistance facilitent l'écriture de tests d'intégration compréhensibles.
Passons en revue les composants nécessaires pour les tests d'intégration.
Composants d'assistance
Une suite de tests complète comporte quelques ingrédients de base, notamment : un contrôle de flux, un cadre de test, un gestionnaire de base de données et un moyen de se connecter aux API principales.
Contrôle de flux
L'un des plus grands défis des tests JavaScript est le flux asynchrone.
Les rappels peuvent faire des ravages dans le code et les promesses ne suffisent pas. C'est là que les assistants de flux deviennent utiles.
En attendant que async/wait soit entièrement pris en charge, des bibliothèques ayant un comportement similaire peuvent être utilisées. Le but est d'écrire du code lisible, expressif et robuste avec la possibilité d'avoir un flux asynchrone.
Co permet d'écrire le code de manière agréable tout en le gardant non bloquant. Cela se fait en définissant une fonction co-génératrice, puis en produisant des résultats.
Une autre solution consiste à utiliser Bluebird. Bluebird est une bibliothèque de promesses qui possède des fonctionnalités très utiles comme la gestion des tableaux, des erreurs, du temps, etc.
Co et Bluebird coroutine se comportent de la même manière que async/wait dans ES7 (attente de résolution avant de continuer), la seule différence étant qu'elle renverra toujours une promesse, ce qui est utile pour gérer les erreurs.

Cadre de test
Le choix d'un cadre de test dépend simplement de vos préférences personnelles. Ma préférence va à un framework facile à utiliser, sans effets secondaires et dont la sortie est facilement lisible et canalisée.
Il existe un large éventail de frameworks de test en JavaScript. Dans nos exemples, nous utilisons Tape. Tape, à mon avis, non seulement remplit ces exigences, mais est également plus propre et plus simple que d'autres frameworks de test comme Mocha ou Jasmin.
La bande est basée sur le protocole TAP (Test Anything Protocol).
TAP a des variantes pour la plupart des langages de programmation.
La bande prend les tests en entrée, les exécute, puis génère les résultats sous forme de TAP. Le résultat TAP peut ensuite être acheminé vers le rapporteur de test ou peut être envoyé à la console dans un format brut. La bande est exécutée à partir de la ligne de commande.
La bande a quelques fonctionnalités intéressantes, comme la définition d'un module à charger avant d'exécuter toute la suite de tests, la fourniture d'une petite bibliothèque d'assertions simples et la définition du nombre d'assertions qui doivent être appelées dans un test. L'utilisation d'un module pour le préchargement peut simplifier la préparation d'un environnement de test et supprimer tout code inutile.
Bibliothèque d'usine
Une bibliothèque d'usine vous permet de remplacer vos fichiers d'appareils statiques par un moyen beaucoup plus flexible de générer des données pour un test. Une telle bibliothèque vous permet de définir des modèles et de créer des entités pour ces modèles sans écrire de code désordonné et complexe.
JavaScript a factory_girl pour cela - une bibliothèque inspirée d'un joyau portant un nom similaire, qui a été initialement développé pour Ruby on Rails.
const factory = require('factory-girl').factory; const User = require('../models/user'); factory.define('user', User, { username: 'Bob', number_of_recipes: 50 }); const user = factory.build('user');
Pour commencer, un nouveau modèle doit être défini dans factory_girl.
Il est spécifié avec un nom, un modèle de votre projet et un objet à partir duquel une nouvelle instance est générée.
Alternativement, au lieu de définir l'objet à partir duquel une nouvelle instance est générée, une fonction peut être fournie qui renverra un objet ou une promesse.
Lors de la création d'une nouvelle instance d'un modèle, nous pouvons :
- Remplacer toute valeur dans l'instance nouvellement générée
- Passer des valeurs supplémentaires à l'option de fonction de génération
Voyons un exemple.
const factory = require('factory-girl').factory; const User = require('../models/user'); factory.define('user', User, (buildOptions) => { return { name: 'Mike', surname: 'Dow', email: buildOptions.email || '[email protected]' } }); const user1 = factory.build('user'); // {"name": "Mike", "surname": "Dow", "email": "[email protected]"} const user2 = factory.build('user', {name: 'John'}, {email: '[email protected]'}); // {"name": "John", "surname": "Dow", "email": "[email protected]"}
Connexion aux API
Démarrer un serveur HTTP à part entière et faire une requête HTTP réelle, pour la supprimer quelques secondes plus tard - en particulier lors de la réalisation de plusieurs tests - est totalement inefficace et peut entraîner des tests d'intégration beaucoup plus longs que nécessaire.
SuperTest est une bibliothèque JavaScript permettant d'appeler des API sans créer de nouveau serveur actif. Il est basé sur SuperAgent, une bibliothèque permettant de créer des requêtes TCP. Avec cette bibliothèque, il n'est pas nécessaire de créer de nouvelles connexions TCP. Les API sont appelées presque instantanément.
SuperTest, avec prise en charge des promesses, est un supertest comme promis. Lorsqu'une telle requête renvoie une promesse, cela vous permet d'éviter plusieurs fonctions de rappel imbriquées, ce qui facilite grandement la gestion du flux.
const express = require('express') const request = require('supertest-as-promised'); const app = express(); request(app).get("/recipes").then(res => assert(....));
SuperTest a été conçu pour le framework Express.js, mais avec de petites modifications, il peut également être utilisé avec d'autres frameworks.
Autres utilitaires
Dans certains cas, il est nécessaire de se moquer de certaines dépendances dans notre code, de tester la logique autour des fonctions à l'aide d'espions ou d'utiliser des stubs à certains endroits. C'est là que certains de ces packages utilitaires sont utiles.
SinonJS est une excellente bibliothèque qui prend en charge les espions, les stubs et les simulacres pour les tests. Il prend également en charge d'autres fonctionnalités de test utiles, telles que le temps de flexion, le bac à sable de test et l'assertion étendue, ainsi que les faux serveurs et requêtes.
Dans certains cas, il est nécessaire de se moquer de certaines dépendances dans notre code. Les références aux services dont nous voudrions nous moquer sont utilisées par d'autres parties du système.
Pour résoudre ce problème, nous pouvons utiliser l'injection de dépendances ou, si ce n'est pas une option, nous pouvons utiliser un service moqueur comme Mockery.
Mockery aide à se moquer du code qui a des dépendances externes. Pour l'utiliser correctement, Mockery doit être appelé avant de charger des tests ou du code.
const mockery = require('mockery'); mockery.enable({ warnOnReplace: false, warnOnUnregistered: false }); const mockingStripe = require('lib/services/internal/stripe'); mockery.registerMock('lib/services/internal/stripe', mockingStripe);
Avec cette nouvelle référence (dans cet exemple, mockingStripe
), il est plus facile de se moquer des services plus tard dans nos tests.
const stubStripeTransfer = sinon.stub(mockingStripe, 'transferAmount'); stubStripeTransfer.returns(Promise.resolve(null));
Avec l'aide de la bibliothèque Sinon, il est facile de se moquer. Le seul problème ici est que ce stub se propagera à d'autres tests. Pour le bac à sable, le bac à sable sinon peut être utilisé. Avec lui, des tests ultérieurs peuvent ramener le système à son état initial.
const sandbox = require('sinon').sandbox.create(); const stubStripeTransfer = sandbox.sinon.stub(mockingStripe, 'transferAmount'); stubStripeTransfer.returns(Promise.resolve(null)); // after the test, or better when starting a new test sandbox.restore();
D'autres composants sont nécessaires pour des fonctions telles que :
- Vider la base de données (peut être fait avec une requête de pré-construction de hiérarchie)
- Le mettre en état de fonctionnement (sequelize-fixtures)
- Se moquer des requêtes TCP vers des services tiers (nock)
- Utiliser des assertions plus riches (chai)
- Réponses enregistrées de tiers (easy-fix)
Des tests pas si simples
L'abstraction et l'extensibilité sont des éléments clés pour créer une suite de tests d'intégration efficace. Tout ce qui détourne l'attention du cœur du test (préparation de ses données, action et assertion) doit être regroupé et résumé en fonctions d'utilité.
Bien qu'il n'y ait pas de bonne ou de mauvaise voie ici, car tout dépend du projet et de ses besoins, certaines qualités clés sont encore communes à toute bonne suite de tests d'intégration.
Le code suivant montre comment tester une API qui crée une recette et envoie un e-mail comme effet secondaire.
Il stubs le fournisseur de messagerie externe afin que vous puissiez tester si un e-mail aurait été envoyé sans en envoyer un. Le test vérifie également si l'API a répondu avec le code d'état approprié.
const co = require('co'); const factory = require('factory'); const superTest = require('../utils/super_test'); const basicEnv = require('../utils/basic_test_enivornment'); const path = '/v1/admin/recipes'; basicEnv.test(`API POST ${path}`, co.wrap(function* (t, assert, sandbox) { const chef = yield factory.create('chef'); const body = { chef_id: chef.id, recipe_name: 'cake', Ingredients: ['carrot', 'chocolate', 'biscuit'] }; const stub = sandbox.stub(mockery.emailProvider, 'sendNewEmail').returnsPromise(null); const serverResponse = yield superTest.get(path, body); assert.spies(stub).called(1); assert.statusCode(serverResponse, 201); }));
Le test ci-dessus est reproductible car il commence avec un environnement propre à chaque fois.
Il a un processus de configuration simple, où tout ce qui concerne la configuration est consolidé dans la fonction basicEnv.test
.
Il teste une seule action - une seule API. Et il énonce clairement les attentes du test à travers de simples déclarations assert. De plus, le test n'implique pas de code tiers par stub/mocking.
Commencer à écrire des tests d'intégration
Lors de la mise en production d'un nouveau code, les développeurs (et tous les autres participants au projet) veulent être sûrs que les nouvelles fonctionnalités fonctionneront et que les anciennes ne seront pas cassées.
Ceci est très difficile à réaliser sans tests, et s'il est mal fait, cela peut conduire à la frustration, à la fatigue du projet et éventuellement à l'échec du projet.
Les tests d'intégration, combinés aux tests unitaires, constituent la première ligne de défense.
L'utilisation d'un seul des deux est insuffisante et laissera beaucoup de place aux erreurs non couvertes. Toujours utiliser les deux rendra les nouveaux commits robustes, donnera confiance et inspirera confiance à tous les participants au projet.