En situation rĂ©elle, vous serez amenĂ©s Ă  tester des composants plus complexes que ce qu’on a vu dans la premiĂšre partie. Par exemple, supposons que l’on souhaite tester une barre latĂ©rale contenant un menu. On aimerait pouvoir la tester sans se soucier du dit menu. Dans de telles situations, on pourra utiliser ce que l’on appelle des « tests superficiels », ou comme disent les anglophones, des « shallow tests ». Ces tests superficiels permettent de tester les composants sur un seul niveau de profondeur, en ignorant tout Ă©lĂ©ment enfant que l’Ă©lĂ©ment pourrait contenir. On dit qu’on teste le composant en « isolation ».

Plan global du cours

Markup

Retrouvez le code de cet article sur ce dépÎt stackblitz :

Open in StackBlitz

Ou suivez pas à pas les instructions 😇 !

Un composant un peu plus complexe

Faisons Ă©voluer notre composant prĂ©cĂ©dent (ContactFormComponent) en lui greffant un service qui fera une requĂȘte vers un serveur qui se chargera de dĂ©livrer le message. Quel serveur, comme il va procĂ©der, tout ceci nous en avons cure pour nos tests, nous allons simplement simuler les requĂȘtes sortantes et leurs rĂ©ponses correspondantes.

ApiService

CrĂ©ons donc notre service ApiService (pour faire simple 😋) en prenant comme modĂšle le code ci-aprĂšs :

import { Injectable } from '@angular/core';
import { HttpClient } from "@angular/common/http";
import { Observable } from "rxjs";

@Injectable({
  providedIn: 'root'
})
export class ApiService {

  apiPath = 'http://apiserver.lol';

  constructor(private http: HttpClient) {
  }

  sendEmail(payload: {recipient: string, message: string}): Observable<any> {
    return this.http.post<any>(`${this.apiPath}/send-email`, payload);
  }
}

Snippet 4.1 – Service ApiService.

Ensuite on va pouvoir injecter notre service flambant neuf directement dans le constructeur du composant, merci l’injection de dĂ©pendance :

constructor(private apiService: ApiService) {
  this.message = new FormControl('', Validators.required);
  this.contactForm = new FormGroup<any>({
    message: this.message
  });
}

À partir de lĂ , si vous avez la curiositĂ© de lancer la suite de tests, vous devriez obtenir une magnifique erreur, semblable Ă  celle de la figure 4.1.

Figure 4.1 – LĂ  on est mal đŸ€­.

Avec ce cas d’utilisation typique, on va bien s’amuser car nous injectons dans notre composant ContactFormComponent un service (ApiService) qui lui mĂȘme possĂšde une dĂ©pendance (HttpClient). On a donc deux niveaux de dĂ©pendances (figure 4.2), configuration idĂ©ale pour commencer Ă  faire n’importe quoi 😆. Heureusement, on va analyser tout ça dans le dĂ©tail pour aborder les problĂšmes potentiels du bon cĂŽtĂ©.

Figure 4.2 – Deux niveaux de dĂ©pendances.

Les outils de tests d’Angular

Parce qu’on s’apprĂȘte Ă  tester un composant un peu plus complexe, je me dois d’abord de faire le tour des principaux outils qu’Angular met Ă  notre disposition pour nous aider Ă  Ă©crire des tests tout mignon. Ces outils sont des dĂ©pendances provenant du framework Angular ou fournies par ce dernier, il faudra donc les importer en dĂ©but de test.

Le « mal-aimé »

Commençons donc par le « mal-aimĂ© » (non ce n’est pas Louis XV) :

import { DebugElement } from '@angular/core';

On utilisera DebugElement pour inspecter un Ă©lĂ©ment du DOM pendant le test. On peut le considĂ©rer comme le « HTMLElement » natif avec des mĂ©thodes et des propriĂ©tĂ©s supplĂ©mentaires qui peuvent ĂȘtre utiles pour le dĂ©boggage des Ă©lĂ©ments. Si vous avez lu le chapitre prĂ©cĂ©dent, vous savez dĂ©jĂ  qu’on ne l’utilisera pas souvent. 😋

Les outils du quotidien

Ensuite les outils du quotidien :

import { ComponentFixture ①, fakeAsync ②, TestBed ⑱, tick ④ } from '@angular/core/testing';

Bien sĂ»r ne mettez pas les numĂ©ros, ils sont lĂ  pour l’explication suivante :

  • ① → une « fixture » de composant est un objet qui permet d’interagir avec une instance du dit composant, dans l’environnement de test. Il fournit un moyen de simuler le comportement du composant et de tester ses fonctionnalitĂ©s, sans avoir besoin d’un navigateur ou d’un serveur.
  • ② → l’utilisation de fakeAsync garantit que toutes les tĂąches asynchrones sont terminĂ©es avant d’exĂ©cuter les assertions. Ne pas utiliser fakeAsync en prĂ©sence d’un code asynchrone peut entraĂźner l’Ă©chec du test car les assertions peuvent ĂȘtre exĂ©cutĂ©es avant que toutes les tĂąches asynchrones ne soient terminĂ©es. fakeAsync s’utilise de concert avec tick pour simuler le passage du temps. Il accepte un paramĂštre, qui est le nombre de millisecondes pour avancer le temps. On y reviendra plus tard.
  • ⑱ → on utilisera cette classe pour installer et configurer les tests. Étant donnĂ© qu’on voudra utiliser TestBed pour chaque nouveau test unitaire d’un composant, d’une directive ou mĂȘme d’un service, c’est l’un des utilitaires les plus importants fournis par Angular. A travers ce document, nous analyserons les mĂ©thodes configureTestingModule, overrideModule et createComponent, que nous utiliserons ensuite. Ayez en tĂȘte que l’API de TestBed est trĂšs Ă©tendue, nous ne ferons qu’effleurer sa surface de l’API dans ce document. N’hĂ©sitez pas Ă  consulter la documentation officielle.
  • ④ → tick, voir point ②

SĂ©lecteurs css

Ensuite, et c’est Ă  contre coeur que je le fais, voyons la star des sĂ©lecteurs css :

import { By } from '@angular/platform-browser';

By est une classe incluse dans le module @angular/platform-browser qui sert à sélectionner des éléments du DOM. Pour sélectionner un élément avec un sélecteur css, on procÚde de la façon suivante :

By.css('.my-class')

La classe offre en tout 3 méthodes, regroupés dans la liste suivante :

MĂ©thodeDescriptionParamĂštre
allRetourne tous les élémentsAucun
cssRetourne que les éléments ciblésAttribut css
directiveRetourne les éléments qui possÚde la directiveNom de la directive
Tableau 4.1 – La classe By.

LĂ  encore on s’en servira avec parcimonie, uniquement avec un pistolet pointĂ© sur la tempe. đŸ”«

Animations

C’est connu, dans les tests on se fiche des animations et les dĂ©veloppeurs Angular l’ont anticipĂ© :

import { NoopAnimationsModule } from '@angular/platform-browser/animations';

On utilisera donc la classe NoopAnimationsModule pour simuler des animations, ce qui permet aux tests de s’exĂ©cuter rapidement sans attendre la fin des animations.

Les routes

Dans les tests, il est fortement dĂ©conseillĂ© d’utiliser les routes de l’application. Le module RouterTestingModule remplacera au pied levĂ© le module RouterModule :

import { RouterTestingModule } from '@angular/router/testing';

GrĂące Ă  ce module, nous dĂ©finirons nos propres routes pour les tests, meilleur moyen de ne pas tout casser si le nom d’une route venait Ă  changer
 đŸ€­.

Petit récapitulatif

Pour ceux qui lisent en diagonale, voici la liste récapitulative des outils abordés dans ce paragraphe :

import { DebugElement } from '@angular/core'; ①
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; ②
import { By } from '@angular/platform-browser'; ⑱
import { NoopAnimationsModule } from '@angular/platform-browser/animations'; ④
import { RouterTestingModule } from '@angular/router/testing'; â‘€
  • ① → utilisĂ© pour le dĂ©boggage des Ă©lĂ©ments du composant.
  • ② → les outils du quotidien fourni par la team Angular.
  • ⑱ → sĂ©lection des Ă©lĂ©ments du DOM avec un sĂ©lecteur css.
  • ④ → supprimer les animations inutiles lors des tests.
  • â‘€ → utilisĂ© pour tester la configuration du router.

Tout ceci est encore un peu flou, mais Ă  ce stade c’est tout Ă  fait normal.

Peut ĂȘtre l’avez vous remarquĂ©, mais il n’existe pas de module FormsTestingModule, qui remplacerait le module FormsModule utilisĂ© dans l’application. Mais en y rĂ©flĂ©chissant un peu, Ă  quoi bon ? 😉

Construction du test pas Ă  pas

Structure générale du test

Nous allons procĂ©der comme lors de l’article prĂ©cĂ©dent, c’est Ă  dire qu’on va utiliser un « squelette » et l’alimenter petit Ă  petit :

import { ComponentFixture, TestBed } from '@angular/core/testing';  ①

import { ContactFormComponent } from './contact-form.component';
import { FormGroup } from "@angular/forms";

describe('ContactFormComponent', () => {  ②
  let component: ContactFormComponent;  ⑱
  let contactForm: FormGroup;
  let fixture: ComponentFixture<ContactFormComponent>;

  beforeEach(async () => {  ④
    // todo
  });

  // -- tests -- //

  it('', () => {});  â‘€

  it('', () => {});

  afterEach(async () => {  â‘„
    // todo
  });

});

Snippet 4.2 – contact-form.component.spec.ts, le squelette des tests.

  • ① → import des dĂ©pendances, du framework et celles du composant Ă  tester.
  • ② → dĂ©claration de la suite de tests du composant Ă  tester.
  • ⑱ → dĂ©claration des variables dont la portĂ©e sera limitĂ©e au bloc describe de premier niveau (celui juste avant) :
    – component : l’instance du composant.
    – contactForm : l’instance du formulaire.
    – fixture : on peut voir cet objet comme une « enveloppe » autour de l’instance du composant, qui permet d’accĂ©der Ă  ses mĂ©thodes, propriĂ©tĂ©s etc.
  • ④ → fonction callback appelĂ©e avant chaque test unitaire.
  • â‘€ → les tests unitaires eux mĂȘmes.
  • â‘„ → fonction callback appelĂ©e aprĂšs chaque test unitaire.

Construire l’environnement de test

Contrairement Ă  l’article prĂ©cĂ©dent, on ne va pas pouvoir faire l’Ă©conomie du module de test fourni par Angular, car cette fois-ci, nous avons des dĂ©pendances injectĂ©es dans notre composant. On ne va pas non plus initialiser une application Angular complĂšte, ça serait beaucoup trop lourd. On va donc s’appuyer sur un module de test qui va se charger de faire la « glue » entre tous les composants et services donc nous avons besoin. Ce module est la classe TestBed.

On peut voir la classe TestBed comme un support pour nos tests. Au lieu d’instancier le framework, on va utiliser une instance de la classe TestBed qui va se charger de coordonner toutes les dĂ©pendances du composant Ă  tester : services, directives, pipes etc. Bien sĂ»r il y aura quelques manipulations Ă  effectuer manuellement (par exemple TestBed n’exĂ©cute pas la mĂ©thode « ngOnInit » tout seul, il faut le faire manuellement), mais nous reverrons ça plus tard.

TestBed va donc crĂ©er un module de test, et comme tout module Angular il faut le configurer. Bonne nouvelle pour nous, la configuration de ce module reprend la mĂȘme logique que les modules « classiques » d’Angular, telle qu’on peut la voir dans le fichier app.module.ts (snippet 4.3).

@NgModule({
  declarations: [AppComponent],
  imports: [
    AppRoutingModule,
    BrowserModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Snippet 4.3 – Configuration d’un module sous Angular.

Ainsi il faudra aussi s’assurer qu’Ă  chaque test une nouvelle instance de la classe TestBed (le module de test) soit disponible, la fonction beforeEach est donc l’endroit idoine pour arriver Ă  nos fins 😉. CrĂ©ons donc notre premier callbak beforeEach :

beforeEach(async () => {
  await TestBed.configureTestingModule({  ①
    imports: [],  ②
    declarations: [ContactFormComponent], ⑱
    providers: [],  ④
    schemas: []  â‘€
  }).compileComponents();

  fixture = TestBed.createComponent(ContactFormComponent);  â‘„
  component = fixture.componentInstance;  ⑩
  contactForm = component.contactForm;  ⑧
  fixture.detectChanges();  ⑹
});
  • ① → configure et crĂ©e le module de test qui va nous permettre de tester le composant. L’objet passĂ© en paramĂštre est de type TestModuleMetadata.
  • ② → la propriĂ©tĂ© « imports » est utilisĂ©e pour importer les modules requis par le composant, mais ici nous n’en avons pas besoin pour le moment.
  • ⑱ → la propriĂ©tĂ© « declarations » est utilisĂ©e pour dĂ©clarer les composants, les directives et les pipes requis par le composant testĂ©. Pour le moment on ne dĂ©clare que le minimum, Ă  savoir le composant Ă  tester lui mĂȘme.
  • ④ → la propriĂ©tĂ© « providers » permet de configurer l’injection de dĂ©pendance, de la mĂȘme maniĂšre que tout module Angular. Plus tard c’est ici qu’on dĂ©clarera des « fakes services » ou « mocks services ».
  • â‘€ → la propriĂ©tĂ© « schemas » (facultative) permet de configurer le schĂ©ma utilisĂ© pour compiler et valider le modĂš le du composant lors des tests. Les plus courants sont CUSTOM_ELEMENTS_SCHEMA et NO_ERRORS_SCHEMA pour autoriser certaines propriĂ©tĂ©s seulement. Par exemple, le schĂ©ma NO_ERRORS_SCHEMA permettra Ă  tout Ă©lĂ©ment qui va ĂȘtre testĂ© d’avoir n’importe quelle propriĂ©tĂ© (Ă  Ă©viter).
    Mais le but de ce document n’Ă©tant pas d’expliciter cette notion, on n’ira pas plus loin.
  • â‘„ → crĂ©ation de la « fixture » du composant (voir plus haut la dĂ©finition).
  • ⑩ → crĂ©ation d’une rĂ©fĂ©rence Ă  l’instance du composant avec une variable qui a une portĂ©e Ă©tendue Ă  toute la suite de tests.
  • ⑧ → mĂȘme chose pour le formulaire du composant, ça Ă©vitera les noms Ă  rallonge dans les tests.
  • ⑹ → la mĂ©thode detectChanges dĂ©clenche un cycle de dĂ©tection des modifications pour le composant; il est nĂ©cessaire de l’appeler aprĂšs avoir initialisĂ© un composant ou modifiĂ© une valeur de propriĂ©tĂ©. AprĂšs avoir appelĂ© detectChanges, les mises Ă  jour du composant seront rendues dans le DOM. En production, Angular possĂšde un mĂ©canisme qui dĂ©termine quand exĂ©cuter la dĂ©tection des modifications, mais il est absent des tests unitaires. C’est pourquoi nous devons le faire frĂ©quemment entre chaque test unitaire.

Tout ceci est bien gentil mais nous n’avons toujours pas rĂ©solu notre problĂšme initial, Ă  savoir la double dĂ©pendance du composant Ă  tester (voir figure 4.2). Pour ceux qui ont suivi, le point ④ nous apprends que la propriĂ©tĂ© « imports » configure l’injection de dĂ©pendances. Parfait, c’est ce dont nous avons besoin. Injectons donc manuellement notre service ApiService et sa dĂ©pendance HttpClientTestingModule :

beforeEach(async () => {
  await TestBed.configureTestingModule({
    imports: [HttpClientTestingModule, FormsModule, ReactiveFormsModule],  ①
    declarations: [ContactFormComponent],
    providers: [ApiService],  ②
    schemas: []
  }).compileComponents();

  // ...
});

Snippet 4.4 – Configuration de l’injection de dĂ©pendance.

  • ① → import du module HttpClientTestingModule, spĂ©cialement taillĂ© pour les tests. Il fait la mĂȘme chose que son cousin HttpClientModule, mais en faisant des requĂȘtes fictives (pour les tests c’est mieux đŸ€­).
  • ② → on « informe » l’injection de dĂ©pendance d’utiliser l’implĂ©mentation rĂ©elle du service ApiService.

On relance les tests, et la magie opĂšre enfin :

Figure 4.3 – On retrouve nos tests 100% fonctionnels.

Peut ĂȘtre l’avez-vous remarquĂ© au point ①, mais en plus du module HttpClientTestingModule, on injecte aussi FormsModule et ReactiveFormsModule. Pourquoi ça ? Rappelez-vous : le composant qu’on teste embarque un formulaire rĂ©actif, qui dĂ©pend de ces 2 modules. Nous devons donc les importer aussi ici.

Bien, l’environnement de test est prĂȘt đŸŸ. Maintenant il faut se poser la question cruciale : que voulons-nous tester ? Pour le moment rien car le service ApiService ne fait rien du tout dans notre composant 😇. RemĂ©dions Ă  cette situation intenable : modifions la mĂ©thode submitForm qui va se servir du service ApiService pour envoyer le message (histoire qu’on n’ai pas fait tout ça pour rien 😎) :

submitForm() {
  if (this.contactForm.valid) {
    this.success = true;
    this.error = false;
  } else {
    this.success = false;
    this.error = true;
  }
}
submitForm() {
    if (this.contactForm.valid) {  ①
      this.apiService.sendEmail({  ②
        recipient: 'toto@toto.com',
        message: this.message.value,
      }).subscribe({
          next: result => {  ⑱
            this.success = true;
            this.error = false;
          },
          error: e => {  ④
            this.success = false;
            this.error = true;
          }
        }
      )
    }
  }
  • ① → on vĂ©rifie que le formulaire est valide, sinon on ne fait rien.
  • ② → on fait appel au service ApiService pour envoyer le message, avec les bons arguments passĂ©s Ă  la mĂ©thode sendEmail.
  • ⑱ → dĂ©finition du callback Ă  exĂ©cuter si la requĂȘte aboutit : affiche du message de confirmation.
  • ④ → dĂ©finition du callback Ă  exĂ©cuter si la requĂȘte renvoie une erreur : affiche du message d’erreur.

À gauche la mĂ©thode telle qu’elle Ă©tait lors du prĂ©cĂ©dent article, Ă  droite les modifications avec le service ApiService. La diffĂ©rence est majeure :

  • Ă  gauche c’est la validitĂ© du formulaire qui dĂ©termine ce qu’on affiche : si le formulaire est valide, on affiche le message de confirmation. Sinon le message d’erreur.
  • Ă  droite, c’est le type de la rĂ©ponse qui dĂ©termine le message Ă  afficher : si on reçoit un « Observable » en erreur, on affiche le message d’erreur. Sinon ce sera le message de confirmation.

Donc Ă  partir de maintenant, quand on clique sur « Envoyer », Angular va tenter de faire une requĂȘte vers http://apiserver.lol, qui va bien sĂ»r Ă©chouer, mais lĂ  n’est pas notre soucis. Nous devons rĂ©parer le fichier de tests, qui ne se sent pas trĂšs bien depuis qu’on a fait le mĂ©nage dans la mĂ©thode submitForm :

Fig 4.4 – On a de nouveau tout cassĂ© 😋.

Si on en croit Jasmine, les messages de confirmation n’apparaissent plus. Rien d’Ă©tonnant Ă  cela, grĂące Ă  l’utilisation du module HttpClientTestingModule, toute requĂȘte Http est interceptĂ©e sans lever la moindre erreur. Du coup le point ⑱ et ④ ne sera jamais exĂ©cutĂ©, quel que soit l’issu de la requĂȘte Http. Comment s’en sortir ? C’est simple, comme toujours se poser les bonnes questions, Ă  savoir qu’est ce que je teste ?

  • je veux m’assurer que mon composant affiche un message de confirmation si le message est envoyĂ©.
  • je veux aussi m’assurer que mon composant affiche un message d’erreur si le message n’est pas envoyĂ©.

Si on analyse de prĂȘt ces 2 phrases, on s’aperçoit qu’on ne parle :

  • ni de requĂȘte Http
  • ni de service ApiService

Eh oui, on ne test pas (encore) ApiService et encore moins les requĂȘtes http d’Angular. Alors que faire ? L’idĂ©e ici est de dĂ©coupler le composant de ses dĂ©pendances embarrassantes, de cette façon elles ne provoqueront plus d’erreurs. On dit qu’on va faire un test en isolation. Mais le composant a quand mĂȘme besoin de ces dĂ©pendances, nous allons les remplacer par des « dĂ©pendances factices »

DĂ©pendance factice

Une « dĂ©pendance factice » est une technique de test qui permet de crĂ©er une fausse implĂ©mentation d’un service dont dĂ©pend le composant Ă  tester (ou d’autres services), plutĂŽt que d’utiliser l’implĂ©mentation rĂ©elle du service.

La simulation d’un service est utile car elle permet d’isoler le comportement du composant testĂ© du comportement des dĂ©pendances sur lesquelles il repose. Cela facilite le test du composant (il est isolĂ©), sans se soucier du comportement des services dont il dĂ©pend. De plus, « mocker » un service permet de contrĂŽler le comportement du service dans le test, ce qui peut ĂȘtre utile pour tester diffĂ©rents scĂ©narios ou conditions d’erreur.

Alors la question que tout le monde se pose : comment fait-on ? Rappelez-vous du point ② du snippet 4.3 : Angular nous donne la possibilitĂ© de configurer l’injection de dĂ©pendance 😎. A partir de lĂ , et faites moi confiance sur ce point, on a deux solutions :

  1. on ré-écrit un object ApiService complet.
  2. on utilise une fonctionnalité offerte par Jasmine : les espions.

Les deux sont possibles, mais nous ne verrons que la seconde solution qui consistera Ă  :

  • crĂ©er et placer un espion sur l’implĂ©mentation rĂ©elle de la dĂ©pendance.
  • informer l’espion de quelle mĂ©thode il doit espionner.
  • et, c’est lĂ  le plus intĂ©ressant, lui dire que faire si la mĂ©thode est appelĂ©e lors du test.
  • injecter l’espion Ă  la place du service rĂ©el.

Evidemment, comme toujours, on va créer un espion par test, nous serons donc avisés de placer sa définition dans un callback beforeEach. Allons y :

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { throwError } from "rxjs";  ①
import { FormGroup, FormsModule, ReactiveFormsModule } from "@angular/forms";

import { ContactFormComponent } from './contact-form.component';
import { HttpClientTestingModule } from "@angular/common/http/testing";
import { ApiService } from "../../services/api.service";

describe('ContactFormComponent', () => {
  let component: ContactFormComponent;
  let contactForm: FormGroup;
  let fixture: ComponentFixture<ContactFormComponent>;
  let apiSpy: any;  ②

  beforeEach(async () => {
    apiSpy = jasmine.createSpyObj<ApiService>('ApiService', ['sendEmail']);  ⑱
    apiSpy.sendEmail.and.returnValue(of(null).pipe(delay(1000)));  ④
    
    await TestBed.configureTestingModule({
      imports: [HttpClientTestingModule, FormsModule, ReactiveFormsModule],
      declarations: [ContactFormComponent],
      providers: [
        {provide: ApiService, useValue: apiSpy}  â‘€
      ],
      schemas: []
    }).compileComponents();

    fixture = TestBed.createComponent(ContactFormComponent);
    fixture.detectChanges();
    component = fixture.componentInstance;
    contactForm = component.contactForm;
  });

  // -- 
});

Snippet 4.5 – CrĂ©ation d’un espion.

Je l’avoue le callback beforeEach commence Ă  prendre du poids, promis on remĂ©diera Ă  tout ça par la suite. En attendant, quelques Ă©clairages :

  • ① → import de l’opĂ©rateur throwError qui va simuler une erreur http.
  • ② → dĂ©claration de l’espion apiSpy qu’on « type » en any pour Ă©viter les ennuis.
  • ⑱ → dĂ©finition de l’espion avec la mĂ©thode jasmine.createSpyObj du framework Jasmine : on espionnera la mĂ©thode « sendEmail »
  • ④ → dĂ©finition du comportement de l’espion quand la mĂ©thode « sendEmail » sera appelĂ©e : il va renvoyer un Observable aprĂšs une seconde de dĂ©lai.
  • â‘€ → on informe le module de test d’utiliser notre espion plutĂŽt que le « vrai » service ApiService en paramĂ©trant l’injection de dĂ©pendances.

On enregistre le tout et on se rue sur les tests : pas de chance ça ne fonctionne toujours pas đŸ˜±.

Ce comportement n’est pas Ă©tonnant : la mĂ©thode submitForm est dĂ©sormais asynchrone puisqu’elle fait appelle au service ApiService qui doit faire requĂȘte Http. Et comme toutes les requĂȘtes Http, on s’attend Ă  recevoir soit un Observable soit une Promise, les deux Ă©tants asynchrones. Et pour se rapprocher encore un peu plus de la rĂ©alitĂ© et comprendre ce que l’on fait, on a insĂ©rĂ© un dĂ©lai d’une seconde avant l’Ă©mission de la rĂ©ponse. C’est pour cette raison que le test n’aboutit pas : le code « n’a pas le temps d’aboutir ».

RemĂ©dions Ă  cet Ă©pineux problĂšme qui en a fait suer plus d’un 😇. Nous allons en quelques sortes forcer le temps Ă  s’Ă©couler plus vite, grĂące Ă  la fonction fakeAsync (fournie par Angular) qui permet de prendre le contrĂŽle du flux temporel. Rien que ça me direz-vous !

Il suffit de rĂ©-Ă©crire le test et d’injecter directement le callback dans l’unique argument de fakeAsync :

import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';  ①
// --

describe('ContactFormComponent', () => {
  let component: ContactFormComponent;
  let contactForm: FormGroup;
  let fixture: ComponentFixture<ContactFormComponent>;
  let apiSpy: any;

  beforeEach(async () => {
    // --
  });

  it('should create the component', () => {
    expect(component).toBeTruthy();
  });

  describe('Form', () => {
    // --
  });

  describe('Component behavior', () => {

    it('shoud display an error message if the email is not sent', fakeAsync(() => {
      contactForm.setValue({message: ''});
      component.submitForm();
      tick(1000);
      expect(component.error).toBeTrue();
      expect(component.success).toBeFalse();
    }));

    fit('shoud display a success message if the email is sent', fakeAsync(() => {  ②
      contactForm.setValue({message: 'Hi there!'});
      component.submitForm();
      tick(1000);   ⑱
      expect(component.error).toBeFalse();
      expect(component.success).toBeTrue();
    }));

  // -- 

  });
});

Analysons un petit peu ce nouveau charabia :

  • ① → importation de 2 nouvelles dĂ©pendances : fakeAsync et tick.
  • ② → injection du callback du test directement dans fakeAsync. On substitue it Ă  fit pour se focaliser uniquement sur ce test pour le moment.
  • ⑱ → on force le passage d’une seconde.

Bingo ! Le test passe à présent :

Figure 4.5 – Premier test asynchrone.

La fonction tick est une mĂ©thode fournie par le framework de test d’Angular qui permet de simuler le passage du temps.
Pour tester un composant avec du code asynchrone tel que des promesses ou des observables, la fonction tick peut ĂȘtre utilisĂ©e pour faire avancer l’horloge virtuelle de l’environnement de test afin de simuler le passage du temps jusqu’Ă  la fin de l’opĂ©ration asynchrone.

Quid des autres tests ? Malheureusement on a toujours un petit récalcitrant :

Figure 4.6 – Le message d’erreur ne s’affiche toujours pas


Mais pourquoi le message d’erreur ne s’affiche t’il pas ? On a pourtant bien utilisĂ© la fonction fakeAsync en conjonction avec tick, mais rien ne se passe comme prĂ©vu ?

C’est bien heureux je dois dire, car rappelez-vous le point ② du snippet 4.5. On a paramĂ©trĂ© la mĂ©thode « sendEmail » du service ApiService pour qu’elle renvoie un Observable aprĂšs un dĂ©lai d’une seconde. En d’autres termes, la requĂȘte aboutit dans tous les cas. Jamais d’erreur. C’est fĂącheux car pour notre test, nous avons justement besoin d’Ă©mettre une erreur !

Bien entendu, nous avions tout prévu en amont et grùce à Jasmine on va pouvoir illustrer la phrase suivante, écrite un peu plus haut :

De plus, « mocker » un service permet de contrÎler le comportement du service dans le test.

Donc pour ce test, uniquement celui-ci, on va changer la valeur de retour de « sendEmail » et lui de renvoyer un « Observable » en erreur :

import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { delay, of, throwError } from "rxjs";  ①

describe('ContactFormComponent', () => {
  let component: ContactFormComponent;
  let contactForm: FormGroup;
  let fixture: ComponentFixture<ContactFormComponent>;
  let apiSpy: any;

  beforeEach(async () => {
    // --
  });

  // --

  describe('Form', () => {
    // --
  });

  describe('Component behavior', () => {

    fit('shoud display an error message if the email is not sent', fakeAsync(() => {
      apiSpy.sendEmail.and.returnValue(throwError(() => new Error('TEST')).pipe(delay(1000)));  ②
      contactForm.setValue({message: 'Hi there!'});
      component.submitForm();
      tick(1000);
      expect(component.error).toBeTrue();
      expect(component.success).toBeFalse();
    }));
  });
});
  • ① → importation de l’opĂ©rateur throwError qui sera utilisĂ© pour gĂ©nĂ©rer un « Observable » en erreur.
  • ② → on redĂ©fini la valeur de retour de l’espion, ici un « Observable » en erreur.

DĂ©finir la valeur de retour de l’espion directement dans le test est une bonne pratique. Elle permet au lecteur de tout de suite connaĂźtre l’origine de l’erreur.

AprĂšs un tel traitement, le test devrait de nouveau fonctionner :

Fig 4.7 – Le retour des Verts â˜ș.

Et voilĂ  ! Que de chemin parcouru depuis le premier article de ce document. Je dois dire que je me perds parfois dans des digressions mais ce n’est jamais pour rien. Pour conclure, voici le code complet du test :

import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { HttpClientTestingModule } from "@angular/common/http/testing";
import { FormGroup, FormsModule, ReactiveFormsModule } from "@angular/forms";
import { delay, of, throwError } from "rxjs";

import { ContactFormComponent } from './contact-form.component';
import { ApiService } from "../../services/api.service";

describe('ContactFormComponent', () => {
  let component: ContactFormComponent;
  let contactForm: FormGroup;
  let fixture: ComponentFixture<ContactFormComponent>;
  let apiSpy: any;

  beforeEach(async () => {
    apiSpy = jasmine.createSpyObj<ApiService>('ApiService', ['sendEmail']);
    await TestBed.configureTestingModule({
      imports: [HttpClientTestingModule, FormsModule, ReactiveFormsModule],
      declarations: [ContactFormComponent],
      providers: [
        {provide: ApiService, useValue: apiSpy}
      ],
      schemas: []
    }).compileComponents();

    fixture = TestBed.createComponent(ContactFormComponent);
    fixture.detectChanges();
    component = fixture.componentInstance;
    contactForm = component.contactForm;
  });

  it('should create the component', () => {
    expect(component).toBeTruthy();
  });

  describe('Form', () => {
    it('should validate the form if « message » field is not empty', () => {
      contactForm.setValue({
        message: 'hello'
      });
      expect(contactForm.valid).toBeTruthy();
    });

    it('should not validate the form if « message » field is empty', () => {
      contactForm.setValue({
        message: ''
      });
      expect(contactForm.valid).toBeFalsy();
    });
  });

  describe('Component behavior', () => {

    beforeEach(() => {
      apiSpy.sendEmail.and.returnValue(of(null).pipe(delay(1000)));
    });

    it('shoud display an error message if the email is not sent', fakeAsync(() => {
      apiSpy.sendEmail.and.returnValue(throwError(() => new Error('TEST')).pipe(delay(1000)));
      contactForm.setValue({message: 'Hi there!'});
      component.submitForm();
      tick(1000);
      expect(component.error).toBeTrue();
      expect(component.success).toBeFalse();
    }));

    it('shoud display a success message if the email is sent', fakeAsync(() => {
      contactForm.setValue({message: 'Hi there!'});
      component.submitForm();
      tick(1000);
      expect(component.error).toBeFalse();
      expect(component.success).toBeTrue();
    }));

    it('shoud not display any message after init', () => {
      expect(component.error).toBeFalse();
      expect(component.success).toBeFalse();
    });

  });
});

Snippet 4.6 – Le test complet du composant ContactForm.

Ce que nous avons appris

  • tester un composant Angular revient souvent Ă  tester ses mĂ©thodes, en laissant de cĂŽtĂ© autant que possible la partie « template ».
  • en utilisant la fonction fakeAsync, il est possible de s’assurer que tous les appels asynchrones sont terminĂ©s avant que les assertions ne soient exĂ©cutĂ©es. Cela empĂȘche le test d’Ă©chouer de maniĂšre inattendue.
  • TestBed est la classe principale du framework de test d’Angular. Elle fournie un environnement de test qui permet de manipuler toutes les autres classes d’Angular, donc les composants bien sĂ»r.