JS: Les classes et modules

Introduction

Dans ce chapitre, vous allez découvrir le cœur conceptuel du langage Javascript... : les prototypes.

Ensuite, la notion de "classe" qui en découle sera abordée ainsi que les facilités syntaxiques apportées par ECMAScript 6.

Nous terminerons par les possibilités de structurer le code d'une application en modules.

Les objets en JavaScript

Pour la plupart des développeurs qui ont appris la programmation orientée objet avec des langages comme Java ou C#, la notion d'objet orienté prototype de JavaScript est au mieux déroutante, et au pire, semble faillible ou absurde.

Et pourtant, une fois dépassé les préjugés, les mécanismes liés au prototypage offrent une grande liberté et une certaine puissance...Comme aurait dit un certain B. Parker: "Un grand pouvoir implique de grandes responsabilités."

Rien de tel qu'une petite analogie pour mieux comprendre ces différences de conceptions que nous allons approfondir de façon plus concrète dans ce chapitre:

La conception d'une classe Voiture

En C# ou en Java, la création d'exemplaires d'une voiture commence par la création d'un plan détaillé de la voiture (la classe) et la mise en point de la chaîne d'assemblage (le constructeur).

Une fois, l'usine démarrée (lancement du programme compilé), les voitures sont produites conformément au plan.

Bien entendu, des options sont possibles pour personnaliser les voitures (couleurs, moteur diesel/essence/électrique, ...) grâce aux paramètres autorisés par le plan.

Si le client nécessite un camion, l'usine doit être arrêtée, un nouveau plan conçu ainsi qu'une sous-chaîne d'assemblage dédiée à la production de camions.

Chaine de montage de voitures
La production rigoureuse et planifiée d'instances d'une classe Voiture

La magie de la conception par prototype

En JavaScript, la création d'exemplaires d'une voiture commence par la création... d'une voiture (le prototype) et c'est tout ! C'est déjà un exemplaire, il roule et est utilisable comme voiture.

Une fois l'usine démarrée, les voitures sont produites grâce à une baguette magique qui duplique le prototype.

Bien entendu, des options sont possibles également pour personnaliser chaque copie du prototype selon les possibilités offertes par celui-ci.

Si le client nécessite un camion, l'usine n'a pas besoin d'être arrêtée, il suffit de modifier le prototype en changeant la taille des pneus, des amortisseurs, la puissance du moteur et en changeant la capacité du coffre à celle d'une benne. Il est alors possible, toujours en dupliquant le prototype, de produire des camions.

Encore plus étrange, si au lieu de modifier les propriétés du prototype, on lui en ajoute... par exemple, un système de détection d'obstacles... alors tous les exemplaires de voiture, y compris ceux produits sur base du prototype original, possèderont cette nouvelle propriété !!

La magie des prototypes
La production magique d'une instance de voiture sur base d'un prototype "Voiture"

Comprendre la notion d'objet

Testez le code suivant dans PHPStorm en plaçant un point d'arrêt à la dernière ligne:

let objNew = new Object();
objNew.a = 1;
console.log(objNew);

let objBrut = {
    a: 1
};
console.log(objBrut);
console.log("Arrêtez ici !");

Comparez le contenu de objNew et objBrut dont la propriété "__proto__". Que constatez-vous ?

Réponse...

Ces deux objets sont strictement identiques ! Ils ont tous deux Object comme prototype.

Les prototypes

La chaine des prototypes

En JavaScript, tout objet possède une propriété privée "prototype" qui est un lien vers un autre objet (le prototype).

Le prototype, étant un objet, possède lui aussi un prototype... jusqu'au prototype Object dont le prototype est null.

Lors d'un appel à une propriété d'un objet, cette propriété est recherchée parmi les propriétés propres (non héritées d'un prototype) puis de proche en proche parmi les propriétés des prototypes.

Un objet hérite donc des propriétés de ses prototypes.

Sauf spécifications d'héritage, la plupart des objets en JavaScript ont uniquement comme prototype l'objet Object.

Instance d'objet via fonction constructeur

let fct = function(){
    this.a = 1;
    this.b = 2;
}
let obj = new fct();
console.log(obj);
fct.prototype.b = 20;
fct.prototype.c = 3;
console.log(obj.a);   //1
console.log(obj.b);   //??
console.log(obj.c);   //??
console.log(obj.d);   //??

Qu'afficheront les trois dernières lignes de code ?

Réponse...

obj.b => "2" car la propriété b est d'abord recherchée dans les propriétés propres de l'objet. la modification de la valeur de la propriété b du prototype n'a donc pas d'impact.

obj.c => "3" car la propriété c n'exite pas dans les propriétés propres de l'objet, elle est donc recherchée et trouvée dans les propriétés du prototype.

obj.d => "undefined" car la propriété d n'existe ni dans les propriétés propres de l'objet ni parmi les propriétés de son prototype.

Les différentes façons de créer des objets

Consultez les excellents exemples et explications sur l'impact de la méthode de création d'un objet sur sa chaine de prototypes (MDN web docs de Mozilla).

Les classes

Les "classes" avec ES5

En JavaScript, les fonctions sont des objets. Une propriété d'un objet peut elle-même être une fonction. Il est donc possible de considérer une fonction comme un genre de "classe".

Une classe avec ES5

Voici un exemple de classe en ES5, une instance d'Animal nommée medor est créée. Le prototype est ensuite modifié pour ajouter une nouvelle méthode. Une seconde instance d'Animal nommée felix est alors créée.

function Animal(espece, nom){
    this.espece = espece;
    this.nom = nom;
    this.affiche = function(){ return this.nom + " (" + this.espece + ")"; }
}

let medor = new Animal("chien", "Médor");
if(medor["crie"] !== undefined) {
    console.log(medor.crie());
} else {
    console.log(medor.affiche() + " ne peut pas crier");
}

Animal.prototype.crie = function(){
    return this.nom + ": Wouf wouf!";
}
if(medor["crie"] !== undefined) {
    console.log(medor.crie());
} else {
    console.log(medor.affiche() + "ne peut pas crier");
}

let felix = new Animal("chien", "Félix");
if(felix["crie"] !== undefined) {
    console.log(felix.crie());
} else {
    console.log(felix.affiche() + "ne peut pas crier");
}

Qu'est-ce qui sera affiché en console ?

Réponse...

medor["crie"] n'est pas défini dans les propriétés propres de l'objet ni dans celles des prototypes => "Médor ne peut pas crier".

Ensuite, le prototype est modifié en ajoutant la méthode "crie", medor["crie"] est donc maintenant trouvée dans le prototype lié => "Médor: Wouf wouf!".

Enfin, évidemment que la deuxième instance créée après la modification du prototype bénéficie de la méthode crie => "Félix: Wouf wouf!".

Les classes depuis ES6

ECMAScript 6 a apporté une facilité d'écriture (un sucre syntaxique) permettant de faire ressembler la déclaration de "classes" similaire à celle de JAVA, C#, ...

Comparez à présent la déclaration de la classe Animal:

class Animal {
    constructor(espece, nom){ //constructeur
        this.espece = espece;
        this.nom = nom;
    }
    affiche(){
        return `${this.nom} (${this.espece})`;
    }
}
//le code qui suit est identique à la version ES5...
let medor = new Animal("chien", "Médor");
if(medor["crie"] !== undefined) {
    console.log(medor.crie());
} else {
    console.log(medor.affiche() + " ne peut pas crier");
}

Animal.prototype.crie = function(){
    return this.nom + ": Wouf wouf!";
}
if(medor["crie"] !== undefined) {
    console.log(medor.crie());
} else {
    console.log(medor.affiche() + "ne peut pas crier");
}

let felix = new Animal("chien", "Félix");
if(felix["crie"] !== undefined) {
    console.log(felix.crie());
} else {
    console.log(felix.affiche() + "ne peut pas crier");
}

L'utilisation du mot-clef "class" ne change rien ! JavaScript n'est pas pour autant devenu un langage de classes mais bien d'objets orienté prototype !!!

Les classes depuis ES2022

Les attributs de la classe sont déclarés en dehors du constructeur. Un attribut peut être déclaré privé en précédant son nom du caractère "#".
Le mot-clef "static" permet de déclarer des attributs ou méthodes statiques.

Comparez à présent la déclaration de la classe Animal:

class Animal {
	espece = ""
	nom = ""
	#age = 0 //attribut privé
	static animalsCounter = 0 //attribut statique
	
    constructor(espece, nom){ //constructeur
        this.espece = espece;
        this.nom = nom;
		this.#compter();
    }
	#compter(){ //méthode privée
		Animal.animalsCounter++; //utilisation de Animal et non this !
	}
	veillir(){
		this.#age++;
	}
    affiche(){
		return `${this.nom}, ${this.#age} ans (${this.espece})`;
    }
	static setAnimalsCounter(nbr){ //méthode statique
		this.animalsCounter = nbr; //utilisation de this car méthode statique
	}
}
let felix = new Animal("chat", "Félix");
let medor = new Animal("chien", "Médor");
medor.veillir()
medor.veillir()
console.log(medor.affiche());
console.log(`Nombre d'animaux : ${Animal.animalsCounter}`);
Animal.setAnimalsCounter(42);
console.log(`Nombre d'animaux : ${Animal.animalsCounter}`);

Une propriété privée n'est pas accessible avec un appel dynamique:

class Animal {
	espece = ""
	nom = ""
	#age = 0 //attribut privé
	
    constructor(espece, nom){ //constructeur
        this.espece = espece;
        this.nom = nom;
		this.#age = 5;
    }
    affiche(){
		let attribut = '#age';
		return `${this.nom}, ${this[attribut]} ans (${this.espece})`;
    }
}
let felix = new Animal("chat", "Félix");
console.log(felix.affiche()); //Félix, undefined ans (chat)

Tester l'existence d'une propriété privée

Puisqu'il n'est pas possible d'accèder dynamiquement à un attribut privé, il faut vérifier l'existence d'un attribut privé dans un objet avec l'opérateur "in" comme suit:

class Animal {
    espece = ""
    nom = ""
    #age = 0 //attribut privé

    constructor(espece, nom){ //constructeur
        this.espece = espece;
        this.nom = nom;
        this.#age = 5;
    }
    static cloneAnimal(obj){
        if ((Object.hasOwn(obj, 'espece')) && (#age in obj)){
            return new Animal(obj.espece, obj.nom);
        }
    }
    affiche(){
        return `${this.nom}, ${this.#age} ans (${this.espece})`;
    }
}
let medor = new Animal('chat', 'Médor');
let felix = Animal.cloneAnimal(medor);
console.log(felix.affiche());

Concepts de POO et classes JS

Quelques autres différences importantes entre la notion de classe dans d'autres langages et celle de JavaScript:

Propriétés et méthodes statiques

Une propriété ou méthode statique doit être liée à la fonction/classe elle-même au lieu de son prototype.

Contrairement à d'autres langages, JavaScript ne permet pas d'accéder à une propriété/méthode statique à partir d'une instance de la classe.

Propriété et méthode statiques

class Animal {
    constructor (espece, nom) {
        this.espece = espece;
        this.nom = nom;
    } 
}
Animal.prototype.crie = function(){
    return this.nom + ": Wouf wouf!";
}
let medor = new Animal("chien", "Médor");

Animal.definition = "---";
Animal.setDefinition = function(definition){ 
    Animal.definition = definition; 
};

Animal.setDefinition("Etre vivant qui se déplace.");
if (medor["setDefinition"] !== undefined){
    medor.setDefinition("Etre vivant qui se déplace à quatre pattes.");
} else {
    console.log("Méthode setDefinition inaccessible pour " + medor.nom);
}
console.log(Animal.definition + " vs " + medor.definition);

Qu'est-ce qui sera affiché en console ?

Réponse...

"Méthode setDefinition inaccessible pour Médor" puisque medor est une instance et setDefinition une méthode statique de la classe.

"Etre vivant qui se déplace. vs undefined" puisque definition est une propriété statique appelée à partir de la classe puis de l'instance.

Le code peut s'écrire également comme ceci:

class Animal {
    constructor (espece, nom) {
    	Animal.definition = "---";
        this.espece = espece;
        this.nom = nom;
        
    }
    crie(){
    	return this.nom + ": Wouf wouf!";
    }
    static setDefinition(def){
    	Animal.definition = def;
    }
}
let medor = new Animal("chien", "Médor");


Animal.setDefinition("Etre vivant qui se déplace.");
if (medor["setDefinition"] !== undefined){
    medor.setDefinition("Etre vivant qui se déplace à quatre pattes.");
} else {
    console.log("Méthode setDefinition inaccessible pour " + medor.nom);
}
console.log(Animal.definition + " vs " + medor.definition);

Héritage

Le mécanisme d'héritage se met en place grâce à la chaine des prototypes. ES6 a facilité grandement la syntaxe :

class Etudiant {
    constructor({matricule= "", prenom= "", nom= "", id=0}={}) {
        this.matricule = matricule;
        this.prenom = prenom;
        this.nom = nom;
        this.id = id;
    }
    affiche(){
        return "Etudiant (" + this.matricule +") ";
    }
}
class EtudiantCours extends Etudiant {
    constructor ({matricule= "", prenom= "", nom= "", id=0, cours=""}={}) {
        super({matricule, prenom, nom, id}); //appel au constructeur du parent
        this.cours = cours;
        this.cotesTJ =  {};
        this.coteExamen = 0;
        this.id = cours + id;
    }
    addCoteTJ(label, cote) {
        this.cotesTJ[label] = cote;
    }
    afficheId(){
        return super.affiche() + this.id + " " + this.nom; 
    }
}
let etudiant = new EtudiantCours({matricule: "e123456", nom:"Dupont", prenom:"Jean", id:42, cours: "JavaScript"});
console.log(etudiant.afficheId()); //Etudiant (e123456) JavaScript42 Dupont

La fonction super() appelle le constructeur de la classe parente. L'appel à une méthode de la classe parente doit être précédé de super.. L'instance possède toutes les propriétés de la classe et des classes héritées.

Getter et setter

Un accesseur (getter) sur une propriété est une fonction qui sera appelée lors de l'accès à une propriété de la classe ou variable locale.

let _version = 2.0;    
class Animal {
    constructor(espece, nom){
        this.espece = espece;
        this.nom = nom;
    }
    get nomMin(){
        return this.nom.toLowerCase();
    }
    get version(){
        return _version;
    }
}
let medor = new Animal("chien", "Médor");
console.log(medor.nomMin);   //médor
console.log(medor.version);  //2

De même, un mutateur (setter) sur une propriété est une fonction qui sera appelée lors de la modification de valeur de cette propriété ou variable locale.

let _version = 2.0;    
class Animal {
    constructor(espece, nom){
        this.espece = espece;
        this.nom = nom;
    }
    get nomMin(){
        return this.nom.toLowerCase();
    }
    set nomMax(nom){
        this.nom = nom.toUpperCase();
    }
    get version(){
        return _version;
    }
    set version(v){
        _version = v;
    }
}
let medor = new Animal("chien", "Médor");
console.log(medor.nomMin);  //médor
console.log(medor.version); //2 
medor.nomMax = "Félix";
medor.version = 3.0;
console.log(medor.nom);     //FÉLIX
console.log(medor.nomMin);  //félix
console.log(medor.version); //3

Getter, setter

class Facture {
    constructor(montant){
        this.montant = montant;
        this.tva = 0.21;
    }
    get tvac(){
        return this.montant * (1 + this.tva);
    }
    set taux(taux){
        this.tva = taux;
    }
}
let facture = new Facture(1000);
facture.taux = 0.06;
console.log(`Montant TVAC = ${facture.tvac}`);
console.log(`Taux appliqué: ${facture.taux}%`);

Quelles seront les lignes affichées en console ?

Réponse...

"Montant TVAC = 1060" en première ligne

"Taux appliqué: undefined%" puisqu'aucun accesseur n'est défini sur taux dans facture et que ce n'est pas non plus une propriété, facture.taux vaut undefined.

Documentation de code JS avec JSDoc

JSDoc propose une syntaxe pour documenter du code JS qui est similaire à la JavaDoc en Java.

Veuillez consulter les exemples de documentation de

Importance de documenter vos fonctions

Créez un nouveau fichier dans PHPStorm et copiez-collez le code ci-dessous:

class De {
    constructor(n){
        this.nbrFaces = n;
        this.faces = this._getFaces();
    }
    //méthode à considérer "privée"
    _getFaces(){
        let faces = [];
        for(let i=0; i < this.nbrFaces; i++) faces.push(i+1);
        return faces;
    }
    get val(){
        return Math.floor(Math.random() * this.nbrFaces)+1;
    }
}

function printValue(de){
    console.log(de);
}

Tapez un '.' à la suite de 'de' à la ligne 18 pour faire apparaître les propositions de complétion de code. Vous pouvez constater que comme JS est un langage typé dynamiquement, l'éditeur est incapable de déterminer le type de 'de'.

À présent, copiez-collez la documentation ci-dessous juste au-dessus de la déclaration de la fonction "printValue":

(...)
/**
 * Affiche la valeur d'un lancé de dé
 * @param {De} de
 */
function printValue(de){
(...)

Testez à nouveau les propositions de complétion de code en tapant un '.' à la suite de 'de'. Grâce à cette documentation, l'éditeur peut déterminer le type de la variable 'de' et propose des choix plus cohérents.

Les modules

Configuration préalable de PHPStorm

L'éditeur en ligne JS.do ne permet pas de tester du code JS réparti en plusieurs fichiers. Nous allons plutôt utiliser l'IDE PHPStorm. Pour rappel, cet IDE offre un environnement node.js et pas celui d'un navigateur. Les versions récentes de node.js permettent d'utiliser les modules ES6 pour autant que les fichiers possèdent l'extension *.mjs au lieu de *.js/.

Création d'un projet node.js

Si ce n'est déjà fait, dans PHPStorm, créez un projet "PHP Empty Project". Créez un sous-répertoire par chapitre du cours.

Ensuite, vérifiez si vous avez node.js installé sur votre ordinateur via la ligne de commande:

node -v

Si nécessaire, rendez-vous sur le site node.js, téléchargez et installez la version courante (Current) qui contient également l'outil de gestion de packages npm (node package manager).

Ouvrez la console de commande dans le dossier racine du projet PHP. Créez votre projet node.js en tapant la commande et en répondant aux quelques questions posées (veuillez spécifier "mocha" à la question "test command"; cette option sera expliquée en fin de ce tutoriel):

npm init

Cette commande crée le fichier package.json contenant des meta-données sur le projet et dans lequel seront spécifiées les dépendances.

Téléchargement et installation des dépendances

Les dépendances suivantes sont nécessaires pour le cours:

Tapez la commande:

npm i --save-dev mocha chai

L'option "--save-dev" indique que ces dépendances sont utilisées uniquement lors de tests effectués en phase de développement et ne doivent pas être chargées lors de la génération de la version de production.

Le fichier package.json est modifié en conséquence et les dépendances sont téléchargées dans le sous-répertoire "node_modules". En cas de partage du projet ou de sauvegarde dans un dépôt git, ce répertoire ne doit surtout pas être copié!! En effet, grâce au fichier package.json, les dépendances peuvent être réinstallées rapidement via la commande suivante tapée dans la racine du projet:

npm install

Les modules ES6+

ES6 a introduit, dans le cœur du langage, le concept de modules pour structurer les sources des applications JavaScript. Un module est un fichier contenant du code (classes, fonctions, variables); ce code est considéré local au module et en mode strict.

Pour pouvoir rendre public tout ou une partie seulement du code, celui-ci doit être exporté avec le mot-clef "export".

Pour pouvoir utiliser les variables, fonctions et classes publiques d'un module, celles-ci doivent alors être importées une à une dans le module courant avec le mot-clef "import".

Il est recommandé d'utiliser l'extension *.mjs au lieu de *.js pour un fichier module ES6.

Et ce pour deux raisons:

  1. distinction plus facile pour les développeurs des fichiers scripts par rapport aux modules,
  2. configuration plus optimisée du transpilateur (i.e.: Babel) qui peut ne traiter que les fichiers modules ES6.

Cependant, si votre environnement node.js n'est pas assez récent (v13.2+), il est possible que les extensions *.mjs ne soient pas supportées. Dans ce cas, mettez à jour votre environnement à la version courante.

Export

Exemple:

//contenu de Facture.mjs
export const TVA = 0.21;
export class Facture {
    constructor(montant){
        this.montant = montant;
    }
    get montantTVAC(){
        return this.montant * (1 + TVA);
    }
}
export function affiche(facture){
    console.log(`Facture d'un montant de ${facture.montantTVAC} € dont ${valeurTVA(facture)} de TVA`);
}
function valeurTVA(facture){ //fonction "privée" du module
    return facture.montant * TVA;
}

Au lieu d'exporter les éléments un à un, il existe une autre syntaxe:

//contenu de Facture.mjs
export {TVA, Facture, affiche};
const TVA = 0.21;
class Facture {
    constructor(montant){
        this.montant = montant;
    }
    get montantTVAC(){
        return this.montant * (1 + TVA);
    }
}
function affiche(facture){
    console.log(`Facture d'un montant de ${facture.montantTVAC} € dont ${valeurTVA(facture)} de TVA`);
}
function valeurTVA(facture){ //fonction "privée" du module
    return facture.montant * TVA;
}

Cette dernière façon permet également de renommer les éléments exportés:

//contenu de Facture.mjs
export {TVA as DEFAUT_TVA, Facture, affiche as afficheMontants};
...

Export d'un élément par défaut

Si nécessaire, il est possible de spécifier quel élément par défaut doit être pris en compte lors de l'importation du module.

//contenu de Facture.mjs
export const TVA = 0.21;
export default class Facture {
    constructor(montant){
        this.montant = montant;
    }
    get montantTVAC(){
        return this.montant * (1 + TVA);
    }
}
...

ou encore selon la syntaxe:

//contenu de Facture.mjs
export {TVA as DEFAUT_TVA, Facture as default, affiche};
const TVA = 0.21;
class Facture {
    constructor(montant){
        this.montant = montant;
    }
    get montantTVAC(){
        return this.montant * (1 + TVA);
    }
}
...

Importer un ou plusieurs éléments

Supposons le fichier "Facture.mjs" avec le début de code suivant:

//contenu de Facture.mjs
export const TVA = 0.21;
export default class Facture {
    constructor(montant){
        this.montant = montant;
    }
    get montantTVAC(){
        return this.montant * (1 + TVA);
    }
}
export function affiche(facture){
    console.log(`Facture d'un montant de ${facture.montantTVAC} € dont ${valeurTVA(facture)} de TVA`);
}
function valeurTVA(facture){ //fonction "privée" du module
    return facture.montant * TVA;
}

Le fichier "myApp.mjs" peut exploiter le module Facture en important son contenu par défaut, ici en l'occurrence la classe Facture :

//fichier myApp.mjs
import Fact from "./Facture.mjs"; //importation par défaut
let facture = new Fact(1000);
console.log(facture.montantTVAC);

Le fichier peut également importer plusieurs éléments du module Facture comme suit:

//fichier myApp.mjs
import {TVA, default as Facture, affiche as logMontants} from './Facture.mjs';
let facture = new Facture(1000);
console.log(facture.montantTVAC); //1210
logMontants(facture);             //Facture d'un montant de 1210 € dont 210 de TVA

Importation conditionnelle de modules

L'importation d'un module peut être dynamique et conditionnelle. Dans ce cas, le mot-clef "import" est utilisé comme une fonction qui retourne une promesse (voir tutoriel "Programmation asynchrone" ).

if(lang === "en"){
    import('./translateInFr.mjs')
        .then(module => {
            module.translate()
        })
        .catch(error => {
            console.log(error)
        })
}

Les modules Node.js

Dans l'environnement orienté serveur Node.js, l'organisation du code en module existe depuis plus longtemps.

La mécanique d'exportation des éléments publics du module et leur importation dans un autre est identique. La syntaxe est légèrement différente:

//contenu de Facture.js
const TVA = 0.21;
class Facture {
    constructor(montant){
        this.montant = montant;
    }
    get montantTVAC(){
        return this.montant * (1 + TVA);
    }
}
function affiche(facture){
    console.log(`Facture d'un montant de ${facture.montantTVAC} € dont ${valeurTVA(facture)} de TVA`);
}
function valeurTVA(facture){ //fonction "privée" du module
    return facture.montant * TVA;
}
module.exports = {TVA, Facture, affiche};

... et l'importation dans le fichier "MyApp.js"


const Fact = require('./Facture');

let facture = new Fact.Facture(1000);
console.log(facture.montantTVAC); //1210
Fact.affiche(facture);            //Facture d'un montant de 1210 € dont 210 de TVA

...ou encore, avec une variante de syntaxe :


const {Facture, affiche} = require('./Facture');
let facture = new Facture(1000);
console.log(facture.montantTVAC);
affiche(facture);

Les modules Node.js sont évidemment reconnus par Node.js, vous pouvez donc exécuter et tester ces modules dans PHPStorm sans transpilation avec Babel.

Par contre, ils ne sont pas reconnus par les navigateurs et donc une étape de transpilation est nécessaire (Babel, Browserify, ...)!

Dans le cadre de ce cours, nous n'utiliserons pas de module Node.js; veuillez donc définir vos modules avec la syntaxe ES6.

Les tests unitaires

Les frameworks de tests unitaires JS

Les frameworks de tests unitaires JS les plus cités sont Jest (https://jestjs.io/) et mocha (https://mochajs.org/). Tous deux permettent d'implémenter des tests unitaires, de les exécuter, de déterminer la couverture de code, d'exécuter des actions avant et/ou après une série de tests, de simuler des ressources (mock), ...

Sans entrer dans les détails, voici quelques éléments de comparaison:

Dans ce cours, afin de rester cohérent avec les outils utilisés dans SALTo, nous utiliserons le tandem Mocha + Chai (expect); et ce d'autant plus que la syntaxe utilisée pour décrire les tests unitaires est fort semblable à celle de Jest et qui est également la plus couramment utilisée selon npmTrends.

Comparaison du nombre de téléchargements d'assert (chai), de should et d'expect de février 2020 à janvier 2022.
Comparaison du nombre de téléchargements d'assert (chai), de should et d'expect de février 2020 à janvier 2022.

Mocha

Installation

Logo Mocha
Mocha

Installez la dépendance "mocha" en exécutant la commande suivante à partir de la racine de votre projet:

npm install --save-dev mocha

Grâce à cette dépendance, dans PHPStorm, vous verrez les symboles "play" de couleur verte dans la marge pour chaque suite de tests et tests unitaires.

Symboles 'lecture' pour exécuter les tests unitaires dans PHPStorm

Choix de l'interface

Mocha propose plusieurs interfaces pour organiser et déclarer des suites de tests et des tests unitaires: BDD (Behavior-Driven Development), TDD (Test-Driven Development), Exports, QUnit et Require.

Dans le cadre de ce cours, nous utiliserons l'interface la plus courante: BDD.


describe('nom de classe ou de module', function(){ //suite de tests liés à une classe ou à un module
    describe('nom de méthode ou de fonction', function(){ 
      //suite de tests liés à une méthode de la classe ou à une fonction du module
        it('libellé explicite du test', function(){
            //test unitaire
        });
        it('libellé explicite du test', function(){
            //test unitaire
        });
        ...
    });
    describe('nom de méthode ou de fonction', function(){
      //suite de tests liés à une méthode de la classe ou à une fonction du module
        it('libellé explicite du test', function(){
            //test unitaire
        });
        it('libellé explicite du test', function(){
            //test unitaire
        });
        ...
    });
});

Avec cette interface, vous pouvez exécuter du code avant/après chaque test, suite de tests ou intégralité des tests unitaires grâce aux fonctions:

describe('hooks', function() {
    before(function() {
        // code exécuté avant tout test unitaire de ce fichier
    });

    after(function() {
        // code exécuté après tout test unitaire de ce fichier
    });

    beforeEach(function() {
        // code exécuté avant tout test unitaire du même bloc 'describe' ou 'context'
    });

    afterEach(function() {
        // code exécuté après tout test unitaire du même bloc 'describe' ou 'context'
    });
    (...)
});

Une bonne pratique consiste à nommer les fichiers de tests unitaires avec l'extension *.spec.js, *.test.js, *.spec.mjs, *.test.mjs pour les différencier plus facilement des fichiers JS contenant du code source.

Exécution de tests unitaires

Créez le fichier "tableval.mjs" et copiez-collez son contenu:

export {TableVal};

class TableVal {
    constructor(...valeurs){
        this.tab = valeurs;
    }
    get total(){
        return this.tab.reduce((s,x) => s+x);
    }

    isEmpty(){
        return this.tab.length === 0;
    }
}

Créez le sous-dossier "__tests__" dans le dossier correspondant à ce tutoriel et créez-y dedans le fichier "TableValTest-mocha.spec.mjs" avec le contenu suivant:

import {TableVal} from '../tableval.mjs';
import assert from 'assert';

describe('TableVal', function(){
    describe('somme', function(){
        it('somme (1,2,3,4,-5) == 5', function(){
            let t = new TableVal(1,2,3,4,-5);
            assert.strictEqual(t.total, 5);
        });
        it('initialisation', function(){
            let t = new TableVal(1,2,3,4,-5);
            assert.deepEqual(t, {tab: [1,2,3,4,-5]});
        });
        it('isEmpty', function(){
            let t = new TableVal(1,2,3,4,-5);
            assert.strictEqual(t.isEmpty(), false, 'table t is not empty');
            let v = new TableVal();
            assert.ok(v.isEmpty(), 'table v is empty');
        });
    });
});

Exécutez les tests unitaires et visualisez le résultat.

Notez que le langage d'assertion utilisé est celle nativement proposée dans l'environnement node.js: node.js assert

Chai

Installation

Logo Chai
Chai

Installez la dépendance "chai" en exécutant la commande suivante à partir de la racine de votre projet:

npm install --save-dev chai

Les différents langages d'assertion proposés par Chai

Pour faciliter la comparaison de la syntaxe, vous trouverez ci-dessous les tests unitaires sur "TableVal" écrits dans les différents langages d'assertion.

assert:

import {TableVal} from '../tableval.mjs';
import {assert} from 'chai';

    describe('TableVal', function(){
        describe('somme', function(){
            it('somme (1,2,3,4,-5) == 5', function(){
                let t = new TableVal(1,2,3,4,-5);
                assert.equal(t.total, 5);
            });
            it('initialisation', function(){
                let t = new TableVal(1,2,3,4,-5);
                assert.deepEqual(t, {tab: [1,2,3,4,-5]});
            });
            it('isEmpty', function(){
                let t = new TableVal(1,2,3,4,-5);
                assert.isFalse(t.isEmpty(), 'table t is not empty');
                let v = new TableVal();
                assert.isTrue(v.isEmpty(), 'table v is empty');
            });
        });
    });
Chai assert API

expect:

import {TableVal} from '../tableval.mjs';
import {expect} from 'chai';

    describe('TableVal', function(){
        describe('somme', function(){
            it('somme (1,2,3,4,-5) == 5', function(){
                let t = new TableVal(1,2,3,4,-5);
                expect(t.total).to.equal(5);
            });
            it('initialisation', function(){
                let t = new TableVal(1,2,3,4,-5);
                expect(t).to.deep.equal({tab: [1,2,3,4,-5]});
            });
            it('isEmpty', function(){
                let t = new TableVal(1,2,3,4,-5);
                expect(t.isEmpty()).to.be.false;
                let v = new TableVal();
                expect(v.isEmpty()).to.be.true;
            });
        });
    });
Chai expect API

should:

import {TableVal} from '../tableval.mjs';
import {should} from 'chai';

describe('TableVal', function(){
    describe('somme', function(){
        it('somme (1,2,3,4,-5) == 5', function(){
            let t = new TableVal(1,2,3,4,-5);
            t.total.should.to.equal(5);
        });
        it('initialisation', function(){
            let t = new TableVal(1,2,3,4,-5);
            t.should.to.deep.equal({tab: [1,2,3,4,-5]});
        });
        it('isEmpty', function(){
            let t = new TableVal(1,2,3,4,-5);
            t.isEmpty().should.to.be.false;
            let v = new TableVal();
            v.isEmpty().should.to.be.true;
        });
    });
});
Chai should API

Jest

Installation

Logo Jest
Jest

Installez la dépendance "jest" en exécutant la commande suivante à partir de la racine de votre projet:

npm install --save-dev jest

L'utilisation de Jest avec des modules ES6 nécessite une configuration particulière. Tout d'abord, il faut lui indiquer quels fichiers contiennent des tests unitaires Jest. Pour cela, nommez vos fichiers de tests unitaires en utilisant l'extension "*.test.mjs" s'ils utilisent des modules ES6 ou "*.test.js" sinon. Ensuite ajoutez les lignes suivantes dans le fichier package.json:

  "type": "module",
  "jest": {
    "verbose": true,
    "testMatch": [
      "/**/*.test.mjs",
	  "/**/*.test.js"
    ],
    "transform": {}
  }

Enfin, certains modules utilisés par Jest pour interpréter les modules ES6 sont toujours considérés comme expérimentaux par node (en janvier 2022... il est possible que ce ne soit plus le cas à l'avenir...). Consultez la documentation de Jest sur le support des modules ECMAScript par Jest

Dans les templates de configuration d'exécution de JEST, spécifiez "--experimental-vm-modules" dans le paramètre "Node options".

import {TableVal} from '../tableval.mjs';

describe('TableVal', function(){
    describe('somme', function(){
        test('somme (1,2,3,4,-5) == 5', function(){
            let t = new TableVal(1,2,3,4,-5);
            expect(t.total).toBe(5);
        });
        test('initialisation', function(){
            let t = new TableVal(1,2,3,4,-5);
            expect(t).toEqual({tab: [1,2,3,4,-5]});
        });
        test('isEmpty', function(){
            let t = new TableVal(1,2,3,4,-5);
            expect(t.isEmpty()).toBeFalsy();
            let v = new TableVal();
            expect(v.isEmpty()).toBeTruthy();
        });
    });
});
Jest expect

Exercices

Exercices

Exercice js.class1

Afin de gérer le classement des cyclistes d'un tournoi en plusieurs épreuves, vous devez créer une classe "Cycliste" et une classe "Classement".

Un cycliste est identifié par son matricule; possède un nom et un score. Il faut pouvoir réinitialiser le score d'un cycliste, obtenir une version imprimable d'un cycliste (format: NOM (matricule): score). Prévoyez également un "setter" pour ajouter plus facilement les points d'une épreuve au score du cycliste.

Un classement contient un ensemble de cyclistes ainsi que le nombre des récompenses à attribuer. Les récompenses seront offertes aux cyclistes dans l'ordre de leur classement.

À cette fin, prévoyez les fonctionnalités suivantes sur le classement:

Prévoyez également l'affichage du classement après chaque épreuve. Celles-ci sont numérotées automatiquement en commençant à 1.

Exemple d'utilisation pour un tournoi avec 3 récompenses:

  1. initialisation avec les cyclistes DUPONT J., n° 126 et 10 points; MATMAH F., n°83 et 26 points,
  2. affichage du classement,
  3. ajout des cyclistes DUPONT J., n° 126 et 15 points; LUCULUS O., n° 42 et 64 points; XIANG P., n° 7654 et 59 points; LEPONT Q., n°4682 et 16 points,
  4. affichage du classement,
  5. attribution de 1000 points au cycliste n°42,
  6. affichage du classement,
  7. disqualification du cycliste n°42,
  8. affichage du classement

Affichage du l'exécution correspondante du programme en console:

Epreuve 1|MATMAH F. (83): 26, DUPONT J. (126): 10
Epreuve 2|LUCULLUS O. (42): 64, XIANG P. (7654): 59, MATMAH F. (83): 26
Epreuve 3|LUCULLUS O. (42): 1064, XIANG P. (7654): 59, MATMAH F. (83): 26
Epreuve 4|XIANG P. (7654): 59, MATMAH F. (83): 26, LEPONT Q. (4682): 16

Exercice js.class2

Créez une classe "Vecteur" dans un module "math_vectoriel" qui permet l'addition, la soustraction, la multiplication et le calcul de la norme d'un vecteur (à 4 chiffres maximum après la virgule) ainsi qu'une version texte du vecteur (chaque composante séparée par une virgule et le tout placé entre parenthèse. Exemple: "(26, 35, 9, 7)").

Les opérations d'ajout, de soustraction ou de multiplication entre deux vecteurs de longueurs différentes génèrent une exception.

Créez une classe "Point" dans un module "math_geometrie" qui définit un point dans un plan selon ses coordonnées x et y. Implémentez la méthode toString() qui retourne une version texte du point (a,b) dans le format "(x: a, y: b)".

Créez une classe "Vecteur2D" dans le module "math_geometrie" qui hérite de "Vecteur". Un vecteur du plan ne possède que 2 composantes déterminées par son point d'origine et son point de fin. Implémentez les méthodes estParalleleA(Vecteur2D) et estPerpendiculaireA(Vecteur2D) qui sont des fonctions booléennes.

Créez des tests unitaires sur vos différentes méthodes.

Créez une application qui définit les points A(1,4), B(3,6), C(1,2), D(5,6), E(2,1), F(0,1); puis qui vérifie la perpendicularité ou le parallélisme des vecteurs AB vs CD, AB vs CE et AB vs FC.

La liste des points:
	(x: 1, y: 4)
	(x: 3, y: 6)
	(x: 1, y: 2)
	(x: 5, y: 6)
	(x: 2, y: 1)
	(x: 0, y: 1)
AB est parallele à CD
AB n'est pas parallele à CE
AB n'est pas perpendiculaire à CD
AB est perpendiculaire à CE
AB est parallele à FC
AB n'est pas perpendiculaire à FC
Rappels mathématiques

Soient les vecteurs a(1,2,3) , b(3,4,5) et c(5,6,7,8),

a + b = (1+3,2+4,3+5) = (4,6,8)
a - b = (1-3,2-4,3-5) = (-2,-2,-2)
a . b = 1*3 + 2*4 + 3*5 = 26
||a|| = racine carrée de (1² + 2² + 3²) = 3,7416
a + c est interdit.

Le vecteur v reliant le point A(3,5) vers B(2,8) est défini par les composantes (2-3 = -1, 8-5 = 3).

Les vecteurs v(a,b) et w(c,d) sont parallèles si a/c = b/d.

Les vecteurs v(a,b) et w(c,d) sont perpendiculaires si v * w = 0.