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
- ① Introduction aux tests Angular
- ② Premier test unitaire avec Jasmine
- ③ Tester des composants Angular (1/2)
- ④ Tester des composants Angular (2/2)
- ⑤ Tester des directives Angular (1/2)
- ⑥ Tester des directives Angular (2/2)
- ⑦ Tester des pipes Angular
- ⑧ Tester des services Angular
Markup
Retrouvez le code de cet article sur ce dépôt 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 :
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
).
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 😔 :
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 :
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 leHttpClientTestingModule
. - ⑤ → surcharge de la propriété
apiPath
du serviceProductService
. - ⑥ → 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 :
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 :
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
dewindow.console
. - ③ → définition d’un objet
mockErrorResponse
avec deux propriétés :status
etstatusText
. 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
dewindow.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é 😆 :
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 :
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 :
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 deProduct
(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 Typescriptas
. - ⑥ → 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 :
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 :
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 :
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.
Je tiens à vous remercier pour cet excellent article ! Grâce à lui, j’ai pu résoudre un problème que je rencontrais depuis des jours. Continuez votre excellent travail
Merci pour ce retour ! 🙂
Lorsque j’ai commencé à écrire des tests de services, cela semblait intimidant, mais avec de la pratique, cela devient plus naturel. Ce tutoriel est un excellent point de départ pour les débutants.