Pour ceux d’entre-vous qui ont eu l’occasion de se pencher sur le framework Angular (V2 et plus) vous avez pu constater à quel point la documentation sur le site officiel pouvait être volumineuse, notamment sur la partie des Tests Unitaires. Le but de cet article est de vous fournir une fiche récapitulative des meilleures pratiques de test unitaire en Angular.
L’article part du principe que vous avez déjà utilisé Angular et ses tests unitaires, et que vous connaissez le champ lexical des Tests Unitaires.
Les conseils et solutions donnés ici sont issus d’une expérience de six mois de développement Angular avec une équipe de cinq personnes. Ils sont bien sûr sujet à amélioration/discussions.
Préambule : Comment “mocker” correctement avec Angular ?
Un petit préambule est nécessaire pour clarifier un point qui est pour moi trop laissé de côté dans la littérature à ce sujet, et qui sera vital pour la suite de la compréhension :
Les mock, Spy et autres Stub.
Trop souvent, une approche un peu naïve est proposée dans les exemples de test unitaire Angular. Prenons le cas du test d’un component dans la doc officielle :
class MockUserService { isLoggedIn = true; user = { name: "Test User" }; } /* ... */ beforeEach(() => { TestBed.configureTestingModule({ // provide the component-under-test and dependent service providers: [ WelcomeComponent, { provide: UserService, useClass: MockUserService } ] }); // inject both the component and the dependent service. comp = TestBed.get(WelcomeComponent); userService = TestBed.get(UserService); });
Ce code explique que pour “mocker” l’implémentation d’un service (ici UserService
) il suffit de créer simplement une fausse classe remplaçant ce service (ici MockUserService
).
Cette approche me gêne fondamentalement car nous perdons toute la vérification de type. En effet, rien ne garantit que MockUserService
respecte bien l’interface de la classe UserService
. À cela vient s’ajouter le coût d’écriture d’une fausse classe …
[bctt tweet=”En Angular, créer un mock à la main n’est ni une approche sûre, ni une approche efficace.”]
Une autre façon de faire que l’on peut voir ici et là, est d’étendre la classe que l’on désire mocker puis, surcharger les comportements désirés. Si nous reprenons notre exemple précédent, cela ressemblerait à ça :
class MockUserService extends UserService { user = { name: "Another user" }; } /* ... */ beforeEach(() => { TestBed.configureTestingModule({ // provide the component-under-test and dependent service providers: [ WelcomeComponent, FirstLevelNeededService, SecondLevelNeededService, { provide: UserService, useClass: MockUserService } ] }); // inject both the component and the dependent service. comp = TestBed.get(WelcomeComponent); userService = TestBed.get(UserService); });
Cette méthode est encore pire que la précédente, car même si vous avez corrigé le problème de typage et de verbosité, vous avez rompu l’isolation nécessaire à un test unitaire.
Comme la vraie implémentation de UserService
est importée, le TestBed
va exiger qu’on lui fournisse aussi toutes les classes dont dépend UserService
. Imaginons le schéma de dépendances ci-dessous :
+---------------+ +--------------------------+ +--------------------------+ | | | | | | | UserService +<---+ FirstLevelNeededService +<----+ SecondLevelNeededService | | | | | | | +---------------+ +--------------------------+ +--------------------------+
Suivant ce schéma, nous devrions fournir au TestBed
, FirstLevelNeededService
et SecondLevelNeededService
. Cela n’a pas de sens quand on y réfléchit car le but d’un mock est de simuler le comportement d’une classe. Pourquoi donc fournir les dépendances d’un simulacre ?
[bctt tweet=”Ne jamais créer un mock d’une classe TypeScript en étendant son implémentation d’origine dans Angular.”]
Sous peine de devoir fournir une masse conséquente de classe à chaque suite de test.
Utiliser une librairie de Mock
Pour les développeurs orientés Object, utiliser une librairie de mock est une évidence dans le cadre des tests unitaires car leur langage n’est pas souvent aussi dynamique que le JavaScript et ne permet pas d’écrire des Mock à la volée (“à l’arrache ?” – Qui a dit ça ?).
Une librairie de Mock va utiliser la définition de type créée via TypeScript pour créer un simulacre d’une classe.
Vieux réflexe de “Javaiste” peut-être, j’affectionne personnellement ts-mockito comme libraire de Mock, mais il en existe d’autres : TS-mock ou encore Typemoq
Si nous reprenons notre exemple avec ts-mockito, notre code ressemblerait à ça :
import { instance, mock, when } from "ts-mockito"; /***/ let userServiceMock: UserService; beforeEach(() => { userServiceMock = mock(UserService); when(userServiceMock.user).thenReturn({ name: "A user" }); TestBed.configureTestingModule({ providers: [ WelcomeComponent, { provide: UserService, useValue: instance(userServiceMock) } ] }); userService = TestBed.get(UserService); });
Simple, n’est-ce pas ? ts-mockito va d’abord créer une classe mockée grâce à la fonction mock
puis créer des instances de simulacre basées sur cette classe avec la fonction instance
.
Pour simuler des résultats, on utilisera souvent la fonction when
. Utiliser toujours when
sur une classe mockée et appeler instance
uniquement après, sinon votre instance de mock n’aura pas les comportements définis.
Dans la suite de l’article nous utiliserons systématiquement ts-mockito
Le guide
Cas n°1 : Tester un service sans dépendance avec Angular
Commençons par le plus simple, le service. En Angular, un service n’est ni plus ni moins qu’une classe TypeScript qui va être instanciée par le container IOC d’Angular et injectée dans tous les autres éléments qui le définiront comme dépendances.
Si le service n’utilise pas de dépendance venant d’Angular (comme le HttpClient
par exemple) vous pourrez le tester comme n’importe quelle classe TypeScript de n’importe quel projet de cette façon :
import { MyService } from "./my.service"; import { instance, mock, when } from "ts-mockito"; import { MathLib } from "./my.service"; describe("MyService", () => { it("should add correctly two positive integers and multiply them by two", () => { // given const mathMock = mock(MathLib); when(mathMock.add(1, 2)).thenReturn(3); when(mathMock.multiply(3)).thenReturn(6); const myService = new MyService(instance(mathMock)); // when const result = myService.addAndMultiplyByTwo(1, 2); // then expect(result).toBe(6); }); });
Premier point, vous remarquerez qu’à l’inverse de angular-cli on ne crée pas de test should be created
. Ce test qui servirait à vérifier l’existence de la classe est de facto déjà vérifiée par les autres tests unitaires, donc inutile.
Deuxième point et potentiellement le plus important de cet article :
[bctt tweet=”N’utilisez le `TestBed` que le plus rarement possible dans les tests unitaires Angular.”]
Le TestBed
, plus particulièrement l’appel de sa méthode configureTestingModule
, est long et amène plus de complexité dans les tests. Et pour cause, l’appel de la méthode TestBed.configureTestingModule
va, comme son nom l’indique, créer un Module Angular à chaque appel et instancier tous les éléments de ce ‘faux module’ grâce au container IOC d’Angular.
Nous avons observé dans nos essais, que l’utilisation du TestBed
prendra potentiellement cinq fois plus de temps qu’une simple instanciation d’objet et ses mocks comme dans notre exemple (plus d’infos ici) : Angular TestBed is too long)
Nous n’avons rien inventé là, c’est ce qu’on appelle un Isolated Test dans la doc Angular (dont je n’arrive plus à trouver le lien tellement elle est énorme :D)
Ok, mais que se passe-t-il dans le cas où j’utilise des Module
venant d’Angular tel que le HttpModule
?
Cas n°2 : Tester un service qui dépend de module Angular
Prenons un test assez classique d’un service d’authentification qui utilise le HttpClient
:
import { AuthService } from "./auth.service"; import { inject, TestBed } from "@angular/core/testing"; import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; describe("AuthService", () => { beforeEach(() => { TestBed.configureTestingModule({ providers: [AuthService], imports: [HttpClientTestingModule] }); }); it( "should return null user when server responds HTTP error", inject( [HttpTestingController, AuthService], (httpMock: HttpTestingController, service: AuthService) => { service.user.subscribe(user => { expect(user).toBe(null); }); httpMock.expectOne("auth/user").error(new ErrorEvent("401")); httpMock.verify(); } ) ); });
Nous n’avons pas le choix d’utiliser le TestBed
car c’est lui qui permet d’accéder au httpMock
responsable de simuler les réponses du serveur.
[bctt tweet=”On n’utilisera le `TestBed` uniquement pour tester les composants ou les classes dépendant de module Angular.”]
Vous noterez aussi l’utilisation du petit helper inject
, bien pratique pour récupérer les instances créées à l’intérieur du conteneur IOC d’Angular. Vous aurez aussi peut-être remarqué que mon beforeEach
ne contient pas d’appel à async
. Async
a tendance à être utilisé à tort et à travers, or, cette fonction est là pour attendre un asynchronisme.
Si vous regardez la doc officielle configureTestingModule
ne retourne pas de Promise
, il n’est donc pas asynchrone, ce n’est donc pas utile de faire appel à async
dans ce cas.
Cas n°3 : Tester un composant “basique”
C’est le genre de composant que vous écrivez beaucoup en Angular.
On verra dans le cas d’après que les composants situés haut dans la hiérarchie feront aussi l’objet d’un traitement un supplémentaire.
Pour illustrer ce test, nous allons imaginer être en train vérifier un composant ListComponent
qui affiche une liste chargée depuis un service ItemsService
: un cas d’école …
La bonne façon de faire ressemblerait à ça :
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { anyString, instance, mock, when } from "ts-mockito"; import { ListComponent } from "./list.component"; import { ItemsService } from "./item/items.service"; import { Item } from "./item/item.model"; import { of } from "rxjs/observable/of"; describe("ListComponent", () => { let component: ListComponent; let fixture: ComponentFixture<ListComponent>; let mockedItemsService: ItemsService; beforeEach(() => { mockedItemsService = mock(ItemsService); }); async function configureTestingModule() { await TestBed.configureTestingModule({ providers: [ { provide: ItemsService, useValue: instance(mockedItemsService) } ], declarations: [ListComponent] }).compileComponents(); fixture = TestBed.createComponent(ListComponent); component = fixture.componentInstance; fixture.detectChanges(); } it("should display an empty list", async () => { // given when(mockedItemsService.loadItems()).thenReturn(of([new Item()])); // when await configureTestingModule(); // then const nbEl = fixture.debugElement.queryAll(By.css("li")).length; expect(nbEl).toEqual(0); }); });
La première chose qui vous a peut-être choqué, c’est la fonction configureTestingModule
en dehors du beforeEach
. En effet, il va être nécessaire de l’appeler dans nos tests unitaires et pas avant afin de maintenir l’ordre d’appel des fonctions mockito citées dans le préambule :
mock -> when -> instance
mock
va être appelé dans le beforeEach
puis when
en début de test unitaire et enfin instance
via l’appel de configureTestingModule
en milieu de test unitaire.
Si nous nous concentrons sur configureTestingModule
, nous voyons qu’elle est asynchrone car on invoque compileComponents
qui retourne une promesse. Cette async
“contamine” d’ailleurs notre test unitaire car on doit le marquer comme async
aussi.
Nous remarquerons aussi que nous surchargeons le provider de ItemsService
pour lui donner l’instance de notre mock ts-mockito :
{ provide: ItemsService, useValue: instance(mockedItemsService) }
Cela fera en sorte que le TestBed
injecte ce simulacre de ItemsService
au composant lors de sa création.
Enfin, nous simulons le retour de la méthode loadItems
du service afin qu’elle retourne un Observable<Item[]>
vide et nous vérifions que la liste html des éléments est bien vide.
Cas n°4 : Tester un composant de haut niveau
Ce que j’appelle des composants “de haut niveau”, sont les composants situés assez haut dans la hiérarchie de composant de notre application. Les composants pages ou contenant beaucoup d’enfants répondent parfaitement à cette dénomination. Le AppComponent
sera d’ailleurs l’exemple utilisé.
La chose contrariante pour vérifier le comportement de ce genre de composant : ils dépendent par définition de beaucoup de sous-composants et sous-modules et il va falloir TOUS les ajouter dans le TestBed
. Pour le AppComponent
ça revient à quasiment lui donner toute l’app en dépendance …
Si d’ailleurs je vous disais que le TestBed.configureTestingModule
était lent dans le cas n°1, il l’est de plus en plus à mesure que le nombre de dépendance s’agrandit.
Pour pallier ce problème, il existe le paramètre schemas
à fournir au TestBed.configureTestingModule
avec la valeur NO_ERRORS_SCHEMA
:
import { TestBed } from "@angular/core/testing"; import { AppComponent } from "./app.component"; import { NO_ERRORS_SCHEMA } from "@angular/core"; describe("AppComponent", () => { let component: ListComponent; let fixture: ComponentFixture<ListComponent>; beforeEach(async () => { TestBed.configureTestingModule({ providers: [], imports: [], declarations: [AppComponent], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); fixture = TestBed.createComponent(ListComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it("should render title in a h1 tag", async () => { // given const selectorText = compiled.querySelector("h1").textContent; // then expect(selectorText).toBe("My awesome app"); }); });
Ce paramètre magique, configure le TestBed
pour qu’il ne lève pas d’erreur s’il ne trouve pas le provider d’un composant ou d’un module utilisé dans le(s) template(s) de(s) composant(s) testé(s). Cette méthode s’appelle du shallow testing
. Nous testons uniquement le composant AppComponent
et n’interprétons pas ses sous-composants.
Le NO_ERRORS_SCHEMA
est à utiliser avec prudence, car appliqué partout, il rend inertes les vérifications du système de dépendance et pourrait vous faire oublier de charger un composant alors qu’il est nécessaire dans votre cas de test.
[bctt tweet=”En Angular, `NO_ERRORS_SCHEMA` est à utiliser avec prudence et seulement pour tester les composants situés haut dans la hiérarchie.”]
Cas n°5 : Les pipes, les classes, le reste
Les pipes, comme le reste des élements qui pourraient composer votre application, vont être testés comme les services sans dépendance Angular, à savoir des classes TypeScript basiques.
Imaginons, un pipe dont le but est de joindre sous la forme d’une chaine de caractère toutes les clés d’un objet dont la valeur n’est pas nulle. On l’appelerait AliciaKeys
… (oui, on n’était pas peu fier du nom)
import { Pipe, PipeTransform } from "@angular/core"; @Pipe({ name: "aliciaKeys" }) export class AliciaKeys implements PipeTransform { transform(obj: any, arg: string[]): string { return obj ? Object.entries(obj) .filter(([key, value]) => !!value) .map(([key]) => key) .join(", ") : ""; } }
Le fichier de test unitaire attaché est trivial puisqu’il s’agit d’une simple instanciation de la classe AliciaKeys
avec deux appels de transform
dans deux cas différents :
import { AliciaKeys } from "./aliciakeys.pipe"; describe("AliciaKeys", () => { it("should return valued keys", () => { const result = new AliciaKeys().transform({ validKey: {}, inValidKey: null, anotherValidKey: 1 }); expect(result).toEqual("validKey, anotherValidKey"); }); it("should return blank when input is falsy", () => { const result = new AliciaKeys().transform(null; expect(result).toEqual(""); }); });
Conclusion & aller plus loin
Nous espérons que chaque description de cas va vous permettre, à vous aussi, de mieux comprendre comment bien tester unitairement vos applications Angular.
Les points importants à retenir sont que les classes en Angular, restent des classes TypeScript classiques et qu’elles peuvent être testées simplement. Le deuxième point est que le TestBed
n’est pas systématique, qu’il faut l’utiliser seulement quand on n’a pas le choix. Enfin, les mocks en Angular doivent être gérés avec une librairie de mock : Dans notre cas ts-mockito
.
Il faut savoir que dans notre projet, nous disposions d’environ 700 tests unitaires qui prenaient plus de 30 secondes à s’exécuter. La rationalisation des tests et leur accélération devenait cruciale pour nous.
Nous avons d’ailleurs poursuivie cette quête de l’accélération en passant ces derniers sous Jest. C’est tout à fait possible en suivant les instructions du repo jest-preset-angular et ça permet de gagner beaucoup beaucoup de temps !
Nous reviendrons vers vous avec plus de détail sur ces sujets pendant nos feedbacks de “JS-Talks” (Journée de veille technologique que nous faisons tous les mois). Quelques photos de fin pour que voyez à quoi cela ressemble :
Publié par Mathieu Breton CTO chez JS-Republic