Tester des services Angular

Tester des services Angular

Dans ce nouvel article du cours « Angular Testing », nous allons apprendre à tester des services Angular, qu’ils soient synchrones ou asynchrones (dans ce dernier cas, ils retournent alors un objet « Promise » ou « Observable »).

Nous apprendrons en outre comment isoler des portions de codes de leurs dépendances associées en utilisant des « espions », petits outils forts sympathiques fournis par le framework Jasmine.

C’est tout ? Eh bien non ! Nous apprendrons aussi à configurer des tests doubles, créer des interfaces pour simplifier les interactions avec les dépendances, et beaucoup de choses encore !

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 😇 !

Introduction

Pour mener à bien ce périlleux voyage, nous allons nous appuyer sur un composant « ProductsList » qui aura pour unique fonction d’afficher une liste de produits. Nous lui adjoindrons un service « ProductService », dont la tâche sera de récupérer les produits à afficher.

Notons que le service « ProductService » va lui même utiliser le service HttpClient d’Angular. Comme son nom le laisse entendre, le service HttpClient a pour fonction principale de faire des requêtes HTTP vers une API REST. C’est donc lui qui fera les appels HTTP, pas notre service « ProductService ». Mais alors à quoi bon créer un service supplémentaire alors qu’Angular en fourni un prêt à l’emploi ? C’est tout l’objet de cet article 😆.

Et ensuite ? Nos efforts se concentrerons sur le service « ProductService », qui va jouer le rôle de « tampon » entre le composant Angular et l’API REST :

Figure 8.1 – Le service « ProductService ».

Définition d’un service

Généralement, les services Angular sont les parties de votre application qui n’interagissent pas directement avec l’interface utilisateur. Imaginez ceci : vous recherchez des images à l’aide d’un service d’imagerie comme Imgur. Vous tapez un terme de recherche, un spinner apparaît brièvement, puis les images correspondant à votre recherche apparaissent à l’écran. Que se passe-t-il pendant que le spinner tourne ? Les services de l’application effectuent un travail invisible en coulisses. Quel genre de travail ? Souvent, il s’agit d’enregistrer ou d’obtenir des données. Ou bien il peut s’agir de modifier ou de créer des données à utiliser par l’interface utilisateur. Les services peuvent également fonctionner comme des canaux de communication entre les composants de l’application.

Les services permettent d’écrire du code « non-UI » de manière modulaire, réutilisable et testable. Comme vous le verrez, le code situé dans les services est plus facile à comprendre et à gérer que le même type de fonctionnalité qui serait imbriquée dans un composant Angular. Les services ne modifient généralement pas le DOM ou n’interagissent pas directement avec l’interface utilisateur (ou l’UI). En contre-partie, il n’y a pas de limite aux fonctionnalités qu’un service Angular peut fournir. Les applications « bien conçues » embarquent la logique métier de l’application et des E/S à l’intérieur d’un service.

Tout code créant des éléments d’interface utilisateur ou gérant des entrées utilisateur doit figurer dans un composant.

Les services sous Angular

Au niveau le plus élémentaire, les services Angular sont des classes JavaScript. Ce sont aussi des singletons : vous les créez une seule fois et vous pouvez les utiliser n’importe où dans l’application.

Les services Angular implémentent souvent le décorateur de classe @Injectable. Ce décorateur ajoute des métadonnées qu’Angular utilise pour résoudre les dépendances.

Un service n’est instancié qu’une seule fois (singleton). Les composants qui définissent ce service comme une dépendance partageront cette instance. Cette technique réduit l’utilisation de la mémoire et permet aux services d’agir en tant que « courtiers » pour le partage de données entre les composants.

Notons qu’Angular propose de nombreux services intégrés, notamment HttpClient et FormBuilder. De nombreuses bibliothèques tierces conçues pour fonctionner avec Angular sont également des services.

Avant de commencer à tester des services, il est important d’avoir une bonne compréhension de ce que fait l’injection de dépendances sous Angular.

L’injection de dépendance

La clé pour comprendre les tests de services Angular est de bien connaître le mécanisme d’injection de dépendances d’Angular.

Commençons d’abord par s’interroger : ne serait-il pas suffisant de simplement importer une dépendance pour ensuite l’injecter d’autres bibliothèques/services  ? La réponse est non car lors de la création d’une nouvelle instance d’une classe lamba, il est possible qu’on on ne connaisse pas encore précisément ses dépendances.

Par exemple, supposons que nous voulions créer une dépendance (base de données, cookie, navigateur etc) pour un service de stockage, PreferencesService. Si le service importe un mécanisme de stockage spécifique (ex: base de données), on sera toujours contraint d’utiliser cette implémentation et aucune autre, même si on n’en a pas besoin (à cause du contexte par exemple).

L’injection de dépendances est un système qui fournit des instances d’une dépendance au moment où la classe est instanciée. On n’a plus besoin de s’occuper des dépendances; le système d’injection de dépendances le fera pour nous. Lorsque le constructeur du service s’exécute, il reçoit une instance d’une dépendance déjà créée par le système d’injection de dépendances et le service utilise le code injecté au lieu de la classe importée.

import { Injectable } from '@angular/core';
import { BrowserStorage } from './browser-storage.service';  ①

@Injectable()
export class PreferencesService {
  constructor(private browserStorage: BrowserStorage) {  ②
  }

  public saveProperty(preference) {
    this.browserStorage.setItem(preference.key, preference.value);  ③
  }

  public getProperty(key: string): any {
    return this.browserStorage.getItem(key);  ③
  }
}

Snippet 8.1 – Exemple d’injection de dépendance.

  • ① → import de la classe afin d’utiliser son nom comme un « jeton », ou « identifiant » de la dépendance. BrowserStorage devient le « jeton » de la dépendance, ou « fournisseur ».
  • ② → l’injection de dépendance utilise le constructeur du service pour rechercher et fournir des dépendances. Le mot clé private est obligatoire
  • ③ → le service utilise maintenant une instance de la dépendance.

Un « fournisseur » est une instruction associée au système d’injection de dépendance sur la façon d’obtenir une valeur pour une dépendance. La plupart du temps, ces dépendances sont des services, mais il peut arriver qu’on injecte des paramètres.

Dans le snippet 8.1, le constructeur de PreferencesService définit un paramètre de type BrowserStorage. Angular utilise donc ces informations pour fournir une instance de BrowserStorage lors de la première création de PreferencesService via l’injection de dépendances.

Pour que l’injection de dépendance fonctionne, le point d’orgue est le suivant : quelque que soit l’instance injectée dans le service, elle doit respecter l’interface définie dans le constructeur du service.

Dans notre snippet 8.1, la classe PreferencesService attend donc une dépendance (browserStorage) qui possède les méthodes setItem et getItem. Ce que font ces méthodes, ce n’est pas notre affaire. Mieux encore, où browserStorage stocke les données ? On ne veut pas le savoir 😆.

L’injection de dépendances d’Angular utilise le type de la classe comme « jeton », qui devient la clé de sa carte interne dans le fournisseur de jetons (token provider). A la place, il est possible d’utiliser une chaîne comme jeton en utilisant la fonction InjectionToken de @angular/core.

Exemple dans un test :

TestBed.configureTestingModule({
  imports: [RouterTestingModule],
  providers: [
    {provide: BrowserStorage, useValue: browserStorageServiceSpy}    ①
  ]
});

Snippet 8.2 – configuration du « token provider ».

  • ① on utilise le jeton défini au point ① du snippet 8.1 pour injecter un espion à lap lace du service et ainsi contrôler le comportement de la dépendance BrowserStorage.

Le décorateur @Injectable

Un décorateur est une fonctionnalité TypeScript qui ajoute des propriétés ou un comportement à une classe ou une méthode. Angular inclut un décorateur de services, @Injectable, qui est un moyen pratique de « marquer » un service comme pouvant être utilisé par le système d’injection de dépendances d’Angular.

Le décorateur indique à Angular que le service lui-même a ses propres dépendances qui doivent être résolues. Un service, comme un composant, est capable de définir ses propre dépendances.

Le décorateur @Injectable est-il requis pour tous les services Angular ? Non. Si votre service n’a pas de dépendances, vous pouvez vous débrouiller sans le décorateur @Injectable.

Il est possible de tester un service sans avoir besoin du module TestBed d’Angular.

Installation du « bac à sable »

Service ProductService

Pour les besoins de cet article, et aussi pour innover un peu, nous allons faire appel au service externe DummyJSON, qui propose une sorte d’api « bac à sable » très pratique pour nous autres développeurs. L’api propose une grande quantité de points de terminaisons, qui ont tous un rapport avec le monde de l’e-commerce. Par exemple, la terminaison /products renvoie une collection de produits. Alors oui c’est tous les mêmes, mais qu’importe 😊.

L’objectif de cet article n’étant pas d’étudier la construction d’un service Http sous Angular mais plutôt d’expliquer comment le tester, je me contenterais de le placer ici, brut de fonderie. Vous pouvez aussi le retrouver le code complet sur le dépôt Stackblitz.

import { Injectable } from '@angular/core';
import { HttpClient, HttpEvent, HttpResponse } from "@angular/common/http";
import { catchError, map, Observable, throwError } from "rxjs";

export interface BasicResponse {
  products: Array<Product>,
  total: string,
  skip: string,
  limit: any
}

export interface Product {
  id: number;
  title: string;
  description: string;
  price: number;
  discountPercentage: number;
  rating: number;
  stock: number;
  brand: string;
  category: string;
  thumbnail: string;
  images: Array<any>;
}

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

  apiUrl = 'https://dummyjson.com';

  constructor(private http: HttpClient) {
  }

  get<T>(url: string, options: any = {}): Observable<T> {
    return this.http.get<T>(`${this.apiUrl}/${url}`, {...options, observe: 'response'})
      .pipe(
        map<HttpEvent<T>, T>(this.extractData),
        catchError(err => {
          return throwError(err);
        })
      )
  }

  products(): Observable<Array<Product>> {
    return this.get<BasicResponse>('products').pipe(
      map(r => r.products)
    );
  }

  private extractData<T>(r: HttpEvent<T>): T {
    if (r instanceof HttpResponse && r.body) {
      return r.body;
    }

    return r as T;
  }
}

Snippet 8.3 – product.service.ts

  • ① → import des dépendances.
  • ② → définition d’une interface BasicResponse en fonction du schéma DummyJSON.
  • ③ → définition d’une interface Product en fonction du schéma DummyJSON.
  • ④ → l’url de l’api DummyJSON.
  • ⑤ → injection du service HttpClient d’Angular.
  • ⑥ → définition de la méthode products chargées de récupérer les produits.

Bien sûr, ce service n’est pas l’endroit idéal pour définir des interfaces, mais cela sera suffisant pour nos besoins.

Composant ProductList

Comme son nom l’indique, le composant ProductList affiche simplement la liste des produits, simple mais efficace :

import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { Product, ProductService } from "../../services/product.service";
import { Observable } from "rxjs";

@Component({
  selector: 'app-product-list',
  template: `
    <div class="wrapper">
      <ul class="product-list">
        <li *ngFor="let product of products$ | async">
          <div class="line">
            <img src="{{product.thumbnail}}" alt="{{product.title}}">
            <p>{{product.title}}<br><i>{{product.description}}</i></p>
          </div>
        </li>
      </ul>
    </div>
  `,
  styles: [
    'div.wrapper { padding:10px; background-color:#bebebe; }',
    'div.line { display: flex;align-items: center; }',
    'img { max-width:60px;margin-right:5px; }',
    'ul { list-style: none; }',
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductListComponent implements OnInit {
  products$?: Observable<Array<Product>>;

  constructor(private productService: ProductService) {
  }

  ngOnInit(): void {
    this.products$ = this.productService.products();
  }
}

Snippet 8.4 – product-list.component.ts

  • ① → on utilise le pipe « async » fourni par Angular car il s’agit de la méthode recommandée (entre autres choses, il se désabonne automatiquement si nécessaire, inutile d’utiliser OnDestroy).
Figure 8.2 – Liste des produits DummyJSON.

Si vous obtenez quelque chose de similaire à la figure 8.2, vous êtes prêt pour passer à la suite : les tests ☺️.

Générer un service avec Angular CLI

Pour bien commencer, nous allons tout reprendre depuis le début et générer un service ProductService avec Angular CLI. Ensuite, examinons le test que génère Angular CLI, en même temps que le service lui même :

ng g s services/product

Cette commande crée deux fichiers : product.service.ts et product.service.spec.ts. Une fois ces fichiers créés, Angular CLI produit ce message :

Warning: Service is generated but not provided, it must be provided to be used

Pas de panique, c’est Angular CLI qui nous rappelle qu’il faut ajouter le service dans les métadonnées du fournisseur de jeton d’un composant ou d’un module pour pouvoir l’utiliser.

L’endroit où vous incluez un service dépend de s’il est local à un composant ou utilisé dans tout le module.

Intéressons-nous maintenant au test généré par Angular CLI :

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

import { ProductService } from './product.service';

describe('ProductService', () => {
  let service: ProductService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: []    ①
    });
    service = TestBed.inject(ProductService);
  });

  it('should be created', () => {
    expect(service).toBeTruthy();    ②
  });
});

Snippet 8.5 – product.service.spec.ts

  • ① → le module de test est configuré avec le service ProductService avant chaque test.
  • ② → vérifie l’existence du service.

Sur un service nouvellement créé, le test ci-dessus fonctionne. Ca serait un comble qu’il en soit autrement !

Construction du test de ProductService

Les tests nécessitant l’utilisation de services Http requièrent une configuration spéciale afin de contourner l’accès aux services Web. Déclencher une requête réseau depuis vos tests unitaires briserait leur isolement, or c’est précisément l’âme du test unitaire : être isolé de l’application.

Injecter la dépendance HttpClient

Maintenant, complétons la définition du service, pour arriver petit à petit au service du snippet 8.3. Injectons dans le constructeur le service httpClient d’Angular :

import { Injectable } from '@angular/core';
import { HttpClient, HttpEvent, HttpResponse } from "@angular/common/http";    ①
import { catchError, map, Observable, throwError } from "rxjs";

// -- définitions interfaces -- //

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

  apiUrl = 'https://dummyjson.com';    ②

  constructor(private http: HttpClient) {    ③
  }
}

Snippet 8.6 – Injection du service HttpClient

  • ① → import de la dépendance HttpClient (HttpClient devient donc le « jeton » de cette dépendance).
  • ② → définition de l’url de l’api.
  • ③ → injection de la dépendance dans le construction en typant le paramètre avec le « jeton » de la dépendance.

Et boom ! Le seul test de la suite ne passe plus, avec en prime un message d’erreur des plus explicites pour qui s’initie aux tests 😔 :

Figure 8.3 – La dépendance n’est pas résolue.

Le message d’erreur est le suivant : « NullInjectorError: No provider for HttpClient! ». Ce qui en substance signifie que la dépendance HttpClient n’est pas résolue (« No provider for HttpClient » peut se traduire par « pas de fournisseur pour HttpClient », plus d’infos les fournisseurs).

La solution ? Eh bien fournir nous même la dépendance :

import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule } from "@angular/common/http/testing";    ①

import { ProductService } from './product.service';

describe('ProductService', () => {
  let service: ProductService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],    ②
      providers: []
    });
    service = TestBed.inject(ProductService);
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });
});

Snippet 8.7 – Import du module HttpClientTestingModule

  • ① → import du module HttpClientTestingModule
  • ② → import du module HttpClientTestingModule dans le module de test, qui met à disposition le service HttpClient

HttpClientTestingModule est un module Angular taillé pour les tests. Il fourni bien un service HttpClient mais contrairement à celui qui est utilisé en temps normal, celui-ci intercèpte les requêtes http et nous permet de les manipuler sans générer d’erreurs. Un pur bonheur ☺️.

Et voilà, de nouveau l’unique test passe :

Figure 8.4 – La dépendance est maintenant résolue.

Ajout de la méthode « products »

Passons à plus consistant. Ajoutons une méthode products qui devrait récupérer la liste des produits via une requête Http avec comme paramètre uri égal à products. Ecrivons de suite le test dans ce sens (par simplicité j’ai retiré le code déjà présent) :

...
import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";    ①

describe('ProductService', () => {
  ...
  let httpTestingController: any;    ②
  let apiPath = 'http://api.path';    ③

  beforeEach(() => {
    ...
    httpTestingController = TestBed.inject(HttpTestingController);    ④
  });

  beforeEach(() => {
    service.apiUrl = apiPath;    ⑤
  })

  ...

  it('should make an http request using GET method', () => {
    service.products().subscribe();     ⑥
    const req = httpTestingController.expectOne(`${apiPath}/products`);    ⑦
    expect(req.request.method).toEqual('GET');    ⑧
  });
});

Snippet 8.8 – Ecriture du test de la méthode « products ».

  • ① → import de la classe HttpTestingController.
  • ② → déclaration d’une variable httpTestingController.
  • ③ → déclaration d’une variable apiPath.
  • ④ → attribution d’une référence au service HttpTestingController pour interagir avec le HttpClientTestingModule.
  • ⑤ → surcharge de la propriété apiPath du service ProductService.
  • ⑥ → souscription à l’ « Observable » retourné par la méthode products.
  • ⑦ → vérification qu’une requête à été exécutée, et vérification de l’url utilisée. Il s’agit d’un test double.
  • ⑧ → et enfin vérification du type de requête (GET).

Le nouveau concept de ce test est le service HttpTestingController. Il permet d’interagir avec le module de test HttpClientTestingModule pour vérifier que des appels sont tentés et pour fournir des réponses prédéfinies.

Pourquoi souscrit-on au point ⑥ ? Si on ne le fait pas, la requête Http n’est jamais déclenchée.

Maintenant le plus important : quid du test ? Logiquement il ne passe pas, car la méthode products n’existe pas encore :

Figure 8.5 – La méthode products reste à écrire.

Nous irons au plus simple : écrire une méthode qui envoie une requête Http à l’adresse http://<products-api>/products, en utilisant un observable de type any (on s’occupera du typage un peu plus tard) :

// -- -- //

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

  apiUrl = 'https://dummyjson.com';

  constructor(private http: HttpClient) {
  }

  products(): Observable<any> {
    return this.http.get<any>(`${this.apiUrl}/products`);
  }
}

Snippet 8.9 – Ecriture de la méthode « products ».

Bien sûr le meilleur dans tout ça, c’est de s’assurer que le deuxième teste passe :

Figure 8.6 – Elle est pas belle la vie ? 😊

Support des erreurs

Nous pouvons désormais améliorer un tout petit peu notre code : et si on gérait le cas d’une erreur Http en affichant quelque chose dans la console, en utilisant la stratégie « Catch and Rethrow » ? D’autant plus que RxJs fournit l’opérateur catchError qui fait très bien le job 😊. Mais, comme à l’accoutumée, écrivons d’abord le test :

...

describe('ProductService', () => {
  ...

  beforeEach(() => {
    ...
  });

  beforeEach(() => {
    ...
  });

  afterEach(() => {
    httpTestingController.verify();    ①
  });

  ...

  it('should handle error', () => {
    const spy = spyOn(window.console, 'log');    ②
    const mockErrorResponse = {status: 400, statusText: 'Bad Request'};    ③
    service.products().subscribe({
      next: () => fail('should have failed with 400 error'),    ④
      error: error => {
        expect(error.status).toEqual(400);    ⑤
        expect(error.statusText).toEqual('Bad Request');
        expect(spy).toHaveBeenCalled();     ⑥
      }
    });

    const req = httpTestingController.expectOne(`${service.apiUrl}/products`);
    expect(req.request.method).toEqual('GET');
    req.flush(null, mockErrorResponse);    ⑦
  });
});

Snippet 8.9 – Vérification de la bonne gestion des erreurs.

  • ① → vérifie qu’il n’y a pas de requêtes en suspens qui n’ont pas été traitées. Le cas échéant, verify() renverra une erreur et le test échouera.
  • ② → définition d’un espion sur la méthode log de window.console.
  • ③ → définition d’un objet mockErrorResponse avec deux propriétés : status et statusText. Ces propriétés sont utilisées pour définir le code d’état et le texte d’état de la réponse d’erreur qui sera renvoyée par le serveur.
  • ④ → vérifie que le callback next ne sera jamais appelé (fail renvoie une erreur et le test échoue immédiatement)
  • ⑤ → vérifie que le code de retour (ici 400).
  • ⑥ → vérifie que la méthode log de window.console est appelée.
  • ⑦ → simule/émet une réponse à une requête Http. Le premier paramètre étant le corps de la réponse, le second les entêtes. Et comme on utilise mockErrorResponse, la réponse sera une erreur.

Comme on pouvait s’en douter, on a tout cassé 😆 :

Figure 8.7 – Le nouveau test ne pas.

Il ne reste plus qu’à compléter le code de la méthode products du service ProductService :

...
import { catchError, Observable, throwError } from "rxjs";    ①

...

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

  apiUrl = 'https://dummyjson.com';

  constructor(private http: HttpClient) {
  }

  products(): Observable<any> {
    return this.http.get<any>(`${this.apiUrl}/products`)
      .pipe(    ②
        catchError(err => {   ③
          console.log('Error:', err);    ④
          return throwError(err);    ⑤
        })
      );
  }
}

Snippet 8.10 – Affichage des erreurs dans la console.

  • ① → import de l’opérateur catchError.
  • ② → on utilise l’opérateur pipe qui permet de chainer jusqu’à 9 opérateurs. Ici on en utilisera qu’un pour l’instant.
  • ③ → l’opérateur catchError « intercepte » une éventuelle erreur provenant du serveur, l’affiche dans la console (④) puis la « renvoie » dans le flux (⑤).

Et boom, le retour des Verts :

Figure 8.7 – Gestion des erreurs OK.

Typer la réponse provenant du serveur

Améliorons encore un peu plus notre code puisque pour l’instant tout se passe pour le mieux. On pourrait, par exemple, structurer la réponse provenant du serveur pour n’en retirer que le nécessaire, c’est à dire la liste des produits, sous forme de tableau.

On sait déjà la forme que prend la réponse provenant de DummyJSON :

Figure 8.8 – Structure de la réponse DummyJSON.

Comme on travaille avec Typescript, on peut facilement créer deux interfaces : une représentant le corps de la réponse (appelons-la par exemple BasicResponse) et une autre représentant un produit (qui se nommerait Product) :

export interface BasicResponse {
  products: Array<Product>,    ①
  total: string,
  skip: string,
  limit: any
}

export interface Product {    ②
  id: number;
  title: string;
  description: string;
  price: number;
  discountPercentage: number;
  rating: number;
  stock: number;
  brand: string;
  category: string;
  thumbnail: string;
  images: Array<any>;
}

Snippet 8.11 – Définition des deux interfaces.

  • ① → les produits arrivent sous forme d’un tableau, c’est pourquoi nous typons la clé products en tableau de Product (produits).
  • ② → définition d’un produit conformément au produit DummyJSON.

Où placer ces deux interfaces ? Dans le cadre d’un projet complet il faudrait les placer dans un répertoire dédié, et les importer à la demande. Mais dans le cadre de cet article, je les place directement dans le fichier de définition du service :

...

export interface BasicResponse {
  products: Array<Product>,
  total: string,
  skip: string,
  limit: any
}

export interface Product {
  id: number;
  title: string;
  description: string;
  price: number;
  discountPercentage: number;
  rating: number;
  stock: number;
  brand: string;
  category: string;
  thumbnail: string;
  images: Array<any>;
}

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

Snippet 8.12 – On déplace les interfaces directement dans product.service.ts.

Traduisons cela dans un test : nous souhaitons récupérer un tableau de produits uniquement, alors que le serveur renvoie une réponse de type BasicResponse :

it('should return an array of products', (done) => {    ①
  service.products().subscribe(result => {    ②
    const expected = {id: 3} as Product;    ⑤
    expect(result).toEqual([expected]);    ⑥
    done();    ⑦
  });
  const req = httpTestingController.expectOne(`${service.apiUrl}/products`);    ③
  req.flush({    ④
    products: [{id: 3}],
    total: 0,
    skip: 0,
    limit: 30
  });
});

Snippet 8.13 – Un test qui vérifie le type de réponse du serveur.

  • ① → on passe le paramètre done, petit utilitaire jasmine qui permet de contrôler la fin du test unitaire.
  • ② → souscription à la réponse de la requête. C’est nécessaire pour accéder à la réponse du serveur.
  • ③ → test double avec l’utilitaire httpTestingController.
  • ④ → simule/émet une réponse de serveur. Nous passons en premier paramètre le corps de la réponse attendue, ici un objet de type BasicResponse. A partir de là, l’« observer » passé à subscribe (②) est exécuté.
  • ⑤ → comme on est fainéant, on crée un produit avec une propriété unique id en utilisant le mot clé magique de Typescript as.
  • ⑥ → on vérifie qu’on a bien un tableau contenant l’unique produit qu’on a défini.
  • ⑦ → done() met fin au test unitaire.

Comme d’habitude, si on écrit un test sans toucher au code, il échoue automatiquement :

Figure 8.9 – Le test échoue.

Pour valider ce test, le typage n’est pas obligatoire, mais il permet de se rendre compte de la manipulation :

products(): Observable<Array<Product>> {    ①
  return this.http.get<BasicResponse>(`${this.apiUrl}/products`)    ②
    .pipe(
      map(r => r.products),    ③
      catchError(err => {
        console.log('Error:', err);
        return throwError(err);
      })
    );
}

Snippet 8.14 – Extraction de la liste des produits provenant du serveur.

  • ① → on souhaite récupérer un tableau de produits (Array<Product>).
  • ② → le serveur fournit une réponse de type BasicResponse.
  • ③ → l’opérateur map extrait la clé products de la réponse et la renvoie.

Après ce petit refactoring, les tests passent :

Figure 8.10 – Beau travail !

Isoler le code dupliqué

Ca ne se voit pas encore, mais si on s’arrête maintenant on va se retrouver avec du code dupliqué : plus exactement la partie qui intercepte les erreurs. A chaque nouvelle méthode dans le service ProductService, on sera obligé de dupliquer la gestion des erreurs. L’idée est donc de déplacer cette partie du code dans une autre méthode, qu’on pourrait par exemple appeler « get ».

get<T>(url: string): Observable<T> {    ①
  return this.http.get<T>(`${this.apiUrl}/${url}`, {observe: 'body'})    ②
    .pipe(
      catchError(err => {   ③
        console.log('Error:', err);
        return throwError(err);
      })
    );
}

Snippet 8.15 – La méthode « get » se veut le plus généraliste possible.

  • ① → la méthode est typé avec le type générique de typescript (T) au niveau de l’« Observable » de retour.
  • ② → il est nécessaire de typer l’appel à la méthode « get » du service Http d’Angular. Il faut aussi passer l’option {observe: 'body'} récupérer directement le corps de la réponse plutôt que la réponse complète.
  • ③ → on reproduit ici la partie qui intercepte les erreurs.

Il faut aussi mettre à jour la méthode « products » qui ne se soucie plus du service Http d’Angular :

products(): Observable<Array<Product>> {    ③
  return this.get<BasicResponse>('products').pipe(    ①
    map(r => r.products)   ②
  );
}

Snippet 8.16 – La méthode « products » présente que le nécessaire.

  • ① → on passe à la nouvelle méthode « get » le type BasicResponse (voir ici).
  • ② → comme on souhaite que le tableau des produits, on l’extrait en utilisant l’opération map.
  • ③ → on informe le compiler que cette fonction retourne un « Observable d’un tableau de produits ».

Et maintenant il nous faut valider tout ça, simplement en lançant la suite de tests qui n’a pas bougé d’un iota :

Figure 8.11 – Pas de régression en vue.

On pourrait encore aller plus loin en sortant la méthode « get » du service ProductService, pour la placer dans un service plus générique qu’on pourrait appeler ApiService. De cette façon, la partie métier s’en trouverait entièrement isolée, et la partie « requêtes Http » isolé dans le service ApiService et réutilisable.

Conclusion

Tester des services Angular peut vite devenir un cauchemar, surtout avec des services qui ont un arbre de dépendances trop profond. Heureusement Angular utilise un système d’injection de dépendance qui permet de « neutraliser » ces dépendances, mais pas que. Il est aussi possible de contrôler leur comportement pendant les tests, de leur greffer des « espions » qui, comme leur nom le laisse entendre, épient littéralement certaines méthodes.

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

Markup

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

Open in StackBlitz

Ou suivez pas à pas les instructions 😇 !

Tester un 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. Il serait cependant assez simple d’améliorer cette classe qui se veut très simple pour les besoin de l’article. Jugez plutôt :

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'phoneNumber'
})
export class PhoneNumberPipe implements PipeTransform {

  transform(value: string, countryCode: string = '33'): string {
    if (!value) {
      return '';
    }

    const phoneNumber = value.toString().trim().replace(/[^0-9]/g, '');
    const formattedNumber = phoneNumber.replace(/^(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, `+${countryCode} ($1) $2 $3 $4 $5`);

    return formattedNumber;
  }
}

Snippet 7.1 – phone-number.ts

Tester PhoneNumber

Commençons par définir les différents scénarios que nous allons rencontrez avec cette classe, ou en anglais les « tests cases » :

ChaineAffichage
Une chaine de 10 caractères numériques affiche un numéro sous la forme ‘+33 (xx) xx xx xx xx’.« 0474769560 »+33 (04) 74 76 95 60
Les caractères non numériques sont ignorés.« 0474769p60 »47476960
Les caractères non numériques sont ignorés.« 0474769p560 »+33 (04) 74 76 95 60
Affiche la même chaine si le nombre de chiffres est inférieur à 10.« 474769560 »474769560
Tableau 7.1 – 3 tests cases à vérifier.

Comme pour les composants, les tests ont toujours la même structure :

// -- imports des dépendances -- //

// -- définition de la suite de tests -- //

Ce qui dans notre cas peut se matérialiser de la manière suivante :

import { PhoneNumberPipe } from './phone-number.pipe';  ①

describe('PhoneNumberPipe', () => {  ②
  let pipe: any;

  beforeAll(() => {
    pipe = new PhoneNumberPipe();  ③
  });

  describe('default behavior', ()=> {  ④

    it('should transform the string or number into the default phone format', () => {  ⑤
    });

  })

  afterAll(() => {
    pipe = null;  ⑥
  });
});
  • ① → import de l’unique dépendance, le « pipe » lui même.
  • ② → définition de la suite de tests.
  • ③ → définition du callback beforeEach : avant chaque test, un crée un nouvelle instance du « pipe ».
  • ④ → définition d’une sous-suite pour améliorer la consistance du test.
  • ⑤ → déclaration du premier test unitaire tiré du premier test case.
  • ⑥ → définition du callback afterEach : on s’assurance que la variable de test « pipe » ne possède plus de références.

Il ne reste plus qu’à compléter le test, qui d’après le tableau 7.1 nous dit : « Un chaine de 10 caractères numériques affiche un numéro sous la forme ‘+33 (xx) xx xx xx xx’ » :

it('should transform the string into the default phone format', () => {
  const inputPhoneNumber = '0987653498';
  const transformedPhoneNumber = pipe.transform(inputPhoneNumber);
  const expectedResult = '+33 (09) 87 65 34 98';
  expect(transformedPhoneNumber).toBe(expectedResult);
});

Je vous l’avez dit, tester des « pipes » est encore plus facile que les directives : pas besoin de toute l’artillerie du framework du tests d’Angular, ici nous testons de simples fonctions :

Figure 7.1 – Premier test ok.

Enchaînons par le second « test case », qui d’après le tableau 7.1 nous dit : « Les caractères non numériques sont ignorés. ». Ce « test case » présente en réalité 2 cas distincts :

  1. la chaine de caractère est composé de 10 chiffres et d’au moins un caractère non numérique.
  2. la chaine de caractère est composé de 9 chiffres ou moins et d’au moins un caractère non numérique.

Pourquoi faire cette distinction ? Après tout on pourrait simplement déclarer que si la chaine possède au moins un caractère non numérique, le « pipe » est inopérant. Mon avis sur la question est qu’une absence totale d’affichage ou aucun changement perceptible apporte beaucoup de confusion. On ne sait pas trop pourquoi ça ne fonctionne pas et on teste tout sortes de choses sans succès. De plus, simplement retirer les caractères non numériques et aboutir à une chaine de 10 caractères fait sens, c’est au final ce que l’on souhaite.

Maintenant, assez de bavardage et place au tests :

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

  it('should only remove non digit chars if there are less than 10 digits', () => {
    const inputPhoneNumber = '0474769p60';
    const transformedPhoneNumber = pipe.transform(inputPhoneNumber);
    const expectedResult = '047476960';
    expect(transformedPhoneNumber).toBe(expectedResult);
  });

  it('should remove non digit chars and format the string if there are at least 10 digits', () => {
    const inputPhoneNumber = '04747695p60';
    const transformedPhoneNumber = pipe.transform(inputPhoneNumber);
    const expectedResult = '+33 (04) 74 76 95 60';
    expect(transformedPhoneNumber).toBe(expectedResult);
  });

});

Ce deuxième « test case » peut être rassemblé dans une sous suite pour plus de lisibilité. C’est ce que j’ai fait en utilisant un bloc « describe » ( ① ) dans le le bloc principal du même nom.

Figure 7.2 – Le second test case passe.

En enfin, troisième test case qui d’après le tableau 7.1 nous dit : « Affiche la même chaine si le nombre de chiffres est inférieur à 10. ». On va se placer dans le sous suite « default behavior » (comportement pas défaut en français), car cette suite sous tend que la valeur à formatter et fournie au « pipe » ne contient que des caractères exploitables :

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

  it('should transform the string into the default phone format', () => {
    // --
  });

  it('should display the same string if there are less than 10 digits', () => {  ①
    const inputPhoneNumber = '987653498';  ②
    const transformedPhoneNumber = pipe.transform(inputPhoneNumber);
    const expectedResult = '987653498';  ②
    expect(transformedPhoneNumber).toBe(expectedResult);
  })

});
  • ① → déclaration du test dans la sous suite « default behavior ».
  • ② → la chaine 987653498 n’a que 9 caractères, donc moins de 10.
  • ③ → le résultat attendu est donc 987653498

Conclusion

Étant donné que les « pipes » ne prennent qu’une valeur en entrée, transforment cette valeur, puis renvoient le résultat transformé, écrire des tests pour ces classes est très simple. C’est aussi parcequ’elles font partie des « fonctions pures », et n’ont donc pas pas de « sides effects » (ou effets de bord).

Les « sides effects » sont des changements qui se produisent en dehors d’une fonction après l’exécution de la dite fonction. Un « sides effects » très courant est, par exemple, un affiche dans la console d’un message d’erreur.

Lorsque vous testez des « pipes », vous testez principalement la méthode de transformation (transform) incluse dans la classe car c’est elle qui :

  • reçoit les différents paramètres que vous souhaitez manipuler.
  • effectue la manipulation.
  • puis renvoie les valeurs modifiées.
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 du cours

Markup

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

Open in StackBlitz

Ou suivez pas à pas les instructions 😇 !

Tester 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 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

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

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.