Archives Wild Code School
Express.js

Express et TDD - Tests d'intégration de routes Express

Quête 12 sur 12

Introduction

Après l'introduction générale au TDD, et l'application à des tests unitaires, tu vas découvrir comment mettre en place des tests sur les routes d'un backend écrit avec Node.js et Express.

N'hésite pas à te rafraîchir la mémoire sur les notions de base du TDD (assertions, suites de tests, test runner...) en relisant les quêtes données en début de formation sur le TDD.

Il est également préférable d'être confortable avec l'écriture de routes Express, et d'être allé jusqu'à la quête Express 7 - POST/PUT.

Les tests présentés ici ne sont pas des tests unitaires : ces derniers ne testent que des "briques" isolées (fonction, classe). On va ici parler de tests d'intégration : le code qu'on va tester fait interagir différents "composants" de l'application, et fait intervenir des ressources externes au code proprement dit (ici, une base de données).

Objectifs

  • Installer Jest et SuperTest
  • Tester des routes Express requêtant une BDD
  • Rendre les tests répétables
  • Séparer les environnements de test et de développement

Un premier exemple

Commence par forker puis cloner ce repo.

Puis place-toi dans le dossier quest-express-tdd et installe les dépendances (npm install).

Il s'agit d'une application Express, avec les modules habituels (express, body-parser, mysql, nodemon), ainsi que dotenv pour la gestion de la configuration.

Afin que tu saches reproduire ces étapes, certaines choses dont tu auras besoin ne te sont pas fournies directement : tu vas notamment devoir installer des modules dédiés aux tests.

Outils de test

Tu vas utiliser Jest et Supertest, qui vont fonctionner de concert.

  • Jest est le framework de test développé par Facebook pour React. Il permet de tester n'importe quel code JavaScript ou TypeScript, même côté backend ! L'écriture des suites de test est très similaire à ce qui se fait avec ses concurrents Mocha et Jasmine. Il fournit plus de fonctionnalités que ces derniers.

  • SuperTest permet de tester spécifiquement des serveurs HTTP : il va permettre de tester l'application Express en lui envoyant des requêtes.

Toujours dans le dossier quest-express-tdd, tu vas installer Jest et SuperTest :

shell
npm install --save-dev jest supertest

L'option --save-dev indique à npm de les stocker dans les devDependencies du package.json (dépendances annexes, nécessaires au développement de l'application, mais pas à son fonctionnement).

En cas d'erreur lors de l'installation de Jest (Linux) :

Il se peut qu'il manque certains paquets Ubuntu, et/ou que l'outil d'installation ne trouve pas Python (dont il a besoin). Pour y pallier :

shell
sudo apt install python2.7 build-essential
PYTHON=/usr/bin/python2.7 npm install --save-dev jest supertest

Scripts dans

Le fichier package.json contient une section scripts, qui permet de lancer des commandes simplement : npm start, npm test. Voici son contenu initial (on y a déjà ajouté nodemon) :

json
  "scripts": {
    "start": "nodemon",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

Si tu lances npm test en l'état, tu obtiendras une erreur !

Modifie ce fichier pour obtenir ceci :

json
  "scripts": {
    "start": "nodemon",
    "test": "jest",
    "test:watch": "jest --watchAll"
  },

Tu pourras désormais utiliser :

  • npm start pour lancer l'application avec Nodemon,

  • npm test pour lancer les tests avec Jest,

  • npm run test:watch pour que Jest relance les tests automatiquement, à chaque modification du test ou du code !

Application Express

Dans les quêtes Express précédentes, l'application comportait un fichier principal : index.js. Ici, ton application va être composée de deux fichiers distincts : index.js et app.js.

  • app.js initialise l'application Express et l'exporte, sans effectuer l'appel à listen. Pour l'instant, il ne comporte aucune route !

  • index.js importe l'application Express, et effectue l'appel à listen pour que l'application écoute sur un port (3000 par défaut).

Ce découpage en deux fichiers s'avère nécessaire pour travailler avec la bibliothèque SuperTest.

Ecriture d'un premier test

Crée un sous-dossier test, dans lequel tu vas stocker le fichier app.integration.spec.js contenant les tests. Jest reconnaît les fichiers de test, à ce qu'ils terminent par .test.js ou .spec.js :

javascript
// test/app.integration.spec.js
const request = require('supertest');
const app = require('../app');

describe('Test routes', () => {
  it('GET / sends "Hello World" as json', (done) => {
    request(app)
      .get('/')
      .expect(200)
      .expect('Content-Type', /json/)
      .then(response => {
        const expected = { message: 'Hello World!'};
        expect(response.body).toEqual(expected);
        done();
      });
  });
});

Avant de lancer le test, quelques explications : on importe supertest sous le nom request. On va l'utiliser pour requêter l'app Express, qu'on a besoin d'importer (raison pour laquelle on a découpé l'application en app.js et index.js).

describe regroupe une suite de tests, décrite par un titre (1er paramètre). Les tests sont écrits au sein d'une fonction (2ème paramètre).

it définit un test. Il est décrit par un titre (1er paramètre). Lorsque Jest arrive à ce test, il exécute la fonction donnée comme 2ème paramètre.

Ce test utilise request pour envoyer une requête à l'app Express, en méthode GET, sur l'URL /. On peut chaîner des assertions (vérifications de conditions requises) avec .expect : on s'attend (expect) à recevoir un code de statut 200, et des données JSON (header de réponse Content-Type contenant json). On obtiendra une erreur si ce n'est pas le cas.

Une fois la réponse reçue et les deux assertions passées avec succès, on exécute le "callback" donné dans .then : on y définit un object expected, qui représente le résultat attendu (les données qu'on qu'on prévoit de recevoir en réponse à notre requête).

Le dernier expect (fonction fournie par Jest) vérifie que le corps de la réponse (response.body) est conforme au résultat attendu.

La syntaxe d'expect dans Jest permet d'écrire les assertions d'une façon proche du langage naturel : "je m'attends à recevoir un corps de réponse égal à un certain objet".

Enfin, lorsque le test est terminé, on appelle la fonction done. C'est un callback qu'on récupère dans les parenthèses de la fonction fléchée. Quand un test contient du code asynchrone, l'appel à done indique à Jest que ce test est terminé et qu'il peut passer au suivant.

3...2...1... Ignition!

Il est temps de lancer le test, avec npm test.

Malheureusement, le résultat n'est pas très probant ! Le test échoue, et Jest est très verbeux ! Essaie cependant de décortiquer ce message, en relation avec le code du test :

Sous le message en rouge, on t'indique :

shell
expected 200 "OK", got 404 "Not Found"

C'est dû au .expect(200) dans le test. Aucune route n'ayant été implémentée dans l'app Express, on reçoit forcément une erreur 404 en l'interrogeant !

Faire passer les tests

À toi de jouer maintenant ! Le but de cette étape va être de faire passer les tests.

Relance les tests, cette fois avec npm run test:watch, afin qu'ils soient relancés automatiquement, à chaque modification.

Puis suis les étapes ci-dessous, dans l'ordre. À chaque étape, vois comment tes modifications affectent le résultats des tests.

  1. Crée une route répondant à un GET sur /, et ne renvoyant qu'un code de statut 200,

  2. Au lieu de ne renvoyer qu'un code de statut, renvoie un objet vide en JSON,

  3. Ajoute le contenu à l'objet renvoyé, de façon à faire passer le test.

Avant cette dernière étape, tu as dû voir ce message d'erreur :

Bien que très verbeux, il met bien en évidence :

  • En vert, la valeur attendue par la dernière assertion

  • En rouge, la valeur qu'on a réellement obtenue au lieu de celle attendue

  • La ligne de code précise où l'erreur s'est produite

Après avoir effectué les dernières modifications, le test devrait passer. Commit alors tout ton code en l'état.

Application de bookmarks

L'exemple t'a montré comment tester une route avec SuperTest et Jest, afin d'en montrer les principes. Il est cependant trop "simple" par rapport à un cas réel.

Pour réaliser de véritables tests d'intégration, tu vas transformer l'exemple en une véritable application, s'appuyant sur une base de données.

Il va s'agir d'une application de bookmarks (favoris).

Pour simplifier, on va pour l'instant implémenter deux fonctionnalités :

  • Récupération d'une liste de bookmarks

  • Création d'un bookmark

Base de données

Le schéma de la base de données utilisée pour l'application se trouve sous database/schema.sql.

Injecte-le dans ta base de données. Dans la console MySQL, crée la BDD :

sql
CREATE DATABASE bookmarks_app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

Puis sors de la console MySQL, et injecte le schéma dans la BDD :

shell
mysql -uroot -p bookmarks_app < database/schema.sql

Utilisation de la BDD dans l'application

Dans app.js, sous les imports d'express et de body-parser, ajoute ceux-ci :

javascript
require('dotenv').config();
const connection = require('./connection');

dotenv va permettre de lire les paramètres de la base de données depuis le fichier .env : ces paramètres seront alors utilisés dans connection.js, via les 4 variables process.env.DB_*.

.env est référencé dans .gitignore, car on veut éviter de partager des paramètres "sensibles", comme ceux permettant d'accéder à la base de données.

Copie .env.sample en tant que .env, et remplace les valeurs suivantes :

  • mysqluser par un nom d'utilisateur MySQL (tu peux utiliser root, uniquement pour travailler en local),

  • mysqlpass par le mot de passe associé à cet utilisateur,

  • mysql_db_name par bookmarks_app

Si tout est bien configuré, ton application doit se (re)lancer sans erreur avec npm start.

Création d'un bookmark

**POST **/bookmarks

Ne pas foncer tête baissée...

Travailler en TDD nous oblige à réfléchir à plusieurs aspects, avant de se lancer dans le code :

  • Y a-t-il des cas d'erreur ?

  • Que doivent renvoyer les routes, en cas d'échec ou de succès ?

Ici, il faut prévoir le(s) cas où le client enverra des requêtes incorrectes au backend Express :

  • Champs url et/ou title manquants

  • Champ url mal formaté

  • Tentative d'insérer plusieurs fois la même URL (ce qu'on peut vouloir interdire)

Il y a donc potentiellement 3 cas d'erreur possibles. Pour simplifier, on ne va considérer que le premier. Ce qu'il vaut mieux éviter pour une application destinée à être mise en production !

Cette simplification étant faite, on aura deux cas à tester, donc deux tests à écrire :

  • Cas d'erreur : le backend doit renvoyer un code 422 (Unprocessable Entity), et le JSON suivant : {"error": "required field(s) missing"}.

  • Cas de succès : le backend doit renvoyer un code 201 (Created), et un JSON représentant le bookmark qui vient d'être inséré. Par exemple : {"id": 1, "url": "https://jestjs.io", "title": "Jest"}.

Le format des données attendues dans le cas d'un succès est dicté par le schéma de la base de données (voir database/schema.sql).

La méthodologie TDD incite à envisager tous les comportements possibles du code. Les tests sont une traduction en code de cette réflexion. Dans ce sens, ils font d'une certaine façon office de documentation !

Ecriture du premier test : cas d'erreur

Dans le fichier app.integration.spec.js, écris toi-même le test, en t'inspirant du it déjà existant. Il faut le placer, comme ce dernier, à l'intérieur du describe.

Il y a juste plusieurs choses à changer par rapport au test précédent :

  • son titre, qui doit être à la fois descriptif et concis

  • la méthode de request à utiliser (remplacer get par post),

  • le chemin de la route à requêter,

  • appel de la méthode send, à ajouter entre l'appel à post() et le premier expect(), en lui passant un objet à envoyer au backend (la doc de supertest en montre des exemples). Ici, on va passer un objet vide !

  • le code de statut attendu, dans le premier expect.

  • enfin, le résultat attendu. Comme on n'envoie pas les champs requis, on s'attend à recevoir en réponse {"error": "required field(s) missing"}.

Après l'écriture de ce test, relance npm run test:watch si tu l'avais interrompu. Tu dois logiquement obtenir une erreur 404 :

shell
expected 422 "Unprocessable Entity", got 404 "Not Found"

Implémentation de la route 1/2

Commence à implémenter la route dans l'application Express. Conformément à l'approche TDD, on va écrire le code minimal permettant de faire passer le test !

À l'intérieur de la route, tu peux donc te contenter :

  • de renvoyer le code de statut et le JSON d'erreur, si l'un des champs url ou title manque dans le corps de requête.

  • de renvoyer le code de statut 201 sinon.

Pas besoin d'envoyer des requêtes SQL pour cette étape !

Si l'écriture du test et/ou l'implémentation de la route t'ont posé problème, une solution t'est proposée dans ce commit (branche correction-step2 du repo).

Ecriture du second test : cas de succès

En-dessous du test précédent, et en t'en inspirant, écris le test pour le cas de succès. Voici les changements à apporter :

  • titre du test

  • objet passé à send contenant les attributs url et title. Par exemple { url: 'https://jestjs.io', title: 'Jest' }.

  • code de statut attendu.

  • résultat attendu. Si on n'a pas encore de bookmark et qu'on envoie l'objet précédent, on s'attend à recevoir en réponse {"id": 1, "url": "https://jestjs.io", "title": "Jest"}.

Implémentation de la route 2/2

Il te faut maintenant compléter la route POST /bookmarks, de façon à :

  1. insérer un nouveau bookmark dans la table bookmark,

  2. envoyer une deuxième requête pour récupérer l'objet inséré.

Pour la deuxième requête, tu dois utiliser la clé insertId de l'objet renvoyé par la requête SQL précédente, avec une clause WHERE, de façon à ne récupérer que le nouveau bookmark.

Attention

  • Pense à bien renvoyer un objet, et non pas un tableau (le SELECT doit te renvoyer un tableau contenant un élément...) !

  • Le res.status(201).json(...) doit être placé au bon endroit : dans le callback de la deuxième requête !

  • Même en ayant parfaitement écrit le test et l'implémentation de la route, il se peut que le test échoue : en l'état, il ne va réussir que si ta table bookmark est dans son état d'origine (vide et sans insertion précédente) ! Tu peux lancer TRUNCATE bookmark dans la console MySQL pour y revenir.

Cette étape est plus difficile que les précédentes : n'hésite pas à te reporter au commit de la solution en cas de problème.

A-t-on terminé ?

Pas encore !

Le test ne marche que si la table bookmark est vide et que son auto-incrément est à sa valeur initiale 1. On s'attend en effet à recevoir un bookmark dont l'id est 1. Or, si tu as déjà lancé le test avant, ou testé ton application avec Postman, la table n'est plus dans son état initial.

Il y a en fait, ici, deux problématiques à prendre en compte :

  • la répétabilité des tests : on doit pouvoir lancer les tests de façon répétée, en obtenant toujours le même résultat. Ils sont censés être prédictibles.

  • la séparation entre les "environnements" de développement et de test. Pour l'instant, les tests utilisent la base de données de l'application, ce qu'il faut éviter : autrement, on risque de "polluer" la BDD de l'application avec des données de test.

On va adresser ces deux problématiques, dans l'ordre !

Répétabilité des tests

Plusieurs approches sont possibles pour rendre les tests répétables.

Ne pas dépendre d'

Le test du cas de succès ne passe que si l'id du bookmark créé est 1 : cela rend le test complètement dépendant de l'état de la base de données.

expect, le module d'assertions intégré à Jest, permet de tester si l'id est n'importe quel nombre.

Remplace cette ligne :

javascript
const expected = { id: 1, url: 'https://jestjs.io', title: 'Jest' };

Par celle-ci :

javascript
const expected = { id: expect.any(Number), url: 'https://jestjs.io', title: 'Jest' };

expect.any permet de vérifier que la valeur testée est n'importe quelle valeur du type passé dans les parenthèses : ici, id peut être n'importe quelle valeur Number.

Réinitialiser la base de données

Une autre approche est de réinitialiser la base de données avant chaque test.

Ici, on peut vider la table bookmark et remettre son auto-incrément à 1, à chaque fois qu'on lance un test. Mais il n'est pas question de lancer manuellement TRUNCATE bookmark depuis la console MySQL !

Heureusement, Jest fournit des fonctions d'"installation" et de "démontage" (setup and teardown). Elles sont documentées ici, et sont au nombre de 4 :

  • beforeAll permet d'exécuter du code avant le premier test contenu dans un describe,

  • beforeEach permet d'exécuter du code avant chaque test d'un describe,

  • de même, afterAll et afterEach permettent d'exécuter du code, respectivement après le dernier test, ou après chaque test d'un describe.

Dans notre cas, il est pertinent d'utiliser beforeEach pour lancer un TRUNCATE de la table avant chaque test !

Dans test/app.integration.spec.js, importe le module connection.js, sous les imports existants :

javascript
const connection = require('../connection');

Puis, sous la ligne d'ouverture du describe, ajoute le code suivant :

javascript
describe('Test routes', () => {
  // truncate bookmark table before each test
  beforeEach(done => connection.query('TRUNCATE bookmark', done));

  // ... tests ...
});

Les tests peuvent maintenant être lancés autant de fois que nécessaire. Tu peux même tester que l'id du bookmark est strictement 1, car tu as l'assurance que la table est dans son état initial avant que le test soit exécuté.

Répétabilité : check !

Séparation des environnements

L'approche consistant à réinitialiser la base de données pour rendre les tests répétables rend d'autant plus nécessaire le fait de cloisonner les environnements de test et de développement.

Cela consiste, dans le cas présent, à disposer de deux bases de données distinctes. Tu vas donc répéter la mise en place d'une base de données, pour en créer une spécifique aux tests.

Dans la console MySQL :

sql
CREATE DATABASE bookmarks_app_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

Sors de la console MySQL, et injecte le schéma dans la BDD :

shell
mysql -uroot -p bookmarks_app_test < database/schema.sql

Utilisation de la BDD de test

L'application utilise dotenv pour lire ses paramètres depuis le fichier .env. Il existe des façons de charger un fichier .env spécifique aux tests (.env.test) ou au développement (.env.development), en indiquant à dotenv le chemin du fichier à charger.

Mais on peut faire plus simple, en spécifiant les valeurs des paramètres (ici DB_NAME) avant de lancer l'application ou les tests.

dotenv détectera que la variable d'environnement DB_NAME est définie, et ignorera la valeur indiquée dans .env.

Tu vas donc simplement modifier les clés test et test:watch, dans package.json :

shell
    "test": "DB_NAME=bookmarks_app_test jest",
    "test:watch": "DB_NAME=bookmarks_app_test jest --watchAll"

Les tests peuvent désormais être lancés sans interférence avec la BDD de l'application en développement !

💪Challenge

Récupérer un bookmark

Le but de ce challenge est d'implémenter la route permettant de récupérer un bookmark (GET sur /bookmarks/:id), par l'approche TDD.

Tu auras deux cas de test à écrire. Ils seront regroupés au sein d'un describe, placé à l'intérieur de celui déjà en place.

On te fournit le code de base de ce describe. On y utilise beforeEach pour réinitialiser la table et créer un bookmark, avant de lancer chaque test :

shell
describe('GET /bookmarks/:id', () => {
  const testBookmark = { url: 'https://nodejs.org/', title: 'Node.js' };
  beforeEach((done) => connection.query(
    'TRUNCATE bookmark', () => connection.query(
      'INSERT INTO bookmark SET ?', testBookmark, done
    )
  ));

  // Write your tests HERE!
});

Place-le à l'intérieur du describe existant. Tu vas y écrire tes tests, avec les exigences suivantes :

  • Cas d'erreur : on fournit dans l'URL appelée par request un id ne correspondant à aucun bookmark dans la base de données. On s'attend à recevoir :
    • le code de statut 404
    • un message d'erreur dans un objet : { error: 'Bookmark not found' }.
  • Cas de succès : on fournit un id qui existe bien. Ici c'est 1 car, après réinitialisation de la DB par le premier beforeEach, le testBookmark inséré par le deuxième aura forcément l'id 1. On s'attend alors à recevoir :
    • le code de statut 200
    • l'objet bookmark (valeurs de testBookmark plus l'id attribué par MySQL).

Tu devras ensuite implémenter la route permettant de faire passer ces tests

Partage l'URL de ton repo en guise de solution.

🧐 Critères de validation

  • Deux cas de tests ont été ajoutés
  • Tous les tests passent