Intermédiaire

Tests unitaires avec Angular (partie 1)

Au début des années 2010, les frameworks Javascript ont pris un essor important, remplaçant le métier d’intégrateur web par celui de développeur front-end. Les solutions de type jQuery, librairies de manipulation et d’animation d’une page HTML, ont fait place à des outils permettant la mise en œuvre d’applications beaucoup plus riches, indépendantes en terme de fonctionnement des traitements serveur, orientées Single Page Application.

À cette complexité s’ajoute un plus grand risque de bugs ou de dysfonctionnements. Il devient nécessaire de vérifier correctement notre code, avec les tests unitaires.

Angular fournit en standard tous les outils permettant de les implémenter. Ces tests vont assurer un code de qualité, sans traitement inutile ou complexe. Ils vont nous permettre de documenter nos fonctionnalités, et nous orienter vers une simplicité de programmation qui sera la bienvenue pour assurer la maintenabilité de nos logiciels.

Les exemples présentés dans cet article sont consultables sur ce github.

I. Les outils : Karma, Jasmine, angular-cli

Karma va être notre moteur de tests. Notre projet, créé avec l’angular-cli, comporte un fichier de configuration standard, karma.conf.js. Les tests sont par défaut lancés dans Chrome, on peut cependant choisir d’autres navigateurs. Plusieurs plugins sont ajoutés, dont karma-istanbul, un utilitaire de code coverage, et karma-jasmine.

Jasmine sera notre langage d’assertion. Il permet de décrire les objets à tester, d’exécuter des commandes avant et après chaque test, et d’évaluer le bon fonctionnement de notre code.

Angular-cli nous fournit la ligne de commande de lancement de nos tests :

ng test

Par défaut, nos tests unitaires sont en mode « watch ». Chaque modification de nos sources provoque une réexécution des tests.

On peut compléter notre commande avec des options, comme n’exécuter le test qu’une fois (- -single-run) ou encore générer un rapport de couverture de test (- -code-coverage) :

ng test --single-run --code-coverage

II. Jasmine

Les fichiers dans lesquels nous allons écrire nos tests sont au format :

nomfichier.spec.ts

Par convention, nous créons nos fichiers spec.ts dans le même répertoire que l’objet que nous souhaitons tester. Karma ira analyser tous les fichiers spec.ts qu’il trouvera dans notre projet.

Jasmine a les principales fonctions et méthodes suivantes :

  1. Describe : description de l’objet à tester
  2. beforeEach / afterEach : exécution de code avant / après chaque test
  3. it : bloc de description de la fonctionnalité à tester
  4. expect : évaluation du cas à tester

Expect comporte des matchers de comparaison avec la variable ou la méthode que l’on souhaite évaluer. Les principaux sont :

  • toBe : égalité stricte (équivalent au === javascript)
  • toEqual : égalité non stricte (comparaison entre objets)
  • toContain : un Array contient un élément donné, ou une string contient une chaîne donnée
  • toBeDefined : l’objet doit être défini
  • toBeNull : la valeur doit être nulle
  • toBeTruthy / toBeFalsy : la valeur est vraie / fausse (truthy / falsy)
  • toHaveBeenCalled : une méthode doit avoir été appelée
  • toHaveBeenCalledWith : une méthode doit avoir été appelée avec des paramètres d’une certaine valeur

Jasmine nous offre la possibilité de mocker des objets ou des méthodes avec :

  • spyOn : mock de la méthode d’un objet
  • createSpyObj : mock d’un objet dans son intégralité

Ces mocks peuvent retourner une valeur que nous définissons afin de satisfaire les besoins de nos tests.

III. Premier test : une fonction pure

Commençons un premier test avec une fonction pure. C’est l’un des cas les plus simples à tester, qui ne sollicite pas d’environnement de test spécifique à Angular.

Une fonction est pure lorsqu’elle retourne toujours la même valeur avec les mêmes paramètres. Elle n’a pas d’effet de bord, comme une requête http, qui pourrait changer ce comportement.

Un cas de fonction pure que l’on peut trouver dans Angular est un reducer, fonction utilisée avec redux et @angular-redux/store, ou @ngrx/store.

Notre fonction prend 2 paramètres en entrée, un state et une action. Elle retourne un nouvel état du state en fonction de l’action :


export function someReducer( state = {}, action ) {

	switch (action.type) {

		case ACTION.START:
				return Object.assign({}, state, { start: true });

		default:
				return state;
	}

}

Notre test aura pour rôle de vérifier que l’objet retourné a bien un paramètre start à true si l’action est ACTION.START. Il vérifiera ensuite qu’il retournera l’état précédent si un autre type d’action est appelé.


import { someReducer } from './some-reducer';
	
describe('someReducer', () => {
	
	it('should add a start parameter to true after ACTION.START', () => {

		const result = someReducer({}, { type: ACTION.START });
		expect(result).toEqual({ start: true });

	});

	it('should return the former state', () => {

		const result = someReducer({}, { type: 'autre' });
		expect(result).toEqual({});

	});
	
});

describe nous permet de nommer le test. Ce nom apparaîtra dans nos rapports de tests.

La première fonction it() va exécuter la fonction someReducer avec un state vide et une action ACTION.START. Le résultat de l’appel de fonction est stocké dans une constante result.
Nous nous attendons (expect) à ce que result soit égal à { start: true }.

Le 2e fonction  it() va appeler someReducer avec un autre type d’action. Nous nous attendons à ce que le résultat de l’appel soit égal à l’objet passé en state.

IV. Le TestBed

La librairie standard de tests Angular se nomme @angular/core/testing. Elle comprend des objets et fonctions, dont TestBed et async. Ces éléments vont nous permettre de définir un module de test pour nos directives et services Angular.


import { NO_ERRORS_SCHEMA } from '@angular/core';
import { TestBed, async } from '@angular/core/testing';
	
describe( 'mon test', () => {

	beforeEach(async(() => {

		TestBed.configureTestingModule({
				imports: [ ...modules ],
				declarations: [ ...components, ...directives, ...pipes ],
				providers: [ ...services ],
				schemas: [ NO_ERRORS_SCHEMA ]
		 });

	});
	
});

Nous allons configurer notre module de test avec les objets qui vont intervenir dans notre test : composants, directives, pipes, services, modules…

Cette configuration est précisée dans une fonction beforeEach : notre module de test sera redéfini pour chaque nouveau test de fonctionnalité it().

Le mot clé async permet de définir une zone d’exécution asynchrone : le compiler-cli va prendre un certain temps à compiler les composants.

Le paramètre NO_ERROS_SCHEMA permet au compiler d’ignorer les éléments non reconnus : un composant déclaré peut utiliser d’autres composants. Ils n’auront pas d’utilité dans notre test, mais provoqueraient une erreur d’exécution sans cette option.

V. Tester un service avec injection de dépendance

La logique métier de notre application Angular repose sur les services. Ils sont instanciés au lancement de l’application (sauf ceux déclarés dans des modules accédés par route asynchrone), sont des singleton, injectables dans l’ensemble de l’application, et vont souvent injecter d’autres services.

Voici un service dont le rôle est d’injecter un autre service effectuant une requête http (Observable), qui va ensuite mapper le résultat en une nouvelle valeur (une string mise en uppercase pour faire simple) :


import { Injectable } from '@angular/core';
import { DataService } from './data.service';
	
@Injectable()
export class MapperService {

	public monResultat;

	constructor( private dataService: DataService ) {}

	public getData = (param) => {

		this.dataService.getInfo(param).subscribe(resultat => {
				this.monResultat = resultat.toUpperCase();
		});

	}
}

Pour tester ce service, nous allons devoir décomposer nos actions comme ceci :

  1. Mocker le service qui a pour charge de faire la requête http
  2. Retourner une valeur de type Observable pour la méthode getInfo
  3. Créer le module de test et déclarer le service à tester, ainsi que le service mocké
  4. Instancier le service à tester
  5. Exécuter la méthode getData avec un paramètre
  6. Vérifier que la méthode getInfo ait été appelée avec ce paramètre
  7. Vérifier que la variable monResultat contient bien une valeur en uppercase

Commençons par importer tout ce dont nous avons besoin :


	import { TestBed, async, inject } from '@angular/core/testing';
	import { Observable } from 'rxjs/Observable';
	import { MapperService } from './mapper.service';
	import { DataService } from './data.service'; 
	

Mockons le service et retournons une valeur de type Observable :


	const spyDataService = jasmine.createSpyObj('spyDataService', ['getInfo']);
	spyDataService.getInfo.and.returnValue( Observable.of('Hello') );
	

Déclarons le module de test. Le service MapperService est déclaré tel quel, par contre nous allons « provider » une value de type spyDataService à notre service DataService :


beforeEach(async(() => {

	TestBed.configureTestingModule({
		providers: [
			MapperService,
			{
				provide: DataService,
				useValue: spyDataService
			}
		]
	});
	
}));

Nous pouvons maintenant instancier notre MapperService grâce à la fonction inject(), et récupérer l’instance dans une variable parente service :


let service;

beforeEach(inject( [MapperService], (_s: MapperService) => {

	service = _s;
	
}));

Nous pouvons maintenant coder notre test. Exécutons la méthode getData. Vérifions que getInfo a bien été appelé, et que monResultat contient bien HELLO :


it('should get data and format result to uppercase', () => {

	service.getData('mon parm');

	expect(spyDataService.getInfo).toHaveBeenCalledWith('mon parm');
	expect(service.monResultat).toBe('HELLO');
		
});

Observable.of() nous retournant un observable terminé, nous n’avons pas à nous occuper de gérer notre test dans un cadre asynchrone.

Le test de services va consister à appeler chacune des méthodes, et à vérifier qu’elles retournent le résultat attendu.
Si notre service comprend des méthodes privées, leur test se fera par l’appel de méthodes publiques qui les utilisent, en prenant soin de bien tester tous les cas possibles avec différentes valeurs de paramètres.

VI. Test d’un service avec appel asynchrone

Dans ce cas, nous allons vouloir tester une variable publique observable. Nous allons y souscrire, et profiter de Jasmine qui nous retourne en paramètre de callback de la fonction it() une fonction done() à exécuter une fois le traitement asynchrone résolu.

Modifions le service à tester, en affectant un BehaviorSubject à notre variable résultat, et en exécutant la méthode next de mon subject afin de publier de manière asynchrone la nouvelle valeur :


import { Injectable } from '@angular/core';
import { DataService } from './data.service';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';

@Injectable()
export class MapperService {

	public monResultat = new BehaviorSubject('');

	constructor( private dataService: DataService ) {}

	public getData = (param) => {

		this.dataService.getInfo(param).subscribe(resultat => {
				this.monResultat.next(resultat.toUpperCase());
		});

	}
}

Pour rappel, un BehaviorSubject est un observable, initialisé à une certaine valeur (ici une string vide), auquel on va souscrire, dont on récupère la dernière valeur au moment de la souscription. Il dispose d’une méthode next() qui permet de pusher une nouvelle valeur à ses observers. Lors de notre traitement, il faudra donc gérer cet abonnement dans un cadre asynchrone.


it('should get data and format result to uppercase', done => {

	service.getData('mon parm');

	service.monResultat.subscribe(resultat => {
	
		expect(spyDataService.getInfo).toHaveBeenCalledWith('mon parm');
		expect(resultat).toBe('HELLO');

		done();
			
	});

});

L’exécution de notre fonction done() permet de signaler à Karma que l’exécution asynchrone est résolue, faute de quoi nous aurions une erreur de type timeout.

VII. Tester http

Reste enfin le cas de notre requête http. Depuis Angular 4.3, le framework fournit HttpClientModule, ainsi qu’un HttpClientTestingModule pour réaliser nos tests.

Notre service va faire un http get vers une url donnée :


public getInfo = param => {

	return this.http.get(`/url?param=${param}`);
	
}

Commençons par importer les objets nécessaires :


import { TestBed, async, inject } from '@angular/core/testing';
import { HttpClientModule, HttpClient } from '@angular/common/http';
import { 
		HttpClientTestingModule, HttpTestingController 
} from '@angular/common/http/testing';
import { DataService } from './data.service';

Créons notre module de test :


let service, http, backend;

beforeEach(() => {

	TestBed.configureTestingModule({
		imports: [ HttpClientTestingModule ],
		providers: [ DataService ]
	});
	
});

Injectons nos services :


beforeEach(inject([DataService, HttpClient, HttpTestingController], (
	conf: DataService,
	_h: HttpClient,
	_b: HttpTestingController
) => {
	service = conf;
	http = _h;
	backend = _b;
}));

Après chaque test, nous allons devoir vérifier que les requêtes ont bien été terminées :


afterEach(inject([HttpTestingController], (httpMock: HttpTestingController) => {

	httpMock.verify();
		
 }));
 

Codons maintenant notre test :


it('should get data', () => {

	service.getInfo('param').subscribe(res => {
	
		expect(res).toBe('pan');
			
	});

	const req = backend.expectOne({
		url: '/url?param=param',
		method: 'GET'
	});

	req.flush('pan', { status: 200, statusText: 'ok' });

});

Tout d’abord, nous allons appeler notre méthode getInfo, souscrire à la requête http qu’elle retourne puis récupérer le résultat et s’attendre à ce que la valeur obtenue soit égale à « pan ».

Nous allons ensuite paramétrer la requête en s’attendant à avoir la méthode GET, ainsi que la bonne url.

Enfin, nous allons résoudre cette requête avec la méthode flush(), ce qui va entraîner la terminaison de notre souscription, et donner la main à notre expect().

VIII. fakeAsync() et tick()

Que se passe-t-il maintenant lorsque nous allons avoir à tester une méthode qui fait appel à un timer ? Nous ne maîtrisons pas l’exécution de ce timer, et devons donc gérer une zone asynchrone :


public value = false;

public getValueAfterTimer = () => {

	setTimeout(() => {
			this.value = true;
	}, 50);
		
} 

fakeAsync nous permet de définir cette zone asynchrone, et tick(n) va cumulativement faire écouler le temps du timeout de manière synchrone au bout de n millisecondes :


import { fakeAsync, tick } from '@angular/core';

it('should return true', fakeAsync(() => {

	service.getValueAfterTimer();

	tick(25);
	expect(service.value).toBe(false);

	tick(25);
	expect(service.value).toBe(true);
		
}));

Dans ce test, le premier tick consomme les 25 premières millisecondes, la valeur retournée reste donc à false.
Avec le 2e tick, on atteint 50 millisecondes, le timeout est résolu, notre variable sera égale à true.

Angular 4.3 nous apporte la fonction flush(), qui résoudra cet appel asynchrone sans avoir à spécifier le temps de résolution :


it('should return true', fakeAsync(() => { 

	service.getValueAfterTimer();

	flush();
	expect(service.value).toBe(true);
	
}));

Conclusion

Nous venons de voir à travers des cas simples, la manière d’utiliser Angular, Karma et Jasmine afin de tester unitairement la logique métier de notre application.

Angular dispose de tous les outils pour maîtriser le test de nos fonctions, méthodes et appels asynchrones.

Rendez-vous dans la prochaine partie de cet article pour découvrir comment gérer les tests de nos objets User Interface : Composants, directives et pipes.

Faites de beaux tests.

© SOAT
Toute reproduction interdite sans autorisation de l’auteur

Nombre de vue : 630

AJOUTER UN COMMENTAIRE