Ma fiche récapitulative des tests unitaires en Angular

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 …

En Angular, créer un mock à la main n'est ni une approche sûre, ni une approche efficace. Click To Tweet

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 ?

Ne jamais créer un mock d'une classe TypeScript en étendant son implémentation d'origine dans Angular. Click To Tweet

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 :

N'utilisez le `TestBed` que le plus rarement possible dans les tests unitaires Angular. Click To Tweet

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.

On n'utilisera le `TestBed` uniquement pour tester les composants ou les classes dépendant de module Angular. Click To Tweet

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&lt;Item[]&gt; 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.

En Angular, `NO_ERRORS_SCHEMA` est à utiliser avec prudence et seulement pour tester les composants situés haut dans la hiérarchie. Click To Tweet

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

No comments

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *