Tester des pipes Angular

Tester des pipes Angular

Souvent, parfois, vous souhaiterez modifier des données affichées dans un modèle. Par exemple, vous souhaiterez peut-être formater un nombre en tant que devise, transformer une date dans un format plus facile à comprendre ou mettre du texte en majuscule. Dans des situations comme celles-ci, Angular fournit un moyen de transformer des données à l’aide de ce qu’on appelle un « pipe » (prononcez « paillepe »). Les « pipes » prennent une valeur en entrée, la transforment, puis renvoient la valeur transformée. Parce que le fonctionnement des « pipes » est simple, écrire des tests pour eux l’est aussi. Les « pipes » ne dépendent que de leur valeur d’entrée. Une fonction dont la sortie ne dépend que de l’entrée qui lui est transmise est aussi appelée fonction pure.

Lorsqu’une fonction peut faire autre chose que renvoyer une valeur, on dit qu’elle a un « side effect » (ou effet secondaire). Un « side effect » peut être la modification d’une variable globale ou l’exécution d’une requête HTTP. Les fonctions pures comme les « pipes » n’ont pas d’effets secondaires, c’est pourquoi elles sont faciles à tester.

Plan global

Tester un pipe : PhoneNumber

Présentation du support des tests

Pour illustrer le propos de cet article, nous allons utiliser un « pipe » maison qui se propose de formater une chaîne de caractères en numéro de téléphone : PhoneNumberPipe. Tant que nous y sommes, faisons dans l’originalité 😇.

Ce « pipe » prend un numéro de téléphone sous la forme d’une chaîne (dans un format valide) et le transforme dans un format spécifié par l’utilisateur. Dans ce cas précis, les tests auront pour objectif de vérifier que la classe fonctionne comme attendu.

Du point de vue Angular, chaque « pipe » a une méthode nommée transform. Cette méthode est responsable du formatage de la valeur d’entrée du « pipe ». La signature de la fonction de transformation pour PhoneNumberPipe ressemble à ceci :

transform(value: string, countryCode: string = '33'): string {

value est transmis à la fonction à partir de la gauche du « pipe » et représente un numéro de téléphone. Comme ceci :

<p>{{ '0474769560' | phone }}</p>

countryCode est un paramètre facultatif qui ajoute un préfixe au numéro de téléphone en fonction du code international du pays. Par exemple, avec un code pays 33 (pour la France), le numéro de téléphone résultant serait +33 (XX) XX XX XX XX.

Pour rester simple, PhoneNumberPipe ne fonctionne qu’avec les numéros de téléphone qui suivent le plan de numérotation français.

Tester des directives Angular (2/2)

Tester des directives Angular (2/2)

Dans ce chapitre, qui se veut plus court que le précédent, nous allons tester une directive structurelle. Késako 🤨 ? Les directives structurelles sont utilisées pour ajouter et/ou supprimer des éléments du DOM – pour modifier la structure de la page par exemple. Angular inclut quelques directives structurelles prêtes à l’emploi, comme ngIf et ngShow.

Plan global

Markup

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

Open in StackBlitz

Ou suivez pas à pas les instructions 😇 !

Tester une directive structurelle

Pour tester une directive d’attribut, c’est au final assez simple : il faut récupérer une instance de la directive, effectuer une action, puis vérifier que les modifications attendues sont bien répercutées dans le DOM. Mais avant cela, examinons de plus près la directive Unless que nous allons tester.

import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';

@Directive({ selector: '[appUnless]'})
export class UnlessDirective {
  private hasView = false;

  constructor(
    private templateRef: TemplateRef<any>,
    private viewContainer: ViewContainerRef) { }

  @Input() set appUnless(condition: boolean) {
    if (!condition && !this.hasView) {
      this.viewContainer.createEmbeddedView(this.templateRef);
      this.hasView = true;
    } else if (condition && this.hasView) {
      this.viewContainer.clear();
      this.hasView = false;
    }
  }
}

Snippet 6.1 – unless.directive.ts.

La directive s’utilise de la façon suivante, sur un élément :

<p *appUnless="condition">Montre cette phrase sauf si la condition est vraie</p>

Vous l’avez remarqué ? Je veux parler de l’astérisque 🙃. L’astérisque transforme l’élément auquel la directive est attachée en modèle. La directive contrôle ensuite la façon dont ce modèle est rendu au DOM, c’est-à-dire comment il peut modifier la structure de la page. Consultez la doc Angular sur le sujet pour en savoir plus.

Tester la directive Unless

Répertorier les cas possibles

Pour arriver à nos fins, il me semble judicieux de lister toutes les configurations possible de la directive, ce qui permettra d’en extraire plus facilement les « tests cases ». 

Configuration de test (« test case »)Affichage
L’élément doit être présent dans le DOM si la condition est « false ».Le texte est visible.
L’élément doit ne doit pas être présent dans le DOM si la condition est « true ».Le texte est invisible.
Tableau 6.1 – Deux « tests cases » pour la directive Unless.

Mise en place de la suite de tests

Maintenant que nous avons une bonne vision d’ensemble des « tests cases », nous pouvons créer la suite de tests.
Pour cela, créons d’abord un fichier nommé unless.directive.spec.ts dans le même répertoire que la classe de la directive. Comme pour la directive d’attribut, on aura besoin d’un composant « support » pour les tests :

import { Component } from "@angular/core";  ①
import { ComponentFixture, TestBed, TestModuleMetadata } from "@angular/core/testing";

import { FavIconDirective } from './fav-icon.directive';  ②

@Component({  ③
  selector: 'app-test',
  template: `  ④
    <div>
      <p *appUnless="true" id="am-i-there">Ce texte n'existe pas !</p>
      <p *appUnless="false" id="that-is-the-question">Mais celui-ci oui</p>
    </div>
  `,
  styles: []
})
class TestComponent {
}

Snippet 6.2 – Préparation du composant support pour le test.

Le composant de test est à définir directement dans le fichier unless.directive.spec.ts, cela montre bien notre intention d’avoir recours à un composant support. Récapitulons légèrement :

  • ① → import des dépendances du framework de tests d’Angular.
  • ② → import de la directive à tester.
  • ③ → définition du composant « support ».
  • ④ → le template embarque les 2 cas que nous avons identifiés plus haut.

Bien ajoutons la suite du test, c’est à dire le paramétrage du module de test du framework de test d’Angular :

// -- import section. Voir snippet 6.2.

describe('UnlessDirective', () => {  ①
  let fixture: ComponentFixture<TestComponent>;
  let component: TestComponent;

  beforeEach(async () => {
    const testModuleMetadata: TestModuleMetadata = {  ②
      declarations: [UnlessDirective, TestComponent]
    };
    await TestBed.configureTestingModule(testModuleMetadata).compileComponents();  ③

    fixture = TestBed.createComponent(TestComponent);  ④
    component = fixture.componentInstance;
    fixture.detectChanges();  ⑤
  });

  it('should not render the element if condition is true', () => {  ⑥

  });

  it('should render the element if condition is false', () => {  ⑥

  });
});

On en a aussi profité pour mettre en place les deux petits tests unitaires qui seront nécessaires pour cette directive. Les pauvres, ils se sentiront bien seuls une fois qu’on ne sera plus là 😌.

Comme à l’accoutumée, essayons d’y voir plus clair :

  • ① → déclaration de la suite de tests
  • ② → déclaration des options du module de test dans une variable. On apprend au passage que le type requis est TestModuleMetadata.
  • ③ → création du module de test.
  • ④ → création de la fixture qui permettra de « jouer » avec le composant de test.
  • ⑤ → initier le mécanisme « Change Detection » d’Angular.
  • ⑥ → déclaration des 2 tests unitaires.

Nous en arrivons donc au point crucial : comment vérifier qu’un élément soit bien rendu dans le DOM ? Très simple : nous allons le sélectionner avec un sélecteur css et tester son contenu. Bien, mais pourtant j’avais précisé en introduction de ce document qu’on évite au maximum de requêter le DOM car cela fragilise les tests. Oui c’est vrai, mais là on va requêter le composant de test, on peut donc s’en donner à coeur joie 😇. Ce qui donne pour les deux tests :

it('should not render the element if condition is true', () => {
  const p: HTMLElement = fixture.nativeElement.querySelector('#am-i-there');  ①
  expect(p).toBeNull();  ②
});

it('should render the element if condition is false', () => {
  const p: HTMLElement = fixture.nativeElement.querySelector('#that-is-the-question');  ③
  expect(p).not.toBeNull();  ④
  expect(p.innerText).toEqual('Mais celui-ci oui');  ⑤
});
  • → sélection de l’élément avec l’id am-i-there.
  • → nous vérifions qu’il est null.
  • → sélection de l’élément avec l’id that-is-the-question.
  • → nous vérifions qu’il n’est pas null.
  • → nous vérifions aussi le contenu de l’élément.

Parfait ! Je l’avoue volontiers, j’ai connu bien plus harassant comme suite de tests ! Juste pour le plaisir des yeux, vérifions que tout fonctionne comme attendu :

Fig 6.1 – Résultat de la suite de tests.

Ce que nous avons appris

  • Angular autorise trois types de directives : les composants, les directives d’attribut et les directives structurelles. Elles sont toutess similaires en ce sens qu’elles encapsulent des fonctionnalités réutilisables. La différence entre les composants et les directives d’attribut et structurelles est que les composants possèdent une vue.
  • Vous pouvez utiliser des directives d’attribut pour modifier l’apparence d’un élément, tandis que vous utiliserez des directives structurelles pour ajouter et supprimer des éléments du DOM.
  • Tester des directives structurelles et d’attribut est similaire en ce sens que vous définissez l’état initial d’un élément, effectuez l’action souhaitée, puis testez pour confirmer que le changement attendu se produit.
  • La méthode configureTestingModule prend un objet qui doit utiliser l’interface TestModuleMetadata. Vous pouvez soit créer une variable qui définit le type sur TestModuleMetadata, puis transmettre la variable à la méthode configureTestingModule, soit créer un objet avec des données de configuration pertinentes, puis la transmettre à la méthode configureTestingModule.
Tester des directives Angular (1/2)

Tester des directives Angular (1/2)

Dans ce chapitre, nous allons apprendre à tester des directives. Les directives, comme les composants, sont en quelques sortes un moyen « d’encapsuler » des parties de votre application sous forme de morceaux de code réutilisables. À l’instar des composants, les directives permettent d’ajouter un comportement au code HTML de l’application. Par exemple, supposons que votre application comporte une table et que vous souhaitiez modifier la couleur d’arrière-plan d’une ligne lorsque le pointeur la survole (un classique 😋). Vous pouvez créer une directive nommée « HighlightRowDirective » qui ajoute ce comportement de mise en surbrillance des lignes puis la réutiliser partout dans le projet.

Mais avant de commencer à écrire des tests pour des directives, examinons d’un peu plus prêt ce dont il s’agit.

Plan global

Markup

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

Open in StackBlitz

Ou suivez pas à pas les instructions 😇 !

Qu’est ce qu’une directive ?

Angular fournit trois types de directives :

  • les composants (eh oui 🤭)
  • les directives structurelles
  • les directives d’attributs

Les directives et les composants sont similaires. Commençons par explorer les différences et les similitudes entre les deux.

Composants ou directive ?

Les composants sont un « type » de directive. La seule différence entre les deux est que les composants contiennent une vue (définie par un modèle). Une autre façon de visualiser cette différence est de se dire que les composants sont visibles dans le navigateur, et les directives ne le sont pas. Par exemple, un composant peut être un en-tête ou un pied de page, alors qu’une directive modifie l’élément auquel elle est attachée. Une directive peut ajouter des classes à un élément ou masquer et afficher quelque chose en fonction d’une condition. Des exemples de directives intégrées à Angular sont ngFor et ngIf.

Pour approfondir votre compréhension de cette notion, nous créerons dans un premier temps un décorateur. Pour ma part, j’ai fait mes tests dans le composant HomeComponent, mais vous pouvez faire comme bon vous semble 😊. Les décorateurs sont un moyen simple d’ajouter un comportement à une classe ou à une méthode, un peu comme les annotations en Java.

Différents types de directives

Utilisez les directives d’attribut lorsque vous essayez de modifier l’apparence d’un élément du DOM. Un bon exemple de directive d’attribut est celle mentionnée précédemment, dans laquelle la couleur d’arrière-plan d’une ligne d’un tableau change pour la mettre en surbrillance lorsqu’un utilisateur survolait la ligne.

Utilisez des directives structurelles pour ajouter ou supprimer des éléments du DOM – pour modifier la structure de la page par exemple. Angular inclut quelques directives structurelles prêtes à l’emploi, comme ngIf et ngShow. Dans cet article et le suivant, nous allons créer des tests pour ces 2 types de directive :

  • FavIconDirective, qui a pour fonction d’afficher une petite étoile de favoris à côté d’un élément.
  • UnlessDirective, qui se comporte comme l’exact inverse de la directive Angular ngIf.

Tester une directive d’attribut

Pour tester une directive d’attribut, c’est au final assez simple : il faut récupérer une instance de la directive, effectuer une action, puis vérifier que les modifications attendues sont bien répercutées dans le DOM. Mais avant cela, examinons de plus près la directive FavIconDirective que nous allons tester.

import { Directive, ElementRef, Input, HostListener, OnInit, Renderer2 as Renderer } from '@angular/core';

const constants = {
  classes: {
    SOLID_STAR: 'fa-solid fa-star fa-lg',
    OUTLINE_STAR: 'fa-regular fa-star fa-lg',
    SOLID_STAR_STYLE_LIST: ['fa-solid', 'fa-star', 'fa-lg'],
    OUTLINE_STAR_STYLE_LIST: ['fa-regular', 'fa-star', 'fa-lg']
  }
}

@Directive({
  selector: '[appFavIcon]'
})
export class FavIconDirective implements OnInit {
  private readonly element: HTMLElement;
  private renderer: Renderer;
  private _primaryColor = 'gold';
  private _starClasses: any = constants.classes;

  @Input('appFavIcon') isFavorite: boolean | undefined;

  @Input() set color(primaryColorName: string) {
    if (primaryColorName) {
      this._primaryColor = primaryColorName.toLowerCase();
      this.setSolidColoredStar();
    }
  }

  constructor(element: ElementRef, renderer: Renderer) {
    this.element = element.nativeElement;
    this.renderer = renderer;
  }

  public ngOnInit(): void {
    if (this.isFavorite) {
      this.setSolidColoredStar();
    } else {
      this.setWhiteSolidStar();
    }
  }

  @HostListener('mouseenter')
  public onMouseEnter(): void {
    if (!this.isFavorite) {
      this.setBlackOulineStar();
    }
  }

  @HostListener('mouseleave')
  public onMouseLeave(): void {
    if (!this.isFavorite) {
      this.setWhiteSolidStar();
    }
  }

  @HostListener('click')
  public onClick(): void {
    this.isFavorite = !this.isFavorite;

    if (this.isFavorite) {
      this.setSolidColoredStar();
    } else {
      this.setBlackOulineStar();
    }
  }

  private setBlackOulineStar(): void {
    this.setStarColor('black');
    this.setStarClass('outline');
  }

  private setSolidColoredStar(): void {
    this.setStarColor(this._primaryColor);
    this.setStarClass('solid');
  }

  private setWhiteSolidStar(): void {
    this.setStarColor('white');
    this.setStarClass('solid');
  }

  private setStarClass(starType: string): void {
    const className: string = this.getStarClasses(starType);
    this.renderer.setAttribute(this.element, 'class', className);
  }

  private setStarColor(color: string): void {
    this.renderer.setStyle(this.element, 'color', color);
  }

  private getStarClasses(starType: any): string {
    let classNames = '';

    switch (starType) {
      case 'solid':
        classNames = this._starClasses.SOLID_STAR;
        break;
      case 'outline':
        classNames = this._starClasses.OUTLINE_STAR;
        break;
      default:
        classNames = this._starClasses.SOLID_STAR;
    }

    return classNames;
  }
}

Snippet 5.1 – fav-icon.directive.ts.

La directive s’utilise de la façon suivante, sur un élément (le plus souvent <i>) :

<i [appFavIcon]="var"></i>

var est un boolean qui indiquera s’il faut activer l’icône (true) ou la désactiver (false). Vous l’aurez certainement saisi à ce stade, cette directive simule donc une petite icône de favoris :

Fig 5.1 – Directive FavIcon.
  • ① → l’icône des favoris est active (affichée en couleur, ici jaune), l’élément est donc un favori.
  • ② → l’icône des favoris est inactive (affichée en blanc), l’élément n’est donc pas un favori.
  • ③ → état de l’icône au survol du pointeur : transparente avec des bordures noires.

Notons que la classe FavIconDirective possède quelques méthodes privées. On ne les testera pas (car elles sont privées 😎), car c’est le fonctionnement de la directive qui nous intéresse.

A noter qu’on a la possibilité de changer la couleur de l’icône grâce au paramètre color (quelle originalité 😇) :

<i [appFavIcon]="var" color="red"></i>

Ce qui en substance pourrait donner quelque chose comme la figure 5.2 :

Fig 5.2 – Paramètre « color » en action.

Tester la directive FavIconDirective

Répertorier les cas possibles

Pour arriver à nos fins, il me semble judicieux de lister toutes les configurations possible de la directive, ce qui permettra d’en extraire plus facilement les « tests cases ». Je propose de les séparés en 3 sous ensembles :

  1. le tableau 5.1 regroupes les configurations lors que appFavIcon est égale à true.
  2. le tableau 5.2 regroupe les configurations lors que appFavIcon est égale à false.
  3. le tableau 5.3 regroupe les configurations attendues lorsqu’on utilise le paramètre [color] de la directive. Ce cas sous-tends que appFavIcon soit égale à true, puisque la couleur ne soit voit qu’en présence d’un favori.
Configuration de test (« test case »)ÉvènementAffichage
L’élément doit inclure une étoile dorée après le chargement de la page.Après chargement de la pageEtoile dorée
L’élément doit toujours afficher une étoile dorée, même si le pointeur de l’utilisateur survole l’étoile.« mouseenter »Etoile dorée
L’élément doit toujours afficher le contour noir d’une étoile lorsque l’utilisateur clique sur l’étoile.« click »Etoile au contour noir
L’élément doit toujours afficher une étoile dorée si le pointeur de l’utilisateur sort de l’étoile.« mouseout »Etoile dorée
Tableau 5.1 – « test case » lorsque appFavIcon est true.

Passons au deuxième sous ensemble, lorsque l’icône n’est pas sensée représenter un favori :

Configuration de test (« test case »)ÉvènementAffichage
L’élément doit inclure une étoile blanche après le chargement de la page.Après chargement de la pageEtoile blanche
L’élément doit afficher une étoile au contour noir si le pointeur de l’utilisateur le survole.« mouseenter »Etoile au contour noir
L’élément doit afficher une étoile dorée juste après le clic du pointeur.« click »Etoile dorée
L’élément doit afficher une étoile blanche si le pointeur de l’utilisateur sort de l’étoile.« mouseout »Etoile blanche
Tableau 5.2 – « test case » lorsque appFavIcon est false.

Et enfin les deux cas qui traiteront de la couleur :

Configuration de test (« test case »)ÉvènementAffichage
L’élément doit inclure une étoile de la couleur spécifiée dans l’attribut [color], après le chargement de la page.Après chargement de la pageEtoile de la couleur spécifiée
Si la couleur n’est pas reconnue, la couleur utilisée sera le noir.Après chargement de la pageEtoile noire
Tableau 5.3 – « test case » lorsque l’attribut [color] est utilisé.

Mise en place de la suite de tests

Maintenant que nous avons une bonne vision d’ensemble des « tests cases », nous pouvons créer la suite de tests.
Pour cela, créons d’abord un fichier nommé favorite-icon.directive.spec.ts dans le même répertoire que la classe de la directive.
Ensuite, ça se passe comme pour les composants : il faut d’abord importer les dépendances provenant du framework de test Angular qui sont nécessaires à l’exécution des tests :

import { Component, DebugElement } from "@angular/core";
import { ComponentFixture, TestBed, TestModuleMetadata } from "@angular/core/testing";

Ensuite, on fait de même avec nos propres dépendances, au minimum la classe à tester c’est à dire dans notre cas la directive :

import { Component, DebugElement } from "@angular/core";
import { ComponentFixture, TestBed, TestModuleMetadata } from "@angular/core/testing";

import { FavIconDirective } from './fav-icon.directive';

Vous l’aurez peut être (ou pas 😌) remarqué, mais nous allons utiliser le décorateur Component, qui sert à créer un composant Angular. Mais pourquoi donc ? La réponse est simple : pour tester une directive, il nous faut un « support » pour « héberger » la directive pendant les tests. Comme il n’est pas question d’utiliser un composant de l’application, nous allons créer un composant de test, des plus minimalistes :

import { Component, DebugElement } from "@angular/core";  ①
import { ComponentFixture, TestBed, TestModuleMetadata } from "@angular/core/testing";

import { FavIconDirective } from './fav-icon.directive';  ②

@Component({  ③
  selector: 'app-test',
  template: `  ④
    <i [appFavIcon]="true"></i>
    <i [appFavIcon]="false"></i>
    <i [appFavIcon]="true" color="red"></i>
    <i [appFavIcon]="true" color="blabla"></i>
  `,
  styles: []
})
class TestComponent {
}

Snippet 5.2 – Préparation du composant support.

Le composant de test est à définir directement dans le fichier favorite-icon.directive.spec.ts, cela montre bien notre intention d’avoir recours à un composant support. Récapitulons légèrement :

  • ① → import des dépendances du framework de tests d’Angular.
  • ② → import de la directive à tester.
  • ③ → définition du composant « support ».
  • ④ → le template embarque les 4 cas que nous avons identifiés plus haut.

Bien ajoutons la suite du test, c’est à dire le paramétrage du module de test du framework de test d’Angular :

// -- import section. Voir snippet 5.2.

describe('FavIconDirective', () => {  ①
  let fixture: ComponentFixture<TestComponent>;
  let component: TestComponent;
  let starElement: any;

  beforeEach(async () => {
    const testModuleMetadata: TestModuleMetadata = {  ②
      declarations: [FavIconDirective, TestComponent]
    };
    await TestBed.configureTestingModule(testModuleMetadata).compileComponents();  ③

    fixture = TestBed.createComponent(TestComponent);  ④
    component = fixture.componentInstance;
    fixture.detectChanges();  ⑤
  });

});

Snippet 5.3 – Configuration du module de test.

Comme à l’accoutumée, essayons d’y voir plus clair :

  • ① → déclaration de la suite de tests
  • ② → déclaration des options du module de test dans une variable. On apprend au passage que le type requis est TestModuleMetadata.
  • ③ → création du module de test.
  • ④ → création de la fixture qui permettra de « jouer » avec le composant de test.
  • ⑤ → initier le mécanisme « Change Detection » d’Angular.

Le mécanisme « Change Detection » d’Angular est le processus de détection des changements dans le modèle de données de l’application et de répercution de ces changements dans l’interface utilisateur (le DOM donc).

Comme nous essayons toujours de faire les choses bien, nous allons créer trois sous-suites de tests, dans le même esprit que nos 3 tableaux précédents :

// -- import section. Voir snippet 5.2.

describe('FavIconDirective', () => {
  let fixture: ComponentFixture<TestComponent>;
  let component: TestComponent;
  let starElement: any;

  beforeEach(async () => {
    const testModuleMetadata: TestModuleMetadata = {
      declarations: [FavIconDirective, TestComponent]
    };
    await TestBed.configureTestingModule(testModuleMetadata).compileComponents();

    fixture = TestBed.createComponent(TestComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  describe('when favorite icon is set to true', () => {

  });

  describe('when favorite icon is set to false', () => {

  });

  describe('when color attribute is given', () => {

  });
});

Rappelez-vous : c’est toujours important de garder en tête l’objectif final, à savoir des tests lisibles et compréhensibles.
Commençons donc par le premier test de la première suite : d’après la première ligne du tableau 5.1, il vérifie que « l’élément doit inclure une étoile dorée après le chargement de la page ». Bien, voyons le code et ensuite analysons le tout :

describe('when favorite icon is set to true', () => {  ①

  beforeEach(() => {  ②
    const defaultTrueElementIndex = 0;
    starElement = getStarElement(fixture, defaultTrueElementIndex);
  });

  it('should display a solid gold star after the page loads', () => {  ③
    expect(starElement.style.color).toBe('gold');  ④
    expect(hasClasses(starElement.classList, ['fa-solid', 'fa-star', 'fa-lg'])).toBeTruthy();  ⑤
  });

  afterEach(() => {  ⑥
    starElement = null;
  });

});

Ça commence à ressembler à quelque chose ma parole ! 😇 Reprenons les points importants un par un :

  • ① → déclaration de la première sous-suite de tests
  • ② → déclaration d’une fonction rappel beforeEach, qui je le rappelle est exécutée avant chaque test présent dans la suite à laquelle elle appartient.
  • ③ → déclaration du test unitaire.
  • ④ → vérification de la couleur de l’étoile (« gold »).
  • ⑤ → vérification de l’icône affichée (étoile pleine, donc la classe font-awesome fa-solid).
  • ⑥ → déclaration d’une fonction rappel afterEach, qui je le rappelle est exécutée après chaque test présent dans la suite à laquelle elle appartient.

Mais que font nos deux callbacks (beforeEach et afterEach) ?

  • le premier stocke dans la variable (ou référence) starElement l’étoile que nous allons confronter au test.
  • le second supprime cette même référence à startElement en lui assignant la valeur null. Les tests s’en trouvent isolés et indépendants.
  • pour ça, nous avons recours à deux assistants : hasClasses et getStarElement. J’explique leur fonctionnement ici.

Il est grand temps de vérifier notre travail en exécutant la suite de tests.

Fig 5.3 – J’en suis tout ému 😇.

Bien. La bonne nouvelle c’est qu’on a bien avancé. La mauvaise c’est qu’il reste encore 9 tests à écrire. Rien que ça 🤪.
Le prochain test est un peu plus compliqué, car d’après le tableau 5.1, il va falloir simuler un survol de souris : « L’élément doit toujours afficher une étoile dorée, même si le pointeur de l’utilisateur survole l’étoile ».

Pour atteindre notre but, nous allons utiliser la méthode dispatchEvent que possède tout élément du DOM (et donc notre starElement), couplé à l’évènement mondialement connu « mouseenter » :

it('should display a solid gold star if the user rolls over the star', () => {
  const event = new Event('mouseenter');  ①
  starElement.dispatchEvent(event);  ②

  expect(starElement.style.color).toBe('gold');  ③
  expect(hasClasses(starElement.classList, ['fa-solid', 'fa-star', 'fa-lg'])).toBeTruthy();  ④
});

Snippet 5.4 – Test case n°2.

  • ① → création d’un évènement de type « mouseenter ».
  • ② → déclenchement de l’évènement sur l’étoile, ce qui simule l’entrée du pointeur de souris.
  • ③ → l’icône doit être dorée, on vérifie donc la propriété css « color » qui doit être égale à gold.
  • ④ → a classe fa-solid assure que l’étoile affichée est plein.

La suite du test est la même que le test précédent, car il s’agit ici de vérifier le même état de l’étoile. Le résultat devrait être proche de la figure suivante :

Fig 5.4 – Et hop, 2 « tests cases » validés !

Voyons le 3ème test. Le tableau 5.1 nous dit : « l’élément doit afficher une étoile dorée juste après le clic du pointeur ». Hum, cela ressemble fort au test précédent ! La seule différence se situe dans la nature de l’évènement, à savoir un click de souris à la place d’un survol :

it('should display a black outline star after the user clicks on the  star', () => {
  const event = new Event('click');  ①
  starElement.dispatchEvent(event);  ②

  expect(starElement.style.color).toBe('black');  ③
  expect(hasClasses(starElement.classList, ['fa-regular', 'fa-star', 'fa-lg'])).toBeTruthy();  ④
});
  • ① → création d’un évènement de type « click ».
  • ② → déclenchement de l’évènement sur l’étoile, ce qui simule le click de souris.
  • ③ → cette fois l’icône doit être noire.
  • ④ → la classe fa-solid fait place à la classe fa-regular ce qui nous assure que l’étoile affichée est bien celle avec bordure.
Fig 5.5 – 3ème « test case » validé.

Finissons avec le 4ème test. D’après le tableau 5.1, il faut que « l’élément affiche toujours une étoile dorée si le pointeur de l’utilisateur sort de l’étoile ». La question principale est donc la suivante : comment allons nous traduire le faire de faire un survol de l’étoile, en s’assurant que le dit pointeur ? On va simplement utiliser l’évènement mouseout :

it('should display a solid gold star if the user rolls out the star', () => {
  const event = new Event('mouseout');
  starElement.dispatchEvent(event);

  expect(starElement.style.color).toBe('gold');
  expect(hasClasses(starElement.classList, ['fa-solid', 'fa-star', 'fa-lg'])).toBeTruthy();
});

Ce 4ème « test case » est quasiment identique au deuxième; la seule différence réside dans l’utilisation de l’évènement mouseout à la place de mousenter.

On arrive au bout de cette première partie. Les 6 « tests cases » des tableaux 5.2 et 5.3 sont très similaires, vous pouvez vous entrainer en les faisant vous même puis en vérifiant le résultat dans le dépôt stackblitz :

Open in StackBlitz

Méthodes hasClasses et getStarElement

Plusieurs personnes m’ont écrit pour me signaler que j’avais fait une énorme boulette dans cet article, même si au final ça n’enlève rien à la qualité de celui-ci 😉. Je me dois bien d’avouer qu’ils ont partiellement raison, car si boulette il y a effectivement, l’adjectif « énorme » est, me semble t-il, légèrement exagéré.

Je vais donc réparer ici mon erreur : j’utilise dans les tests ci-dessus, deux fonctions « assistante » qui ne sont pas détaillées, cependant elles possèdent un nom suffisamment explicite pour aiguiller le profane dans sa recherche de la vérité :

  • hasClasses, est une méthode qui va vérifier si un élément possède certaines classes précises.
  • getStarElement retourne un élément qui sera, accrochez-vous bien, une étoile 😝.

Et voici le détail du code de chaque méthode :

function getStarElement(fixture: ComponentFixture<any>, defaultElementIndex: number): DebugElement {
  const el: DebugElement = fixture.nativeElement as DebugElement;
  return el.children[defaultElementIndex];
}

Snippet 5.5 – getStarElement

function hasClasses(resultClasses: DOMTokenList, expectedClasses: string[]): boolean {
  for (let i = 0; i < expectedClasses.length; i++) {
    if (!resultClasses.contains(expectedClasses[i])) {
      return false;
    }
  }

  return true;
}

Snippet 5.6 – hasClasses

Tester des composants Angular (2/2)

Tester des composants Angular (2/2)

En situation réelle, vous serez amenés à tester des composants plus complexes que ce qu’on a vu dans la première partie. Par exemple, supposons que l’on souhaite tester une barre latérale contenant un menu. On aimerait pouvoir la tester sans se soucier du dit menu. Dans de telles situations, on pourra utiliser ce que l’on appelle des « tests superficiels », ou comme disent les anglophones, des « shallow tests ». Ces tests superficiels permettent de tester les composants sur un seul niveau de profondeur, en ignorant tout élément enfant que l’élément pourrait contenir. On dit qu’on teste le composant en « isolation ».

Plan global

Markup

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

Open in StackBlitz

Ou suivez pas à pas les instructions 😇 !

Un composant un peu plus complexe

Faisons évoluer notre composant précédent (ContactFormComponent) en lui greffant un service qui fera une requête vers un serveur qui se chargera de délivrer le message. Quel serveur, comme il va procéder, tout ceci nous en avons cure pour nos tests, nous allons simplement simuler les requêtes sortantes et leurs réponses correspondantes.

ApiService

Créons donc notre service ApiService (pour faire simple 😋) en prenant comme modèle le code ci-après :

import { Injectable } from '@angular/core';
import { HttpClient } from "@angular/common/http";
import { Observable } from "rxjs";

@Injectable({
  providedIn: 'root'
})
export class ApiService {

  apiPath = 'http://apiserver.lol';

  constructor(private http: HttpClient) {
  }

  sendEmail(payload: {recipient: string, message: string}): Observable<any> {
    return this.http.post<any>(`${this.apiPath}/send-email`, payload);
  }
}

Snippet 4.1 – Service ApiService.

Ensuite on va pouvoir injecter notre service flambant neuf directement dans le constructeur du composant, merci l’injection de dépendance :

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

À partir de là, si vous avez la curiosité de lancer la suite de tests, vous devriez obtenir une magnifique erreur, semblable à celle de la figure 4.1.

Figure 4.1 – Là on est mal 🤭.

Avec ce cas d’utilisation typique, on va bien s’amuser car nous injectons dans notre composant ContactFormComponent un service (ApiService) qui lui même possède une dépendance (HttpClient). On a donc deux niveaux de dépendances (figure 4.2), configuration idéale pour commencer à faire n’importe quoi 😆. Heureusement, on va analyser tout ça dans le détail pour aborder les problèmes potentiels du bon côté.

Figure 4.2 – Deux niveaux de dépendances.

Les outils de tests d’Angular

Parce qu’on s’apprête à tester un composant un peu plus complexe, je me dois d’abord de faire le tour des principaux outils qu’Angular met à notre disposition pour nous aider à écrire des tests tout mignon. Ces outils sont des dépendances provenant du framework Angular ou fournies par ce dernier, il faudra donc les importer en début de test.

Le « mal-aimé »

Commençons donc par le « mal-aimé » (non ce n’est pas Louis XV) :

import { DebugElement } from '@angular/core';

On utilisera DebugElement pour inspecter un élément du DOM pendant le test. On peut le considérer comme le « HTMLElement » natif avec des méthodes et des propriétés supplémentaires qui peuvent être utiles pour le déboggage des éléments. Si vous avez lu le chapitre précédent, vous savez déjà qu’on ne l’utilisera pas souvent. 😋

Les outils du quotidien

Ensuite les outils du quotidien :

import { ComponentFixture ①, fakeAsync ②, TestBed ③, tick ④ } from '@angular/core/testing';

Bien sûr ne mettez pas les numéros, ils sont là pour l’explication suivante :

  • ① → une « fixture » de composant est un objet qui permet d’interagir avec une instance du dit composant, dans l’environnement de test. Il fournit un moyen de simuler le comportement du composant et de tester ses fonctionnalités, sans avoir besoin d’un navigateur ou d’un serveur.
  • ② → l’utilisation de fakeAsync garantit que toutes les tâches asynchrones sont terminées avant d’exécuter les assertions. Ne pas utiliser fakeAsync en présence d’un code asynchrone peut entraîner l’échec du test car les assertions peuvent être exécutées avant que toutes les tâches asynchrones ne soient terminées. fakeAsync s’utilise de concert avec tick pour simuler le passage du temps. Il accepte un paramètre, qui est le nombre de millisecondes pour avancer le temps. On y reviendra plus tard.
  • ③ → on utilisera cette classe pour installer et configurer les tests. Étant donné qu’on voudra utiliser TestBed pour chaque nouveau test unitaire d’un composant, d’une directive ou même d’un service, c’est l’un des utilitaires les plus importants fournis par Angular. A travers ce document, nous analyserons les méthodes configureTestingModule, overrideModule et createComponent, que nous utiliserons ensuite. Ayez en tête que l’API de TestBed est très étendue, nous ne ferons qu’effleurer sa surface de l’API dans ce document. N’hésitez pas à consulter la documentation officielle.
  • ④ → tick, voir point ②

Sélecteurs css

Ensuite, et c’est à contre coeur que je le fais, voyons la star des sélecteurs css :

import { By } from '@angular/platform-browser';

By est une classe incluse dans le module @angular/platform-browser qui sert à sélectionner des éléments du DOM. Pour sélectionner un élément avec un sélecteur css, on procède de la façon suivante :

By.css('.my-class')

La classe offre en tout 3 méthodes, regroupés dans la liste suivante :

MéthodeDescriptionParamètre
allRetourne tous les élémentsAucun
cssRetourne que les éléments ciblésAttribut css
directiveRetourne les éléments qui possède la directiveNom de la directive
Tableau 4.1 – La classe By.

Là encore on s’en servira avec parcimonie, uniquement avec un pistolet pointé sur la tempe. 🔫

Animations

C’est connu, dans les tests on se fiche des animations et les développeurs Angular l’ont anticipé :

import { NoopAnimationsModule } from '@angular/platform-browser/animations';

On utilisera donc la classe NoopAnimationsModule pour simuler des animations, ce qui permet aux tests de s’exécuter rapidement sans attendre la fin des animations.

Les routes

Dans les tests, il est fortement déconseillé d’utiliser les routes de l’application. Le module RouterTestingModule remplacera au pied levé le module RouterModule :

import { RouterTestingModule } from '@angular/router/testing';

Grâce à ce module, nous définirons nos propres routes pour les tests, meilleur moyen de ne pas tout casser si le nom d’une route venait à changer… 🤭.

Petit récapitulatif

Pour ceux qui lisent en diagonale, voici la liste récapitulative des outils abordés dans ce paragraphe :

import { DebugElement } from '@angular/core'; ①
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; ②
import { By } from '@angular/platform-browser'; ③
import { NoopAnimationsModule } from '@angular/platform-browser/animations'; ④
import { RouterTestingModule } from '@angular/router/testing'; ⑤
  • ① → utilisé pour le déboggage des éléments du composant.
  • ② → les outils du quotidien fourni par la team Angular.
  • ③ → sélection des éléments du DOM avec un sélecteur css.
  • ④ → supprimer les animations inutiles lors des tests.
  • ⑤ → utilisé pour tester la configuration du router.

Tout ceci est encore un peu flou, mais à ce stade c’est tout à fait normal.

Peut être l’avez vous remarqué, mais il n’existe pas de module FormsTestingModule, qui remplacerait le module FormsModule utilisé dans l’application. Mais en y réfléchissant un peu, à quoi bon ? 😉

Construction du test pas à pas

Structure générale du test

Nous allons procéder comme lors de l’article précédent, c’est à dire qu’on va utiliser un « squelette » et l’alimenter petit à petit :

import { ComponentFixture, TestBed } from '@angular/core/testing';  ①

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

describe('ContactFormComponent', () => {  ②
  let component: ContactFormComponent;  ③
  let contactForm: FormGroup;
  let fixture: ComponentFixture<ContactFormComponent>;

  beforeEach(async () => {  ④
    // todo
  });

  // -- tests -- //

  it('', () => {});  ⑤

  it('', () => {});

  afterEach(async () => {  ⑥
    // todo
  });

});

Snippet 4.2 – contact-form.component.spec.ts, le squelette des tests.

  • ① → import des dépendances, du framework et celles du composant à tester.
  • ② → déclaration de la suite de tests du composant à tester.
  • ③ → déclaration des variables dont la portée sera limitée au bloc describe de premier niveau (celui juste avant) :
    component : l’instance du composant.
    contactForm : l’instance du formulaire.
    fixture : on peut voir cet objet comme une « enveloppe » autour de l’instance du composant, qui permet d’accéder à ses méthodes, propriétés etc.
  • ④ → fonction callback appelée avant chaque test unitaire.
  • ⑤ → les tests unitaires eux mêmes.
  • ⑥ → fonction callback appelée après chaque test unitaire.

Construire l’environnement de test

Contrairement à l’article précédent, on ne va pas pouvoir faire l’économie du module de test fourni par Angular, car cette fois-ci, nous avons des dépendances injectées dans notre composant. On ne va pas non plus initialiser une application Angular complète, ça serait beaucoup trop lourd. On va donc s’appuyer sur un module de test qui va se charger de faire la « glue » entre tous les composants et services donc nous avons besoin. Ce module est la classe TestBed.

On peut voir la classe TestBed comme un support pour nos tests. Au lieu d’instancier le framework, on va utiliser une instance de la classe TestBed qui va se charger de coordonner toutes les dépendances du composant à tester : services, directives, pipes etc. Bien sûr il y aura quelques manipulations à effectuer manuellement (par exemple TestBed n’exécute pas la méthode « ngOnInit » tout seul, il faut le faire manuellement), mais nous reverrons ça plus tard.

TestBed va donc créer un module de test, et comme tout module Angular il faut le configurer. Bonne nouvelle pour nous, la configuration de ce module reprend la même logique que les modules « classiques » d’Angular, telle qu’on peut la voir dans le fichier app.module.ts (snippet 4.3).

@NgModule({
  declarations: [AppComponent],
  imports: [
    AppRoutingModule,
    BrowserModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Snippet 4.3 – Configuration d’un module sous Angular.

Ainsi il faudra aussi s’assurer qu’à chaque test une nouvelle instance de la classe TestBed (le module de test) soit disponible, la fonction beforeEach est donc l’endroit idoine pour arriver à nos fins 😉. Créons donc notre premier callbak beforeEach :

beforeEach(async () => {
  await TestBed.configureTestingModule({  ①
    imports: [],  ②
    declarations: [ContactFormComponent], ③
    providers: [],  ④
    schemas: []  ⑤
  }).compileComponents();

  fixture = TestBed.createComponent(ContactFormComponent);  ⑥
  component = fixture.componentInstance;  ⑦
  contactForm = component.contactForm;  ⑧
  fixture.detectChanges();  ⑨
});
  • ① → configure et crée le module de test qui va nous permettre de tester le composant. L’objet passé en paramètre est de type TestModuleMetadata.
  • ② → la propriété « imports » est utilisée pour importer les modules requis par le composant, mais ici nous n’en avons pas besoin pour le moment.
  • ③ → la propriété « declarations » est utilisée pour déclarer les composants, les directives et les pipes requis par le composant testé. Pour le moment on ne déclare que le minimum, à savoir le composant à tester lui même.
  • ④ → la propriété « providers » permet de configurer l’injection de dépendance, de la même manière que tout module Angular. Plus tard c’est ici qu’on déclarera des « fakes services » ou « mocks services ».
  • ⑤ → la propriété « schemas » (facultative) permet de configurer le schéma utilisé pour compiler et valider le modè le du composant lors des tests. Les plus courants sont CUSTOM_ELEMENTS_SCHEMA et NO_ERRORS_SCHEMA pour autoriser certaines propriétés seulement. Par exemple, le schéma NO_ERRORS_SCHEMA permettra à tout élément qui va être testé d’avoir n’importe quelle propriété (à éviter).
    Mais le but de ce document n’étant pas d’expliciter cette notion, on n’ira pas plus loin.
  • ⑥ → création de la « fixture » du composant (voir plus haut la définition).
  • ⑦ → création d’une référence à l’instance du composant avec une variable qui a une portée étendue à toute la suite de tests.
  • ⑧ → même chose pour le formulaire du composant, ça évitera les noms à rallonge dans les tests.
  • ⑨ → la méthode detectChanges déclenche un cycle de détection des modifications pour le composant; il est nécessaire de l’appeler après avoir initialisé un composant ou modifié une valeur de propriété. Après avoir appelé detectChanges, les mises à jour du composant seront rendues dans le DOM. En production, Angular possède un mécanisme qui détermine quand exécuter la détection des modifications, mais il est absent des tests unitaires. C’est pourquoi nous devons le faire fréquemment entre chaque test unitaire.

Tout ceci est bien gentil mais nous n’avons toujours pas résolu notre problème initial, à savoir la double dépendance du composant à tester (voir figure 4.2). Pour ceux qui ont suivi, le point ④ nous apprends que la propriété « imports » configure l’injection de dépendances. Parfait, c’est ce dont nous avons besoin. Injectons donc manuellement notre service ApiService et sa dépendance HttpClientTestingModule :

beforeEach(async () => {
  await TestBed.configureTestingModule({
    imports: [HttpClientTestingModule, FormsModule, ReactiveFormsModule],  ①
    declarations: [ContactFormComponent],
    providers: [ApiService],  ②
    schemas: []
  }).compileComponents();

  // ...
});

Snippet 4.4 – Configuration de l’injection de dépendance.

  • ① → import du module HttpClientTestingModule, spécialement taillé pour les tests. Il fait la même chose que son cousin HttpClientModule, mais en faisant des requêtes fictives (pour les tests c’est mieux 🤭).
  • ② → on « informe » l’injection de dépendance d’utiliser l’implémentation réelle du service ApiService.

On relance les tests, et la magie opère enfin :

Figure 4.3 – On retrouve nos tests 100% fonctionnels.

Peut être l’avez-vous remarqué au point ①, mais en plus du module HttpClientTestingModule, on injecte aussi FormsModule et ReactiveFormsModule. Pourquoi ça ? Rappelez-vous : le composant qu’on teste embarque un formulaire réactif, qui dépend de ces 2 modules. Nous devons donc les importer aussi ici.

Bien, l’environnement de test est prêt 🍾. Maintenant il faut se poser la question cruciale : que voulons-nous tester ? Pour le moment rien car le service ApiService ne fait rien du tout dans notre composant 😇. Remédions à cette situation intenable : modifions la méthode submitForm qui va se servir du service ApiService pour envoyer le message (histoire qu’on n’ai pas fait tout ça pour rien 😎) :

submitForm() {
  if (this.contactForm.valid) {
    this.success = true;
    this.error = false;
  } else {
    this.success = false;
    this.error = true;
  }
}
submitForm() {
    if (this.contactForm.valid) {  ①
      this.apiService.sendEmail({  ②
        recipient: 'toto@toto.com',
        message: this.message.value,
      }).subscribe({
          next: result => {  ③
            this.success = true;
            this.error = false;
          },
          error: e => {  ④
            this.success = false;
            this.error = true;
          }
        }
      )
    }
  }
  • ① → on vérifie que le formulaire est valide, sinon on ne fait rien.
  • ② → on fait appel au service ApiService pour envoyer le message, avec les bons arguments passés à la méthode sendEmail.
  • ③ → définition du callback à exécuter si la requête aboutit : affiche du message de confirmation.
  • ④ → définition du callback à exécuter si la requête renvoie une erreur : affiche du message d’erreur.

À gauche la méthode telle qu’elle était lors du précédent article, à droite les modifications avec le service ApiService. La différence est majeure :

  • à gauche c’est la validité du formulaire qui détermine ce qu’on affiche : si le formulaire est valide, on affiche le message de confirmation. Sinon le message d’erreur.
  • à droite, c’est le type de la réponse qui détermine le message à afficher : si on reçoit un « Observable » en erreur, on affiche le message d’erreur. Sinon ce sera le message de confirmation.

Donc à partir de maintenant, quand on clique sur « Envoyer », Angular va tenter de faire une requête vers http://apiserver.lol, qui va bien sûr échouer, mais là n’est pas notre soucis. Nous devons réparer le fichier de tests, qui ne se sent pas très bien depuis qu’on a fait le ménage dans la méthode submitForm :

Fig 4.4 – On a de nouveau tout cassé 😋.

Si on en croit Jasmine, les messages de confirmation n’apparaissent plus. Rien d’étonnant à cela, grâce à l’utilisation du module HttpClientTestingModule, toute requête Http est interceptée sans lever la moindre erreur. Du coup le point ③ et ④ ne sera jamais exécuté, quel que soit l’issu de la requête Http. Comment s’en sortir ? C’est simple, comme toujours se poser les bonnes questions, à savoir qu’est ce que je teste ?

  • je veux m’assurer que mon composant affiche un message de confirmation si le message est envoyé.
  • je veux aussi m’assurer que mon composant affiche un message d’erreur si le message n’est pas envoyé.

Si on analyse de prêt ces 2 phrases, on s’aperçoit qu’on ne parle :

  • ni de requête Http
  • ni de service ApiService

Eh oui, on ne test pas (encore) ApiService et encore moins les requêtes http d’Angular. Alors que faire ? L’idée ici est de découpler le composant de ses dépendances embarrassantes, de cette façon elles ne provoqueront plus d’erreurs. On dit qu’on va faire un test en isolation. Mais le composant a quand même besoin de ces dépendances, nous allons les remplacer par des « dépendances factices »

Dépendance factice

Une « dépendance factice » est une technique de test qui permet de créer une fausse implémentation d’un service dont dépend le composant à tester (ou d’autres services), plutôt que d’utiliser l’implémentation réelle du service.

La simulation d’un service est utile car elle permet d’isoler le comportement du composant testé du comportement des dépendances sur lesquelles il repose. Cela facilite le test du composant (il est isolé), sans se soucier du comportement des services dont il dépend. De plus, « mocker » un service permet de contrôler le comportement du service dans le test, ce qui peut être utile pour tester différents scénarios ou conditions d’erreur.

Alors la question que tout le monde se pose : comment fait-on ? Rappelez-vous du point ② du snippet 4.3 : Angular nous donne la possibilité de configurer l’injection de dépendance 😎. A partir de là, et faites moi confiance sur ce point, on a deux solutions :

  1. on ré-écrit un object ApiService complet.
  2. on utilise une fonctionnalité offerte par Jasmine : les espions.

Les deux sont possibles, mais nous ne verrons que la seconde solution qui consistera à :

  • créer et placer un espion sur l’implémentation réelle de la dépendance.
  • informer l’espion de quelle méthode il doit espionner.
  • et, c’est là le plus intéressant, lui dire que faire si la méthode est appelée lors du test.
  • injecter l’espion à la place du service réel.

Evidemment, comme toujours, on va créer un espion par test, nous serons donc avisés de placer sa définition dans un callback beforeEach. Allons y :

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { throwError } from "rxjs";  ①
import { FormGroup, FormsModule, ReactiveFormsModule } from "@angular/forms";

import { ContactFormComponent } from './contact-form.component';
import { HttpClientTestingModule } from "@angular/common/http/testing";
import { ApiService } from "../../services/api.service";

describe('ContactFormComponent', () => {
  let component: ContactFormComponent;
  let contactForm: FormGroup;
  let fixture: ComponentFixture<ContactFormComponent>;
  let apiSpy: any;  ②

  beforeEach(async () => {
    apiSpy = jasmine.createSpyObj<ApiService>('ApiService', ['sendEmail']);  ③
    apiSpy.sendEmail.and.returnValue(of(null).pipe(delay(1000)));  ④
    
    await TestBed.configureTestingModule({
      imports: [HttpClientTestingModule, FormsModule, ReactiveFormsModule],
      declarations: [ContactFormComponent],
      providers: [
        {provide: ApiService, useValue: apiSpy}  ⑤
      ],
      schemas: []
    }).compileComponents();

    fixture = TestBed.createComponent(ContactFormComponent);
    fixture.detectChanges();
    component = fixture.componentInstance;
    contactForm = component.contactForm;
  });

  // -- 
});

Snippet 4.5 – Création d’un espion.

Je l’avoue le callback beforeEach commence à prendre du poids, promis on remédiera à tout ça par la suite. En attendant, quelques éclairages :

  • ① → import de l’opérateur throwError qui va simuler une erreur http.
  • ② → déclaration de l’espion apiSpy qu’on « type » en any pour éviter les ennuis.
  • ③ → définition de l’espion avec la méthode jasmine.createSpyObj du framework Jasmine : on espionnera la méthode « sendEmail »
  • ④ → définition du comportement de l’espion quand la méthode « sendEmail » sera appelée : il va renvoyer un Observable après une seconde de délai.
  • ⑤ → on informe le module de test d’utiliser notre espion plutôt que le « vrai » service ApiService en paramétrant l’injection de dépendances.

On enregistre le tout et on se rue sur les tests : pas de chance ça ne fonctionne toujours pas 😱.

Ce comportement n’est pas étonnant : la méthode submitForm est désormais asynchrone puisqu’elle fait appelle au service ApiService qui doit faire requête Http. Et comme toutes les requêtes Http, on s’attend à recevoir soit un Observable soit une Promise, les deux étants asynchrones. Et pour se rapprocher encore un peu plus de la réalité et comprendre ce que l’on fait, on a inséré un délai d’une seconde avant l’émission de la réponse. C’est pour cette raison que le test n’aboutit pas : le code « n’a pas le temps d’aboutir ».

Remédions à cet épineux problème qui en a fait suer plus d’un 😇. Nous allons en quelques sortes forcer le temps à s’écouler plus vite, grâce à la fonction fakeAsync (fournie par Angular) qui permet de prendre le contrôle du flux temporel. Rien que ça me direz-vous !

Il suffit de ré-écrire le test et d’injecter directement le callback dans l’unique argument de fakeAsync :

import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';  ①
// --

describe('ContactFormComponent', () => {
  let component: ContactFormComponent;
  let contactForm: FormGroup;
  let fixture: ComponentFixture<ContactFormComponent>;
  let apiSpy: any;

  beforeEach(async () => {
    // --
  });

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

  describe('Form', () => {
    // --
  });

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

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

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

  // -- 

  });
});

Analysons un petit peu ce nouveau charabia :

  • ① → importation de 2 nouvelles dépendances : fakeAsync et tick.
  • ② → injection du callback du test directement dans fakeAsync. On substitue it à fit pour se focaliser uniquement sur ce test pour le moment.
  • ③ → on force le passage d’une seconde.

Bingo ! Le test passe à présent :

Figure 4.5 – Premier test asynchrone.

La fonction tick est une méthode fournie par le framework de test d’Angular qui permet de simuler le passage du temps.
Pour tester un composant avec du code asynchrone tel que des promesses ou des observables, la fonction tick peut être utilisée pour faire avancer l’horloge virtuelle de l’environnement de test afin de simuler le passage du temps jusqu’à la fin de l’opération asynchrone.

Quid des autres tests ? Malheureusement on a toujours un petit récalcitrant :

Figure 4.6 – Le message d’erreur ne s’affiche toujours pas…

Mais pourquoi le message d’erreur ne s’affiche t’il pas ? On a pourtant bien utilisé la fonction fakeAsync en conjonction avec tick, mais rien ne se passe comme prévu ?

C’est bien heureux je dois dire, car rappelez-vous le point du snippet 4.5. On a paramétré la méthode « sendEmail » du service ApiService pour qu’elle renvoie un Observable après un délai d’une seconde. En d’autres termes, la requête aboutit dans tous les cas. Jamais d’erreur. C’est fâcheux car pour notre test, nous avons justement besoin d’émettre une erreur !

Bien entendu, nous avions tout prévu en amont et grâce à Jasmine on va pouvoir illustrer la phrase suivante, écrite un peu plus haut :

De plus, « mocker » un service permet de contrôler le comportement du service dans le test.

Donc pour ce test, uniquement celui-ci, on va changer la valeur de retour de « sendEmail » et lui de renvoyer un « Observable » en erreur :

import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { delay, of, throwError } from "rxjs";  ①

describe('ContactFormComponent', () => {
  let component: ContactFormComponent;
  let contactForm: FormGroup;
  let fixture: ComponentFixture<ContactFormComponent>;
  let apiSpy: any;

  beforeEach(async () => {
    // --
  });

  // --

  describe('Form', () => {
    // --
  });

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

    fit('shoud display an error message if the email is not sent', fakeAsync(() => {
      apiSpy.sendEmail.and.returnValue(throwError(() => new Error('TEST')).pipe(delay(1000)));  ②
      contactForm.setValue({message: 'Hi there!'});
      component.submitForm();
      tick(1000);
      expect(component.error).toBeTrue();
      expect(component.success).toBeFalse();
    }));
  });
});
  • ① → importation de l’opérateur throwError qui sera utilisé pour générer un « Observable » en erreur.
  • ② → on redéfini la valeur de retour de l’espion, ici un « Observable » en erreur.

Définir la valeur de retour de l’espion directement dans le test est une bonne pratique. Elle permet au lecteur de tout de suite connaître l’origine de l’erreur.

Après un tel traitement, le test devrait de nouveau fonctionner :

Fig 4.7 – Le retour des Verts ☺️.

Et voilà ! Que de chemin parcouru depuis le premier article de ce document. Je dois dire que je me perds parfois dans des digressions mais ce n’est jamais pour rien. Pour conclure, voici le code complet du test :

import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { HttpClientTestingModule } from "@angular/common/http/testing";
import { FormGroup, FormsModule, ReactiveFormsModule } from "@angular/forms";
import { delay, of, throwError } from "rxjs";

import { ContactFormComponent } from './contact-form.component';
import { ApiService } from "../../services/api.service";

describe('ContactFormComponent', () => {
  let component: ContactFormComponent;
  let contactForm: FormGroup;
  let fixture: ComponentFixture<ContactFormComponent>;
  let apiSpy: any;

  beforeEach(async () => {
    apiSpy = jasmine.createSpyObj<ApiService>('ApiService', ['sendEmail']);
    await TestBed.configureTestingModule({
      imports: [HttpClientTestingModule, FormsModule, ReactiveFormsModule],
      declarations: [ContactFormComponent],
      providers: [
        {provide: ApiService, useValue: apiSpy}
      ],
      schemas: []
    }).compileComponents();

    fixture = TestBed.createComponent(ContactFormComponent);
    fixture.detectChanges();
    component = fixture.componentInstance;
    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', () => {

    beforeEach(() => {
      apiSpy.sendEmail.and.returnValue(of(null).pipe(delay(1000)));
    });

    it('shoud display an error message if the email is not sent', fakeAsync(() => {
      apiSpy.sendEmail.and.returnValue(throwError(() => new Error('TEST')).pipe(delay(1000)));
      contactForm.setValue({message: 'Hi there!'});
      component.submitForm();
      tick(1000);
      expect(component.error).toBeTrue();
      expect(component.success).toBeFalse();
    }));

    it('shoud display a success message if the email is sent', fakeAsync(() => {
      contactForm.setValue({message: 'Hi there!'});
      component.submitForm();
      tick(1000);
      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 4.6 – Le test complet du composant ContactForm.

Ce que nous avons appris

  • tester un composant Angular revient souvent à tester ses méthodes, en laissant de côté autant que possible la partie « template ».
  • en utilisant la fonction fakeAsync, il est possible de s’assurer que tous les appels asynchrones sont terminés avant que les assertions ne soient exécutées. Cela empêche le test d’échouer de manière inattendue.
  • TestBed est la classe principale du framework de test d’Angular. Elle fournie un environnement de test qui permet de manipuler toutes les autres classes d’Angular, donc les composants bien sûr.

Tester des composants Angular (1/2)

Tester des composants Angular (1/2)

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

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.