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 du cours

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