Les applications créées avec le framework Angular sont construites à partir de composants (« components » en anglais), ces derniers sont donc le point de départ idoine pour bien commencer dans le monde des tests.

En guise d’exemple, imaginez un composant qui affiche un calendrier. Il permettra à un utilisateur de sélectionner une date, de modifier la date sélectionnée, de faire défiler les mois et les années, etc. Il nous incombe de rédiger un cas de test pour chacune de ces fonctionnalités.

Dans cet article, nous aborderons les principales classes et fonctions de test, telles que TestBed, ComponentFixture et fakeAsync, qui nous aideront à tester nos composants. Une bonne compréhension de ces classes et fonctions est nécessaire pour écrire ces tests, le sujet de ces articles n’étant pas l’apprentissage du framework.

Plan global du cours

Markup

Retrouvez le code de cet article sur ce dépôt stackblitz :

Open in StackBlitz

Ou suivez pas à pas les instructions 😇 !

Tester un composant simple

La meilleure façon de se familiariser avec l’écriture de tests de composants est d’écrire quelques tests pour un composant assez simple. C’est à dire un composant avec très peu de fonctionnalités, ce qui va le rendre très facile à tester.

ContactFormComponent

Ce composant servira de support à nos premiers tests. Il s’agit d’un composant qui affiche un formulaire avec un seul champ (message) et un bouton pour envoyer le formulaire. Techniquement on ne va pas envoyer le message, car de toute façon tester l’envoi du message n’a pas sa place dans un test unitaire. Au lieu de ça, on affichera simplement un message de confirmation ou d’infirmation.

Ci après le code du composant :

import { Component } from '@angular/core';
import { FormControl, FormGroup, Validators } from "@angular/forms";

@Component({
  selector: 'app-contact-form',
  template: `
    <form [formGroup]="contactForm" (ngSubmit)="submitForm()">
      <div>
        <label>Message : </label>
        <input formControlName="message"/>&nbsp;
        <button type="submit">Envoyer</button>&nbsp;
        <span *ngIf="error">Error: message not sent!</span>
        <span *ngIf="success">Info: message sent!</span>
      </div>
    </form>`
})
export class ContactFormComponent {

  error = false;
  success = false;

  // -- Forms controls -- //
  contactForm: FormGroup;
  message: FormControl;

  constructor() {
    this.message = new FormControl('', Validators.required);
    this.contactForm = new FormGroup<any>({
      message: this.message
    });
  }

  submitForm() {
    if(this.contactForm.valid) {
      this.success = true;
      this.error = false;
    } else {
      this.success = false;
      this.error = true;
    }
  }
}

Snippet 3.1 – Le composant ContactFormComponent.

En pratique, cela donne un composant plutôt dépouillé :

Figure 3.1 – Plus simple tu meurs…

Notre composant support est prêt à être tester, alors c’est parti !

Réflexion : comment tester un composant

Avant de se jeter corps et âme dans l’écriture d’un test unitaire, nous devons d’abord nous poser les bonnes questions. Le sujet est important car beaucoup butent sur cette question simple : que dois-je tester dans un composant Angular ?

La réponse est au moins aussi simple que la question : il suffit simplement de s’assurer que le code du composant réagit comme prévu. On va donc se focaliser sur les méthodes du composant, en mettant de côté le template.

D’une manière un peu générale et abstraite, il faut essayer de se placer dans le contexte du composant, de son point de vu. Et de se demander : « comment doit réagir mon composant quand il se passe telle action ? ».
Ou bien « si me composant exécute cette action, qu’est ce que cela engendre ? ».

Il faut également veiller à ne jamais dépasser le premier niveau d’enchaînement, c’est à dire qu’il faut s’intéresse seulement aux conséquences qui sont directement liées au composant.
Par exemple, imaginons un composant qui envoie un formulaire d’inscription via un service (apiService) dédié. Ici on va simplement vérifier que la bonne méthode de notre service est appelée si le formulaire est valide, avec éventuellement les bons paramètres. On ne vérifie pas ce que la méthode du dit service fait, cela équivaudrait à un « test traversant ». Plus tard on testera en isolation notre service et c’est à cette occasion qu’on vérifiera son comportement.

Construction du test pas à pas

La première étape de la création du test consiste à importer les dépendances. De manière générale, ce type de test nécessitera deux dépendances, bien que dans notre cas précis seule la seconde dépendance sera utile :

  • ① → la première est ContactFormComponent, à savoir le composant à tester.
  • ② → la seconde est le « matériel Angular » si je puis m’exprimer ainsi, les outils qui vont nous aider à tester notre composant.
import { ComponentFixture, TestBed } from '@angular/core/testing'; ①

import { ContactFormComponent } from './contact-form.component'; ②

On notera que je fais toujours une séparation entre les dépendances provenant du framework et celles venant de la logique métier. Ici elle est matérialisée par un saut de ligne.

Maintenant, nous allons créer la suite de tests qui abritera tous les tests du composant. Après les instruction d’importation, ajoutons un bloc describe pour initier la suite de tests :

describe('ContactFormComponent', () => {
}

Ensuite, nous devons déclarer une variable nommée ContactFormComponent qui fait référence à une instance de ContactsFormComponent. Cette variable sera instanciée dans le bloc beforeEach des tests. Cela garantira une nouvelle instance du composant ContactsFormComponent à chaque nouveau test, ce qui empêchera les différents tests d’interférer les uns avec les autres. Sur la première ligne à l’intérieur de la fonction de rappel describe, ajoutons le code suivant :

let component: ContactFormComponent;

Ce qui pourrait se traduire par : « initialise moi la variable component qui sera de type ContactFormComponent ».

A ce stade il est important de noter que la variable n’est encore instanciée, seulement déclarée.

Par la suite, nous devrons donc instancier cette variable dans la fonction beforeEach de notre suite de tests :

import { ContactFormComponent } from './contact-form.component'; ①

describe('ContactFormComponent', () => {
  let component: ContactFormComponent; ②

  beforeEach(() => {
    component = new ContactFormComponent(); ③
  });
});

Bon on récapitule car je pense en avoir perdu au moins la moitié 😆 :

  • ① → import des dépendances.
  • ② → nous déclarons une variable component qui sera de type ContactFormComponent.
  • ③ → la variable component est instanciée.

En procédant ainsi, on s’assure de toujours avec un composant « vierge » dans chaque test (qui ne sont pas encore écrits 😝).
A présent, nous sommes prêt pour ajouter notre premier test : vérifier que le composant s’instancie correctement, ou dit autrement vérifier que le composant a un constructeur valide. On le fait avec le test suivant :

it('should create the component', () => {
  expect(component).toBeTruthy();
});

Snippet 3.2 – Un test simple mais efficace.

Ici, on utilise le matcher toBeTruthy car on ne se soucie pas de la valeur testée, on veut juste s’assurer qu’elle soit vraie dans un contexte booléen.
Mis bout à bout, notre fichier de test ressemble désormais au snippet 3.3 suivant :

import { ContactFormComponent } from './contact-form.component'; ①

describe('ContactFormComponent', () => { ②
  let component: ContactFormComponent; ③

  beforeEach(async () => {
    component = new ContactFormComponent(); ④
  });

  it('should create the component', () => { ⑤
    expect(component).toBeTruthy(); ⑥
  });
});

Snippet 3.3 – Une première suite de tests complète.

Voyons tout ceci en détail :

  • ① → import des dépendances.
  • ② → déclaration de la suite de tests.
  • ③ → déclaration d’une variable component qui sera de type ContactFormComponent. Sa portée se limitera au bloc describe.
  • ④ → la variable component est instanciée avant chaque test car on se trouve dans la fonction beforeEach.
  • ⑤ → déclaration du premier test unitaire.
  • ⑥ → vérification que le constructeur du composant est valide.

Pour bien se rendre compte de ce que nous avons accompli, démarrons la suite de tests avec la commande suivante :

# ./node_modules/.bin/ng test

Jasmine devrait automatiquement ouvrir le navigateur Chrome grâce à Karma :

Figure 3.2 – Premier test d’un composant.

Terminer la suite de tests

Maintenant que l’on a une bonne vue d’ensemble sur la façon de structurer une suite de tests, nous pouvons ajouter quelques tests supplémentaires pour couvrir la totalité des fonctionnalités du composant. Voici ce que je propose de tester :

  • vérifier la validité du formulaire
  • vérifier qu’en cas d’erreur un message d’erreur s’affiche
  • vérifier qu’en cas de succès un message de confirmation s’affiche
  • vérifier qu’aucuns messages ne s’affiche initialement

Pour le premier cas, le test est trivial : nous devons faire deux tests :

  1. si le champ message est vide, le formulaire ne doit pas être valide
  2. si le champ message n’est pas vide, le formulaire doit être valide

Et comme nous allons faire deux tests pour un seul cas (validité du formulaire), on va même ajouter une petite section describe :

describe('Form', () => { ①

  it('should validate the form if « message » field is not empty', () => { ②
    component.contactForm.setValue({ ③
      message: 'hello'
    });
    expect(component.contactForm.valid).toBeTruthy(); ④
  });

  it('should not validate the form if « message » field is empty', () => { ⑤
    component.contactForm.setValue({ ⑥
      message: ''
    });
    expect(component.contactForm.valid).toBeFalsy(); ⑦
  });

});

Je vous dois quelques explications 🙃 :

  • ① → on déclare une nouvelle « sous suite » de tests car les tests que l’on va écrire sont liés entre eux.
  • ② → on déclare le premier test, qui doit vérifier que le formulaire est valide si l’unique champ (« message ») est renseigné.
  • ③ → on rentre manuellement une valeur dans l’unique champ « message » du formulaire.
  • ④ → on teste la propriété valid du formulaire, qui sera égale à true si le formulaire est valide, false sinon.
  • ⑤ → le second test fait l’inverse du premier : il doit vérifier que le formulaire n’est pas valide si le champ « message » est vide.
  • ⑥ → ici on s’assure que le champ « message » sera bien vide pour le test.
  • ⑦ → cette fois, la propriété valid du formulaire doit retourner false, ce qui indique que le formulaire n’est pas valide.

A première vue, le point ⑥ peut paraître étrange. A quoi bon entrer une valeur vide dans le champ de formulaire alors que celui-ci l’est déjà de par sa construction 🤔 ? La réponse est simple : imaginiez qu’un beau jour vous décidiez, pour une raison quelconque, de « pré-remplir » le formulaire. Dans ce cas le test sera cassé, alors que la fonctionnalité sera toujours correcte.

On peut voir le résultat avec Jasmine :

Figure 3.3 – Le formulaire est prêt.

De manière à rendre les choses légèrement plus concises, on peut faire quelques modifications pour réduire la longueur de nos tests, bien qu’ici ils ne soient pas très long (pour le moment). C’est toujours mieux de prendre les bonnes habitudes dès le début 😋.
Je pense plus particulièrement à la variable component.contactForm qui rend le test légèrement « verbeux ». On va donc isoler cette variable dans un bloc beforeEach, en lui donnant le nom de contactForm, histoire de rendre tout ceci plus clair :

import { ContactFormComponent } from './contact-form.component';
import { FormGroup } from "@angular/forms";

describe('ContactFormComponent', () => {
  let component: ContactFormComponent;
  let contactForm: FormGroup; ①

  beforeEach(async () => {
    component = new ContactFormComponent();
    contactForm = component.contactForm; ②
  });

  it('should create the component', () => {
    expect(component).toBeTruthy();
  });

  describe('Form', () => {
    it('should validate the form if « message » field is not empty', () => {
      contactForm.setValue({ ③
        message: 'hello'
      });
      expect(contactForm.valid).toBeTruthy();
    });
    
    it('should not validate the form if « message » field is empty', () => {
      contactForm.setValue({
        message: ''
      });
      expect(contactForm.valid).toBeFalsy();
    });
  });
});

Snippet 3.4 – Résultat après optimisation.

  • ① → la variable contactForm est initialisée avec le type FormGroup.
  • ② → la variable est associée à la valeur component.contactForm avant chaque test. Le fait que ce soit fait avant chaque test ne pose aucuns problèmes.
  • ③ → la variable contactForm est disponible dans nos blocs de tests it.

Passons maintenant aux deux cas suivants, qui par ailleurs se ressemblent comme deux gouttes d’eau :

  • vérifier qu’en cas d’erreur de l’envoi un message d’erreur s’affiche.
  • vérifier qu’en cas de succès de l’envoi un message de confirmation s’affiche.

Comme précisé plus haut, on n’envoie pas réellement un email, on se contente pour le moment d’afficher un message de succès (ou d’erreur) si toutes les conditions sont réunis. A savoir:

  • le formulaire doit être valide (ou non pour l’erreur d’envoi)
  • l’utilisateur clique sur le bouton « Envoyer »

Nous arrivons donc à la question centrale : comment simuler l’action de cliquer sur le bouton ? On pourrait retrouver notre bouton avec un sélecteur css puis déclencher un événement :

it('should call a function when the button is clicked', () => {
  const button = fixture.debugElement.query(By.css('button')); // get the DebugElement for the button
  button.nativeElement.click(); // simulate a click on the button
});

Snippet 3.5 – Exemple de ce qu’il ne faut pas faire.

Le problème de cette méthode est qu’elle rend le test très instable. Pour une bonne raison : quel est le meilleur sélecteur css à utiliser ? Je veux dire, ici on a qu’un seul bouton, mais si demain j’en ai un deuxième, le test casse. Mais pas la fonctionnalité.
De plus, utiliser cette méthode revient aussi à vérifier que le framework fait se pourquoi on l’utilise, c’est à dire que l’évènement ngSubmit du formulaire fonctionne correctement. Et ce n’est pas à nous de le faire, on veut juste savoir si après avoir cliquer il se passe ce que nous voulons.

Ici la meilleur façon d’arriver à nos fins est donc d’activer manuellement la fonction submitForm, car c’est effectivement, grâce au framework, ce qu’il se passe : si je clique sur le bouton, l’évènement ngSubmit exécute la fonction submitForm.

Ce test se résume donc à tester la fonction submitForm du composant, on n’a donc pas besoin du template html pour arriver à nos fins. Ceci est vrai dans 99% des tests unitaires. Sinon ce n’est plus un test unitaire mais un test fonctionnel.

Arrêtons ici les digressions, passons à la pratique. Dans ce genre de tests, j’aime bien les regrouper dans une section « Comportement du composant », de cette façon on sait qu’on s’intéresse à une fonctionnalité qui nécessite une action utilisateur :

describe('Component behavior', () => { ①

  it('shoud display an error message if the email is not sent', () => { ②
    contactForm.setValue({message: ''}); ③
    component.submitForm(); ④
    expect(component.error).toBeTrue(); ⑤
    expect(component.success).toBeFalse(); ⑥
  });

  it('shoud display a success message if the email is not sent', () => {
    contactForm.setValue({message: 'Hi there!'});
    component.submitForm();
    expect(component.error).toBeFalse();
    expect(component.success).toBeTrue();
  });

});

A mon avis quelques explications s’imposent :

  • ① → déclaration d’une nouvelle suite de tests, désormais on n’a l’habitude. 😋
  • ② → déclaration du premier test unitaire.
  • ③ → on s’assure que le formulaire sera invalide ce qui doit déclencher l’erreur.
  • ④ → le formulaire est soumis.
  • ⑤ → je vérifie que la variable component.error est à true, ce qui affiche le message d’erreur.
  • ⑥ → je vérifie que la variable component.success est à false, ce qui masque le message de confirmation.

Vous l’avez compris, j’ai pris le raccourci suivant : un message (d’erreur ou de confirmation) est affiché si et seulement si sa valeur correspondante (component.error pour l’erreur et component.success pour la confirmation) est à true.

Sans doutes avez-vous pensé que nous aurions pu faire la chose suivante :

let fixture = TestBed.createComponent(ContactFormComponent);
const button = fixture.debugElement.query(By.css('span')).nativeElement;
expect(getComputedStyle(button).display).toEqual('none');

Et vous avez raison, mais comme précisé plus haut, cette méthode est à proscrire car source d’ennuis à la puissance 10. Par exemple, dans ce cas précis, qui me dit qu’il faut tester la propriété display plutôt que visibility ? Ou bien alors les deux ? Tirons à pile ou face ! 🤭

Pour ceux qui sont encore là, vous avez sans doutes notez qu’il manque notre dernier test :

  • vérifier qu’aucuns messages ne s’affiche initialement

Après avoir fait les deux cas, précédents, celui-ci ressemble à une promenade de santé 😉. En guise d’exercice, je vous laisse le faire par vous même.

Récapitulatif

Que de chemin parcouru ! On en oublierait presque de tester tout en direct avec Karma/Jasmine. « Let’s dot it ! » comme disent les anglophones :

Figure 3.4 – Du vert partout !

Et voici le code final de notre suite de tests :

import { ContactFormComponent } from './contact-form.component';
import { FormGroup } from "@angular/forms";

describe('ContactFormComponent', () => {
  let component: ContactFormComponent;
  let contactForm: FormGroup;

  beforeEach(async () => {
    component = new ContactFormComponent();
    contactForm = component.contactForm;
  });

  it('should create the component', () => {
    expect(component).toBeTruthy();
  });

  describe('Form', () => {
    it('should validate the form if « message » field is not empty', () => {
      contactForm.setValue({
        message: 'hello'
      });
      expect(contactForm.valid).toBeTruthy();
    });

    it('should not validate the form if « message » field is empty', () => {
      contactForm.setValue({
        message: ''
      });
      expect(contactForm.valid).toBeFalsy();
    });
  });

  describe('Component behavior', () => {

    it('shoud display an error message if the email is not sent', () => {
      contactForm.setValue({message: ''});
      component.submitForm();
      expect(component.error).toBeTrue();
      expect(component.success).toBeFalse();
    });

    it('shoud display a success message if the email is not sent', () => {
      contactForm.setValue({message: 'Hi there!'});
      component.submitForm();
      expect(component.error).toBeFalse();
      expect(component.success).toBeTrue();
    });

    it('shoud not display any message after init', () => {
      expect(component.error).toBeFalse();
      expect(component.success).toBeFalse();
    });

  });
});

Snippet 3.6 – La suite de tests complète.

Ce que nous avons appris

Nous venons d’apprendre qu’il est possible de tester de façon très direct des composants Angular simple, sans sortir l’artillerie lourde des outils de tests d’Angular. Et contrairement aux idées reçues, de tels composants sont monnaie courante, car nous devons toujours veiller à développer des composants qui soit clairs et concis, et donc facilement testables par la méthode que je présente ici.

Parfois, cependant, on n’aura d’autre choix que d’écrire des composants un peu plus complexes, notamment des composants avec des dépendances. C’est l’objet du prochain chapitre.