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.