Les tests unitaires

Introduction aux tests

Les objectifs du cours

  • Connaître les différents types de tests
  • Mettre en place des procédures de test
  • Automatiser les tests
  • Connaitre la roadmap pour faire du code de qualité

Comment testez-vous vos codes ?

Série de questions ouvertes

  • Comment faites vous pour vérifier vos codes ?
  • C’est quoi un test ?
  • Pourquoi on teste ?
  • Comment on teste ?

Synthèse les enjeux des tests

  • Avoir un code conforme aux attendus
  • Avoir une application qui marche
  • Vérifier la conformité de l’ensemble en cas de modification d’une partie du code
  • Sécuriser la maintenance
  • Faire la recette d’une application avec un client (pour se faire payer)
  • Améliorer la qualité du logiciel
  • Améliorer les tâches des développeurs (le dev essaie de prendre soin de lui)

Et si les tests étaient la meilleure manière de documenter votre code ?

Les différents types de tests

Une application est un programme complexe, qui résulte de l’assemblage de briques simples.

Pour prendre l’analogie d’un bâtiment, il faut d’une part s’assurer que chaque brique est bien robuste (tests unitaires), puis que les différentes parties s’emboitent bien (tests d’intégration) et enfin que la construction est conforme (tests fonctionnels)

  • Les tests unitaires (unit and feature tests) : Ce sont eux qui ont posés les bases du test. Nés du Smalltalk et du Java, ils sont aujourd’hui utilisés dans tous les langages.
    • Tester si chaque fonction renvoie une sortie prévisible
    • Tester si les composants sont fonctionnels, notamment sur les attendus des fonctions interfaces
  • Les tests d’intégration : vérifier que les différentes parties de l’application fonctionnent bien ensemble
  • Les tests fonctionnels : tests des fonctionnalités globales d’une applications en lui fournissant des entrées et en vérifiant que les sorties sont bien les attendues
  • Les tests de régression : ensemble de test passés lorsqu’une nouvelle version du logiciel est développée.
  • La recette & les procédures de validation : formalise une procédure rassemblant les tests nécessaires à valider le fonctionnement de l’application

On parle aussi de white-box et de black-box testing suivant que le code source de l’application peut être testé (white-box) ou uniquement les entrées sorties et le fonctionnement global de l’application (black-box).

Exercice : créons 1 test unitaire

Présentation

Nous voulons créer une fonction qui affiche à l’écran une somme d’argent restante en tenant compte du pluriel par exemple :

  • il me reste 10 euros
  • il me reste 1 euro
  • il me reste 0 euro

Notre premier test unitaire

On veut vérifier que notre fonction marche, et écrire une fonction pour la tester.

On écrit tout d’abord notre fonction dans un fichier pluriel.js

function afficherArgentRestant(somme) {
    return `J'ai ${somme} euro`+(somme>=1?'s':'');
}

On crée ensuite un test unitaire dans un fichier pluriel-test.js

console.log(afficherArgentRestant(1));
console.log(afficherArgentRestant(10));
console.log(afficherArgentRestant(0));

Enfin on inclut les scripts dans une page testPluriel-1.html qui appellera les tests.

Les assertions

L’assertion ou vérification est le coeur du test unitaire. Le terme peut être trompeur en français mais c’est une fonction qui a pour unique but de renvoyer l’info de réussite ou d’échec au test.

Cette appellation est standard dans le domaine du test. On peut définir une fonction dit d’assertion, appelée assert est définie dans sa forme la plus simple comme suit :

function assert(message, expr){
 if(!expr){
  throw new Error(message);
 }
}

Le message est la définition du texte destinée à être lue par un humain, tandis que expr est le résultat booléen du test.

Il est toujours intéressant d’afficher sous forme de message l’état du test :

function assert(message, expr){
 if(!expr){
  output(false, message);
  throw new Error(message);
 }
 output(true, message);
}

function output(result, message){
 var p = document.createElement('p');
 message += result ? ' : SUCCESS' : ' : FAILURE';
 p.style.color = result ? '#0c0' : '#c00';
 p.innerHTML = message;
 document.body.appendChild(p);
}

Nous pouvons maintenant relancer nos tests avec ces fonctions

Une suite de test

Nous allons faire une fonction nécessitant un peu plus de test. Créez une fonction de conversion de l’euro vers 3 devises étrangères : les dollars US, la livre anglaise et la Yen Japonaise. La fonction renvoie une erreur en cas de devise inconnue, message Monnaie non gérée.

function convertirEuro(euro, devise){
 switch(devise){
  case 'USD' :
   return euro * 1.3; 
  case 'GBP' :
   return euro * 0.87;
  case 'JPY' :
   return euro * 124.77;
  default : {
   throw new Error('Monnaie non gérée');
  }
 }
}

Afin de rendre les résultats plus digestes, on souhaite regrouper les tests pour chaque devise. C’est la notion de suite de test. Pour mettre cela en place, il faut créer une fonction déroulant une série de tests unitaires, ici la fonction testcase :

function testCase(message, tests) {
  // Initialisations 
  var total = 0; // compteur du nombre de test de la suite
  var succeed = 0; // nombre de test réussis
  var p = document.createElement('p');
  p.innerHTML = message;
  document.body.appendChild(p);
  // Parcours des tests
  for(test in tests){
   total++;
   try {
     // Passage du test
     tests[test]();
     succeed++;
   } catch(err) {
     // Erreurs non renvoyées pour passer les autres tests
   }
  }
  var p = document.createElement('p');
  p.innerHTML = 'succeeded tests ' + succeed + '/' + total ;
  document.body.appendChild(p);
}

Petit rappel pour bien comprendre la syntaxe utilisée :

// Crée objet JS où chaque propriété est une fonction
const objet = { 'Diego':function() { console.log('Maradona')}, 
      'Jesus':function() { console.log('Christ')  }  }

// Parcours des propriétés de l'objet 
for (key in objet) { console.log(key) }

// Parcours des propriétés de l'objet et affichage des valeurs qu'elles contiennent
for (key in objet) { console.log( objet[key] ) }

// Parcours des propriétés de l'objet et exécution des fonctions qu'elles contiennent
for (key in objet) { console.log( objet[key]() ) }

Nous pouvons ensuite créer les suites de tests pour chaque devise sur le modèle suivant :

testcase('Conversion euro → dollars us', {
 'Test avec 1 euro' : function(){
  assert('1€ doit renvoyer 1,3$', convertEuro(1, 'USD') === 1.3);
 },
 'Test avec 2 euros' : function(){
  assert('2€ doit renvoyer 2,6$', convertEuro(2, 'USD') === 2.6);  
 }
})

Écrire l’ensemble des tests pour les autres devises. Créer aussi un test pour une devise non gérée. Remarque : utiliser ALT+0165 pour le caractère ¥

Initialisation et désactivation

Enfin, il peut nécessaire d’initialiser les objets avant de démarrer un test et de les libérer à la fin d’une suite de test. Ce sont les concepts de setUp et tearDown. Par rapport à l’exemple ci-dessous, on passe en paramètre aux tests la devise pour éviter d’éventuelles erreurs de copier-coller dans la rédaction des tests.

function testcase(message, tests){
 var total = 0;
 var succeed = 0;
 var hasSetup = typeof tests.setUp === 'function';
 var hasTeardown = typeof tests.tearDown === 'function';
 var p = document.createElement('p');
 p.innerHTML = message;
 document.body.appendChild(p);
 for(test in tests){
  if(test !== 'setUp' && test !== 'tearDown'){
   total++;
  }
  try{
   if(hasSetup){
    tests.setUp();
   }
   tests[test]();
   if(test !== 'setUp' && test !== 'tearDown'){
    succeed++;
   }
   if(hasTeardown){
    tests.tearDown();
   }
  }catch(err){  
  }
 }
 var p = document.createElement('p');
 p.innerHTML = 'succeed tests ' + succeed + '/' + total ;
 document.body.appendChild(p);
}

testcase('I convert euro to usd', {
 'setUp' : function(){
  this.currency = 'USD';
 },
 'I test with one euro' : function(){
  assert('1€ should return 1,3$', convertEuro(1, this.currency) == 1.3);
 },
 'I test with two euros' : function(){
  assert('2€ should return 2,6$', convertEuro(2, this.currency) == 2.6);  
 }
})

Synthèse d’un test unitaire

Il y a 4 phases dans l’exécution d’un test unitaire :

  1. Initialisation (fonction setUp) : définition d’un environnement de test complètement reproductible (une fixture).
  2. Exercice : le module à tester est exécuté.
  3. Vérification (utilisation de fonctions assert) : comparaison des résultats obtenus avec un vecteur de résultat défini. Ces tests définissent le résultat du test : SUCCÈS (SUCCESS) ou ÉCHEC (FAILURE). On peut également définir d’autres résultats comme ÉVITÉ (SKIPPED).
  4. Désactivation (fonction tearDown) : désinstallation des fixtures pour retrouver l’état initial du système, dans le but de ne pas polluer les tests suivants. Tous les tests doivent être indépendants et reproductibles unitairement (quand exécutés seuls).

Pour les valeurs numériques, il est souvent préfèrable de définir une tolérance de valeurs plutôt qu’une valeur fixe.

Le Test Driven Development (TDD)

Voir plus d’infos sur la page Wikipédia du Test Driven Development.

L’idée de base est de définir les tests avant de coder. Cela est toujours possible car dès qu’une fonction est définie, on en connait les résultats attendus et on peut donc écrire des tests.

Les 3 lois du TDD sont formulables ainsi :

  • Loi no 1 : Vous devez écrire un test qui échoue avant de pouvoir écrire le code de production correspondant.
  • Loi no 2 : Vous devez écrire une seule assertion à la fois, qui fait échouer le test ou qui échoue à la compilation.
  • Loi no 3 : Vous devez écrire le minimum de code de production pour que l’assertion du test actuellement en échec soit satisfaite.

Le développement orienté test TDD évolue pour devenir le Behavior Driven Development (BDD). L’esprit pour les codeurs est sensiblement le même, on parle comportement plutôt que de test pour une approche plus transversale côté métier.

Après le “Mobile First”, voici le “Write Test First”

Utiliser les frameworks de test

Jasmine

Installation

Nous allons utiliser le framework de test Jasmine qui est à la fois populaire et assez facile à prendre en main. Il peut notamment s’utiliser directement depuis le navigateur, sans utiliser NodeJS ou un framework particulier.

Pour installer et utiliser Jasmine :

  • Récupérer un zip du dernièr release de Jasmine
  • Dézipper le release dans un dossier local
  • Ce dossier contient la librairie pour vos projets, mais aussi un exemple de test (et d’inspiration)
  • Ouvrir de dossier avec Visual Studio Code et lancer SpecRunner.html avec le Live Server
  • Observer les fichiers de définition des tests (ou specifications) du dossier spec

Pour intégrer Jasmine à votre projet :

  • Créer un sous-dossier jasmine à la racine de votre projet
  • récopier uniquement le contenu du dossier lib dans votre projet : jasmine/lib/jasmine-standalone-3.6.0

Votre premier test

Pour faire fonctionner le test suivant avec Jasmine :

  • Créer un fichier testAddition.html et ajouter les lignes suivantes dans l’entête :
<link rel="shortcut icon" type="image/png" href="lib/jasmine-3.6.0/jasmine_favicon.png">
<link rel="stylesheet" type="text/css" href="lib/jasmine-3.6.0/jasmine.css">

<script type="text/javascript" src="lib/jasmine-3.6.0/jasmine.js"></script>
<script type="text/javascript" src="lib/jasmine-3.6.0/jasmine-html.js"></script>
<script type="text/javascript" src="lib/jasmine-3.6.0/boot.js"></script>
<!-- Inclusion des fonctions js à tester -->
<script src="addition.js"></script>
<!-- Inclusion des test js -->
<script src="test-addition.js"></script>
  • Vérifiez que les chemins d’inclusion des fichiers de Jasmine sont valides
  • Créer un fichier de description des tests avec
describe("Test de l'avaScript addition operator", function () {
    it('adds two numbers together', function () {
        expect(1 + 2).toEqual(3);
    });
});

La syntaxe est la suivante :

  • Un groupe de tests (suite) est déclaré avec describe, qui prend 2 arguments :
    1. Le nom de la suite de test
    2. La définition des tests dans une fonction anonyme
  • La fonction d’un groupe de tests peut contenir plusieurs tests
  • Un test suit la syntaxe it(‘titre du test’, fonction anonyme)
  • A l’intérieur de la fonction du test, la valeur à tester est dans expect(evaluation de la fonction).matcher(valeur attendu)e
  • Le matcher est une fonction du framework qui permet de faire différents tests classiques pour comparer des types de variables, par exemple :
    • expect(…).toBe(valeur attendue) : valeurs identiques (===)
    • Qui se déclinent en .toBeNaN(), toBeNull(), toBeFalse(), …
    • expect(…).toEqual(valeur attendue) : valeurs égales à l’intérieur d’un objet
    • expect(valeur).toBeCloseTo(attendue, precision)() : valeurs proche
    • expect(tableau).toContain(élément)() : élément contenu dans un tableau
    • expect(chaine).toContain(sous-chaine)() : sous-chaine à l’intérieur d’une autre

Reprendre les tests de la fonction de conversion euros pour qu’ils utilisent le framework Jasmine

Lisez la documentation à l’adresse suivante :

QUnit

Lire la doc de QUnit et les tests de la fonction de conversion euros pour qu’ils utilisent le framework QUnit.
Utiliser la version CDN de QUnit à appeler dans le navigateur

Pour réduire les risques d’erreur

Afin d’éviter les copier-coller de tests, nous allons plutôt définir un tableau JS des valeurs à tester et créer des tests automatiquement à partir des valeurs à tester.

// Création d'un tableau de définition des tests
// [0]:devise, [1]:montant €, [2]:résultat attendu, [3]:caractère monnaie 
var tabAttendusTests  = [
  ['USD', 1, 1.3, '$'],
  ['USD', 2, 2.6, '$'],
  ['GBP', 1, 0.87, '£'],
  ['GBP', 2, 1.74, '£'],
  ['JPY', 1, 124.77, '¥'],
  ['JPY', 2, 249.54, '¥']
];

Utiliser une boucle sur le tableau pour créer les tests automatiquement.

Le projet BonAnniversaire

Présentation

Le cahier des charges a été exprimé par le client comme suit :

Je souhaite avoir une application dans laquelle je puisse rentrer les dates anniversaires de mes proches et qui me donne leur âge en les triant par date d’anniversaire la plus proche. Les anniversaires très proches ( la veille ou le jour même) doivent être mis en avant. Un plus serait d’avoir des notifications.

Conception

Les architectes agiles ont défini un MVP (minimum viable product) et les sprints suivants :

Sprint 1 (MVP)

  • L’application est contenue dans 1 page HTML, utilisant le Framework Bootstrap
  • Les données sont stockées par l’utilisateur en local avec le localstorage du navigateur et rechargées avec la page
  • Les anniversaires sont affichés dans des cartes triées par ordre chronologique
  • Une fenêtre modale permet la saisie d’une nouvelle personne
  • Une classe Personne contient
    • les propriétés {id:"xxx", nom:"Name", dateAnniv:objet Date, comment:"commentaire"}
    • les méthodes pour obtenir l’âge de la personne et le nombre de jour avant anniversaire
  • Une classe MesAnniversaires contient
    • les propriétés nom et un tableau arPersonne de Personnes.
    • les méthodes pour ajouter / modifier / supprimer / afficher la liste des personnes et sauver / charger l’objet dans le localStorage.

Sprint 2

  • Ajouter l’édition des entrées
  • Comme on ne connait pas toujours l’année de naissance, il faut la rendre optionnelle
  • On souhaite aussi afficher les anniversaires jusqu’à 3 jours après leur date
  • Ajouter l’édition des entrées

Sprint 3

Sprint 4

  • PWA : Écrire le fichier manifest.json
  • Import/export des contacts
  • Import depuis un fichier de contacts Google
  • Import sélectif depuis un fichier

Sprint 5

  • Internationalisation : traduire l’application dans plusieurs langues (i18n)

Exercice

Prise en main

  • Récupérer le code source de l’application Bon Anniversaire en cours de développement à l’adresse : https://gitlab.com/exercices-et-tp/bon-anniversaire
  • Analysez la structure du code de l’application :
    • La page index.html est le point d’entrée de l’application
    • La page template.html rassemble uniquement des bribes de codes Html Bootstrap pour tester des mises en page. Utilisez là pour tester votre mise en page personnelle. Ces bouts se retrouvent plus ou moins fidèlement dans l’application.
    • Quelles sont les cripts et classes Javascript, quels sont leurs rôles ?
  • Lancez le fichier index.html avec le live server pour avoir un aperçu de l’application : le site est à peu près en place visuellement, mais fonctionnellement presque rien de marche
  • Il semblerait que les développeurs ont préparé les tests de différentes fonctions qui ne passent pas : saurez-vous les retrouver et les corriger ?

Codage MVP

  • Mettre au point les fonctions de la classe Personne : getAge() et getJoursAvantAnniv() pour qu’elles valident les tests unitaires (dossier test).
  • Modifier la page index.html pour que la date soit affichée dans le sous-titre de l’application
  • Modifier les cartes pour qu’elles incluent la date de naissance de la personne en plus du jour.

Faites une recherche sur le mot clef ‘TODO’ dans le code permet aussi de voir les choses identifiées comme à faire.

Aperçu du MVP Tests Jasmine du MVP

Et après …

Vous pouvez ensuite suivre les étapes proposées du développement ou choisir celles qui vous intéressent pour faire vos Sprints.

N’oubliez pas de mettre en place les tests AVANT de coder les nouvelles fonctions.

Les outils du codage

Au delà des tests, il existe une jungle d’outils pour valider tous les aspects de vos codes et automatiser les étapes de tests et déploiement (CI/CD) :

  • Tests du code : QUnit, Jasmine, Mocha, Jest, …
  • Compilation (babel, Sass)
  • Linting : analyse syntaxique et standardisation de la manière d’écrire
  • Formateurs de code : Prettier, Tidy, minify
  • Code coverage : vérifie le taux de code exécuté par vos tests unitaires, Istanbul
  • Task runner : Gulp, Grunt, … il permettent de définir des scripts contenant une liste des tâches à exécuter suivant les cas : test, deploy, build, watch (Live server), …
  • Profiling : étude du temps d’exécution d’un code (outil intégré au navigateur)

Pour accéder à ces outils et les installer, il faut souvent commencer par utiliser des gestionnaires de packets logiciels comme yarn ou npm.

Les normes de la qualité

La norme ISO/CEI 9126, puis ISO 25010 définissent un langage commun pour modéliser les qualités d’un logiciel :

  • Capacité fonctionnelle : est-ce que le logiciel répond aux besoins fonctionnels exprimés ?
  • Fiabilité : est-ce que le logiciel maintient son niveau de service dans des conditions précises et pendant une période déterminée ?
  • Rendement et efficacité : est-ce que le logiciel requiert un dimensionnement rentable et proportionné de la plate-forme d’hébergement en regard des autres exigences ?
  • Maintenabilité : est-ce que le logiciel requiert peu d’effort à son évolution par rapport aux nouveaux besoins ?
  • Portabilité : est-ce que le logiciel peut être transféré d’une plate-forme ou d’un environnement à un autre ?
FonctionnalitéFiabilitéUtilisabilitéRendementMaintenabilitéPortabilité
PertinenceMaturitéCompréhensionComportement temporelAnalyseAdaptation
ExactitudeTolérance aux pannesApprentissageUtilisation des ressourcesmodificationInstallation
InteropérabilitéFacilité de récupérationExploitationStabilitéCoexistence
SécuritéTestabilitéInterchangeabilité
Conformité

Ressources

Lectures intéressantes

Frameworks de test unitaire JS

Les frameworks de test unitaire JS les plus utilisés sont :

  • Jasmine : idem
  • QUnit : idem
  • Mocha : un framework JS utilisable dans le browser ou avec NodeJS
  • [Jest] : framework avancé, utilisé par Facebook, Twitter, Instagram, Spotify, …
  • Code covering : Istanbul
  • Linting : ESLint (ou JShint, JSlint)

Pour aller plus loin

Solutions des exercices

Les solutions des exercices sont disponibles dans le dépot GIT https://gitlab.com/exercices-et-tp/tests-unitaires-js-jasmine-qunit