Les bases des tests unitaires avec Angular

Publié par Matthieu BERTHO le

Temps de lecture : 6 minutes

Pourquoi faire des tests unitaires?

Dans un objectif de qualité, les tests unitaires sont devenus un passage obligatoire pour tout projet. Considérés comme les tests les « moins chers », bien implémentés, ils permettent de s’assurer de la cohérence de l’implémentation technique des solutions choisies.

Quelques bénéfices :

  • Coût réduit : bien qu’on ne le voit pas au premier abord, le fait de détecter des régressions pendant le développement réduit le temps en aller-retour entre tests fonctionnels et développement.
  • Sécurité/Qualité : comme dit précédemment les tests réduisent grandement le nombre de régressions et donc assure l’expérience utilisateur.
  • Documentation : les tests unitaires peuvent aussi servir de documentation technique à du code complexe.

Nous allons ici voir les bases de l’implémentation des tests unitaires sur un projet Angular.

Les outils

Lors de la génération d’un projet à l’aide de l’outil @angular/cli, Angular intègre automatiquement des outils permettant de réaliser des tests unitaires :

  • Karma : moteur permettant d’exécuter les TU et calculer le code-coverage.
  • Jasmin : framework de tests unitaires.
ng new nouveau-projet

Les fichiers de configuration suivants sont automatiquement créés lors de l’initialisation du projet :

  • karma.conf.js : configuration globale de Karma (fichiers inclus et exclus des tests et du code-coverage, navigateur utilisé pour l’exécution…).
  • tsconfig.spec.json : configuration TypeScript spécifique aux TU (fichiers inclus et exclus de la compilation pour les TU par exemple).

Par défaut, les fichiers de tests unitaires sont identifiés par l’extension .spec.ts.

Ces fichiers sont automatiquement créés lors de la génération d’un composant ou d’un service à l’aide d’@angular/cli, avec un contenu par défaut.

ng generate component home
CREATE src/app/home/home.component.scss (0 bytes)
CREATE src/app/home/home.component.html (19 bytes)
CREATE src/app/home/home.component.spec.ts (614 bytes)
CREATE src/app/home/home.component.ts (268 bytes)
UPDATE src/app/app.module.ts (467 bytes)

L’exécution des TU Angular s’effectue avec la commande suivante:

ng test 

L’application et ses tests sont alors compilés, puis les tests sont exécutés au sein du navigateur configuré dans karma.conf.js. Tout changement effectué dans les sources déclenchera automatiquement un redéploiement et une nouvelle campagne de test.

Chrome est le navigateur configuré par défaut dans Karma. Il est possible de changer ce navigateur dans le fichier karma.conf.js, en installant le launcher associé dans le package.json (par défaut karma-chrome-launcher pour Chrome). Pour l’exécution dans un environnement d’intégration continue, il est préférable d’utiliser un navigateur sans interface graphique comme ChromeHeadless.

Il est bien sûr possible de ne lancer les tests qu’une seule fois avec la commande suivante:

ng test --watch=false

Enfin, le code-coverage peut être calculé avec l’option --code-coverage:

ng test --code-coverage

Cette option génère un rapport dans le dossier coverage à la racine du projet. Ce rapport peut être visualisé en ouvrant le fichier coverage/nouveau-projet/index.html dans un navigateur.

Structure d’un test Jasmine

Pour écrire des tests unitaires avec Jasmine, on va commencer par déclarer un TestingModule, la structure de déclaration pour un composant est la suivante :

beforeEach(waitForAsync(() => {
   TestBed.configureTestingModule({
     // Dépendances directes de notre module
     imports: [HttpClientTestingModule],

     // Composants utilisés dans le template
     declarations: [...],

     // Services injectables utilisés
     providers: [...],
   })
       .compileComponents();
 }));

On peut s’intéresser ici à plusieurs choses :

  • beforeEach : fonction qui sera déroulée avant chacun de nos tests
  • Testbed : classe Angular permettant de créer un environnement de test, émulant un module Angular.
    • imports : les importations à faire pour que notre module fonctionne correctement (ex : HttpClientTestingModule).
    • declaration : les composants/sous-composants/pipes qui sont utilisés dans notre template.
    • providers : les différents services injectables qu’utilise votre composant.

Une fois que nous avons instancié correctement notre environnement de test nous allons pouvoir écrire nos premiers tests.

Pour cela, il nous faut à minima 3 fonctions fournies par Jasmine :

  • describe : permet de définir un groupe de « specs ».
  • it : permet de définir une « spec » (ou un test).
  • expect : pour implémenter les assertions.

Pour accompagner ces fonctions il nous faudra des ‘matchers’ fournis également par Jasmine:

  • toEqual : tester la valeur d’une variable.
  • toHaveBeenCalled : tester si une fonction a été appelée.

Pour avoir la liste exhaustive des matchers, on peut se référer à la Doc Jasmine.

Tester un service

Les services sont là où se situe la majorité de notre logique métier, c’est donc par eux que nous allons commencer.

Pour injecter un service dans notre environnement de test on va utiliser la méthode inject de TestBed

service = TestBed.inject(BieresService);

Méthode classique

Prenons la fonction suivante qui va nous renvoyer le prix d’une bière TTC en fonction du prix HT et de la TVA :

public calculerPrixBiereAvecTVA(prixHT: number, tva: number) {
        return prixHT + prixHT * tva / 100;
    }

La fonction est très basique, on veut simplement récupérer le prix TTC en fonction du prix HT et de la TVA

describe('#calculerPrixBiereAvecTVA', () => {
    it('doit retourner le prix TTC en fonction de la TVA et du prix hors taxes', () => {
        expect(service.calculerPrixBiereAvecTVA(10, 20)).toEqual(12);
    });
});

Appels HTTP et observables

Pour tester la fonction suivante :

public recupererBieres() {
	return this.http.get<Biere[]>(`${BieresService.backendUrl}/beers`);
}

Nous allons tester un appel http, pour cela nous aurons besoin d’importer le HttpClientTestingModule et d’injecter l’httpTestingController.

beforeEach(() => {
	TestBed.configureTestingModule({
	/**
	* On va utiliser le HTTP Testing Module qui nous évite de réellement faire des requêtes
	*/
		imports: [HttpClientTestingModule]
	});

	service = TestBed.inject(BieresService);

	/**
	* L'HTTP Testing Controller nous est fourni par le Testing Module et nous fourni des méthodes pour mocker des requêtes
	*/
	httpTestingController = TestBed.inject(HttpTestingController);
});

Et enfin le test unitaire :

describe('#recupererBieres', () => {
	it('doit récupérer la liste des bières depuis le backend', () => {
		// 1. On souscrit à l'observable qui lance la requête
		service.recupererBieres().subscribe((result) => {
			// 3. On vérifie que le résultat correspond à ce qui est attendu
			expect(result).toEqual(listeBieres);
		});

		// 2. L'HTTP Testing Controller vérifie que la requête a été lancée, qu'elle correspond à l'URL donnée et renvoie une réponse
			httpTestingController.expectOne((req) => req.url === `${BieresService.backendUrl}/beers`)
				.flush(listeBieres);
	});
});

L’httpTestingController sert à intercepter la requête qui nous intéresse et à renvoyer un résultat mocké.

Tester un composant

Nous allons maintenant voir comment tester un composant, qui peut lui-même faire appel à des services.

Initialisation

Pour tout nouveau composant créé, Angular va ajouter dans le fichier spec.ts associé la fonction suivante :

it('doit créer le composant', () => {
	expect(component).toBeTruthy();
});

Cela va servir à vérifier que le composant s’instancie, on peut donc toujours garder cette fonction.

Bouchonner les dépendances

Une des règles clés des tests unitaires est l’isolation de nos tests, chaque composant, service, fonction, etc… doit être testé sans que rien d’autre ne vienne perturber ce test.

Pour cela, une bonne façon de faire est de bouchonner/mocker nos dépendances.

Prenons comme exemple un composant qui doit afficher une liste de bières et qui récupère la liste à afficher ainsi de :

export class ListeBieresComponent {

	public bieres$: Observable<Biere[]>;

	constructor(private bieresService: BieresService, private matSnackBar: MatSnackBar) {
		this.bieres$ = this.bieresService.recupererBieres();
	}

	public ajouterAuPanier(biere: Biere) {
		this.matSnackBar.open(`Une ${biere.nom} a été ajoutée au panier !`, biere.tchin);
	}
}

Nous avons 2 dépendances pour ce composant, le BieresService qui va nous permettre de récupérer nos bières à afficher, et MatSnackBar qui va afficher une alerte.

On a deux façons de faire pour mocker nos dépendances :

  • La première est de mettre un espion à la place de la fonction qui devrait réellement être appelée :
it('doit ouvrir une SnackBar', () => {
	spyOn(matSnackBar, 'open');
	component.ajouterAuPanier(listeBieres[0]);
	expect(matSnackBar.open).toHaveBeenCalled();
});
  • La seconde, qu’on recommande, est de mettre un espion non plus seulement sur la fonction appelée, mais sur toute la dépendance :
beforeEach((() => {
	/**
	* On "mock" le bieresServices pour éviter de faire réellement les appels HTTP
	*/
	mockBieresService = createSpyObj<BieresService>('BieresService', ['recupererBieres']);
	mockBieresService.recupererBieres.and.returnValue(of(listeBieres));

	TestBed.configureTestingModule({
	// ...
	providers: [
		/**
		* On utilise notre bieresService mocké à la place du vrai
		*/
		{
			provide: BieresService,
			useValue: mockBieresService
		}
	],
	}).compileComponents();
}));

Cela nous permet d’être encore plus isolé dans nos tests unitaires, et de ne plus avoir à s’inquiéter de dépendances de dépendances…

Input/Output

Des informations passent en permanence entre les composants parents et enfants en Angular.

Pour faire transiter ces informations on utilise des @Input/@Output.

Prenons pour exemple la classe suivante :

export class ListeBieresItemComponent {
	@Input() biere: Biere;
	@Output() ajouteAuPanier: EventEmitter<Biere>;

	constructor() {
		this.ajouteAuPanier = new EventEmitter<Biere>();
	}

	public ajouterAuPanier(): void {
		this.ajouteAuPanier.emit(this.biere);
	}
}

On veut ici mocker un @Input nécessaire au composant enfant :

it('doit créer le composant', () => {
	component.biere = listeBieres[0];
	fixture.detectChanges();
	expect(component).toBeTruthy();
});

Pour vérifier que notre composant enfant envoie bien les informations au parent :

it('doit émettre un event sur l\'output ajouteAuPanier', () => {
	spyOn(component.ajouteAuPanier, 'emit');
	component.ajouterAuPanier();
	expect(component.ajouteAuPanier.emit).toHaveBeenCalled();
});

Tester un pipe

Tester les pipes est souvent un bon truc pour faire du TDD.

Le Test Driven Developement consiste à commencer par rédiger les tests avant de faire le code. Cela permet d’avoir des spécifications précises de notre besoin avant de résoudre techniquement le problème.

Voici les tests d’un pipe qui permet d’afficher le prix des bières :

describe('doit formater le prix', () => {
	it('doit ajouter le symbole € après le nombre', () => {
		expect(pipe.transform(2.38)).toEqual('2.38€');
	});

	it('doit toujours avoir deux chiffres après la virgule', () => {
		expect(pipe.transform(10)).toEqual('10.00€');
	});

	it('ne doit pas ajouter d\'espaces pour les separateurs de milliers', () => {
		expect(pipe.transform(1235.94)).toEqual('1235.94€');
	});

	it('doit arrondir à 2 décimales près', () => {
		expect(pipe.transform(5.5555)).toEqual('5.56€');
	});
});

Et le code qu’on en déduit :

transform(value: number): string {
	return `${value.toFixed(2)}€`;
}

Trucs à éviter

Il est facile de se perdre dans les tests unitaires, alors voilà quelques petites choses à éviter pour garder des tests cohérents :

Tester les fonctions privées : n’essayez pas de tester directement les fonctions privées, mais plutôt de voir ce qu’elles retournent dans leur utilisation dans une fonction publique.

Tester les fonctions Angular : pareillement, il est inutile de tester des fonctions Angular qui ne contiennent pas de logique métier supplémentaire.

Oublier de mocker les dépendantes : en oubliant de mocker les dépendances, on doit maintenir l’évolution de celles-ci. Essayons de rester le plus unitaire possible et mockons un maximum.

Conclusion

On espère que cette introduction aux tests unitaires vous aura permis d’y voir plus clair. Jasmine est parfois complexe à prendre en main mais la base reste abordable si l’on sait ce qu’on fait.

D’autres outils existent si vous voulez pousser plus loin comme Jest et Testing Library pour du test unitaire ou Cypress pour du test End-to-end.

Catégories : Développement