Implémenter des tests dans une application Angular

Cet article fait partie de la série d’articles Angular from Scratch.


Le but de cet article est d’indiquer comment implémenter des tests unitaires dans une application Angular. Les tests peuvent porter sur du code dans la classe d’un composant, d’un service ou le rendu HTML à partir d’un template. On indiquera quelques méthodes pour mocker des objets, lancer des évènements ou vérifier que des exécutions se sont correctement déroulées.

Comment implémenter un test ?

Lorsqu’on crée une application Angular avec le CLI, il est directement possible d’exécuter les tests en utilisant Karma qui est un composant permettant d’exécuter des tests (i.e test-runner).

Par défaut, lorsqu’on crée un objet Angular avec le CLI, un fichier <nom de l'objet>.spec.ts est créé de façon à pouvoir implémenter des tests (voir Création d’un composant pour avoir un exemple).

Si on considère un composant nommé Example. On peut créer ce composant en exécutant la commande suivante:

ng g c Example

Parmi les fichiers créés se trouve un fichier nommé example.component.spec.ts. Ce fichier contient le squelette d’un test, par exemple:

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ExampleComponent } from './example.component';

describe('ExampleComponent', () => {
  let component: ExampleComponent;
  let fixture: ComponentFixture<ExampleComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ ExampleComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(ExampleComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

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

Même si ce test n’effectue pas de tests pertinents, il peut être exécuté par Karma en exécutant la commande suivante:

ng test 

Un browser s’ouvre pour afficher une page similaire à celle-ci:

Dans l’exemple plus haut, le code de test utilise le framework Jasmine qui permet de faciliter l’implémentation de tests unitaires. Quelques détails sur ce code:

  • describe(): une suite de tests concernant un composant peut être implémentée à l’intérieur d’une fonction describe(). Cette fonction est exécutée par Karma au moment de l’exécution des tests. La syntaxe générale de cette méthode est:
    describe(<description du test>, <lambda comportant les tests>);
    

    Dans la lambda se trouve l’ensemble des fonctions permettant d’exécuter les tests. A l’intérieur de la lambda, les règles de portée de variable s’appliquent comme dans du code Javascript habituel (voir le scope des variables en Javascript).

  • beforeEach() et beforeEach(async)sont exécutées avant chaque exécution d’un test unitaire.
  • it() correspond à un test unitaire.
Comment lancer les tests avec Firefox à la place de Chrome ?

Par défaut, à l’exécution de la commande ng test, le browser Chrome est lancé. Pour lancer Firefox, il faut:

  1. Installer le package karma-firefox-launcher en exécutant:
    npm install karma-firefox-launcher --save-dev
    
  2. Configurer Karma en modifiant le fichier de configuration karma.conf.js et en rajoutant l’utilisation du plugin karma-firefox-launcher:
    module.exports = function (config) {
      config.set({
        basePath: '',
        frameworks: ['jasmine', '@angular-devkit/build-angular'],
        plugins: [
          require('karma-jasmine'),
          require('karma-chrome-launcher'),
          require('karma-firefox-launcher'),
          require('karma-jasmine-html-reporter'),
          require('karma-coverage'),
          require('@angular-devkit/build-angular/plugins/karma')
        ],
        client: {
          jasmine: {
          },
          clearContext: false 
        },
        jasmineHtmlReporter: {
          suppressAll: true 
        },
        coverageReporter: {
          // ...
        },
        // ...
      });
    };
    
    
  3. Indiquer au runner Karma de lancer Firefox plutôt que Chrome en modifiant la configuration browsers dans karma.conf.js:
    module.exports = function (config) {
      config.set({
        basePath: '',
        frameworks: ['jasmine', '@angular-devkit/build-angular'],
        plugins: [
          require('karma-jasmine'),
          require('karma-chrome-launcher'),
          require('karma-firefox-launcher'),
          require('karma-jasmine-html-reporter'),
          require('karma-coverage'),
          require('@angular-devkit/build-angular/plugins/karma')
        ],
        client: {
          jasmine: {
          },
          clearContext: false 
        },
        jasmineHtmlReporter: {
          suppressAll: true 
        },
        coverageReporter: {
          // ...
        },
        reporters: ['progress', 'kjhtml'],
        port: 9876,
        colors: true,
        logLevel: config.LOG_INFO,
        autoWatch: true,
        // browsers: ['Chrome'],
        browsers: ['Firefox'],
        singleRun: false,
        restartOnFileChange: true,
        files: [
          'src/script.js'
        ]
      });
    };
    

Comment débugger un test ?

On peut débugger un test de la même façon qu'à l'exécution (cf. Comment débugger une application Angular ?):

  1. Utiliser fdescribe() ou fit() pour n'exécuter qu'un seul test.
  2. Lancer Karma en exécutant ng test. Il est possible de débugger en pas à pas avec le browser en affichant les outils de développement:
  3. Pour afficher les outils de développement dans un browser:
    • Sous Firefox: on peut utiliser la raccourci [Maj] + [F7] (sous MacOS: [⌥] + [⌘] + [Z], sous Linux: [Ctrl] + [Maj] + [Z]) ou en allant dans le menu "Outils" ⇒ "Développement web" ⇒ "Débogueur".
    • Sous Chrome: utiliser le raccourci [F12] (sous MacOS: [⌥] + [⌘] + [I], sous Linux: [Ctrl] + [Maj] + [I]) puis cliquer sur l'onglet "Sources". A partir du menu, il faut aller dans "Afficher" ⇒ "Options pour les développeurs" ⇒ "Outils de développement".
  4. Dans l'onglet "Debugger" dans Firefox ou "Sources" dans Chrome, il faut déplier le nœud
    webpacksrcapp
    ou
    webpack://.srcapp
  5. Il est possible de placer des points d'arrêt en cliquant à coté de la ligne:
  6. On peut débugguer si on recharge la page avec [F5]:

    Ensuite, on peut taper:

    • [F8] pour relancer l'exécution jusqu'au prochain point d'arrêt,
    • [F10] pour exécuter la ligne de code sans entrer dans le corps des fonctions exécutées
    • [F11] pour exécuter la ligne de code en rentrant dans le corps des fonctions exécutées.

    Dans le débugger, on peut accéder à d'autres outils pour vérifier le contenu d'une variable, afficher la pile d'appels ou placer des points d'arrêts lorsque des évènements surviennent:

Implémentation des tests

Lorsqu'on lance ng test, tous les tests de l'application sont lancés. Par défaut, les tests sont implémentés dans des fichiers dont le nom est du type *.spec.ts. On peut modifier cette configuration dans le fichier tsconfig.spec.json (ce fichier permet de configurer les fichiers utilisés dans le cadre des tests):

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/spec",
    "types": [
      "jasmine"
    ]
  },
  "files": [
    "src/test.ts",
    "src/polyfills.ts"
  ],
  "include": [
    "src/**/*.spec.ts",
    "src/**/*.d.ts"
  ]
}

describe()

Chaque fichier de test doit comporter une fonction describe() permettant d'implémenter une suite de tests unitaires, par exemple, pour une classe donnée.

Il est possible d'imbriquer les fonctions describe():

describe('main test suite', () => {
  describe('more precise test suite', () => {
    // ...
  });
});

Si on utilise this dans le code de describe(), c'est pour désigner le contexte global au sens javascript (cf. scope).

En imbriquant les méthodes describe(), on peut partager des variables, par exemple:

describe('Outer test suite', () => {
  let outerVar = 'outer';

  describe('Inner test suite 1', () => {
    let innerVar1 = 'inner';

    it('test 1', () => {      
      console.log(outerVar);
      console.log(innerVar1);
    });
  });

  describe('Inner test suite 2', () => {
    let innerVar2 = 'inner';

    it('test 2', () => {
      console.log(outerVar);
      console.log(innerVar2);
    });
  });
});

Pour éviter d'exécuter tous les tests et n'exécuter que les tests se trouvant dans un seul fichier *.spec.ts, il suffit de renommer la méthode describe() concernée en:

fdescribe('...', () => {
  // ...
});

Si une suite de tests est renommée en fdescribe(), seule cette suite sera exécutée.

Pour ne pas exécuter une suite de tests, il faut renommer la méthode describe() concernée en:

xdescribe('...', () => {
  // ...
});

it()

Cette méthode correspond à un test unitaire. Il peut y en avoir plusieurs dans une suite de tests implémentée avec describe():

describe('ExampleComponent', () => {
  it('test 1', () => {
    // ...
  });

  it('test 2', () => {
    // ...
  });

  it('test 3', () => {
    // ...
  });
});

Pour limiter l'exécution à une seule méthode it(), on peut la renommer en:

fit('...', () => {
  // ...
});

Pour ne pas exécuter le test dans une méthode it(), il faut la renommer en:

xit('...', () => {
  // ...
});

Setup et Teardown

Les méthodes suivantes permettent d'instancier, de configurer ou de détruire des objets utilisés lors de l'exécution des tests. Ces méthodes sont exécutées soit avant ou près tous les tests, soit avant ou après chaque test:

  • beforeEach(): permet d'exécuter un même code avant l'exécution de chaque test.
  • afterEach(): permet d'exécuter un même code après l'exécution de chaque test.
  • beforeAll(): permet d'exécuter du code avant d'exécuter les tests dans la méthode describe().
  • afterAll(): permet d'exécuter du code après l'exécution des tests dans la méthode describe().

Ces méthodes sont à implémenter à l'intérieur d'une méthode describe().

Stopper l'exécution ou faire échouer un test

Lors d'un appel à it(), fit() ou xit(), on peut exécuter les méthodes suivantes:

  • pending(): permet de marquer un test en attente. L'exécution ne mènera pas à une erreur quelque soit les résultats du test.
  • fail(): indique une erreur lors de l'exécution du test.

Vérification des résultats (espions)

Pour vérifier le contenu d'objets en les comparant avec une valeur attendue, on peut utiliser la méthode expect():

  • expect(<objet à tester>).toBeTruthy(): vérifier qu'une variable contient une valeur. Pour être plus précis, cet opérateur teste si un objet est égal à true en utilisant l'opérateur type coercion !!. Il ne faut confondre toBeTruthy() avec toBeUndefined() ou toBeNull().
  • expect(<objet à tester>).toBeUndefined(): pour comparer si un objet est égal à Undefined.
  • expect(<objet à tester>).toBeNull(): pour comparer si un objet est égal à Null.
  • expect(<objet à tester>).toBe(<valeur attendue>): pour vérifier si des objets ont des valeurs égales (pour les types primitifs) ou sont les mêmes. La comparaison utilisée est ===.
  • expect(<objet à tester>).not.toBe(<valeur non attendue>): pour vérifier qu'une variable ne correspond pas à un autre objet. La comparaison utilisée est !==.
  • expect(<objet à tester>).toEqual(<valeur attendue>): pour comparer par rapport à une valeur attendue. La comparaison est effectuée par valeur, si des objets différents ont les mêmes valeurs alors toEqual() renverra true. Il ne faut confondre cette fonction avec toBe() qui renvoie false si des objets sont de même valeur mais différents en mémoire.
  • expect(<objet à tester>).toBeTrue(): pour comparer si une valeur est true. La comparaison utilisée est === true.
  • expect(<objet à tester>).toBeLessThan(<valeur numérique>): pour vérifier si une valeur est inférieure à une valeur particulière.
  • expect(<objet à tester>).toBeGreaterThan(<valeur numérique>): pour vérifier si une valeur est supérieure à une valeur particulière.

Pour vérifier que des fonctions d'un objet ont été appelées:

  • Appeler spyOn(<objet à espionner>, '<nom de la fonction de l'objet à vérifier>'); pour indiquer à Jasmine qu'on souhaite espionner la méthode d'un objet.
  • Pour effectuer les vérifications:
    • Qu'une fonction a été exécutée une fois: expect(<fonction à vérifier sous la forme obj.function>).toHaveBeenCalled();
    • Qu'une fonction a été exécutée un certain nombre de fois: expect(<...>).toHaveBeenCalledTimes(<nombre d'appels attendu>);
    • Qu'une fonction a été appelée avec des arguments particuliers: expect(<...>).toHaveBeenCalledWith(<arguments attendus>);
  • Pour indiquer n'importe quel argument correspondant à un type particulier:
    jasmine.any(<type attendu>)
    

    Par exemple:
    expect(<objet à espionner>).toHaveBeenCalledWith(jasmine.any(Number)); permet de tester un argument de type Number.

  • Pour accéder aux informations stockées lorsqu'une fonction est espionnée:
    obj.<fonction à espionner>.calls
    

Créer un mock

Pour créer un espion ou un objet mock (pour lequel on peut implémenter un comportement particulier):

instanceObj = jasmine.createSpyObj('<nom de la variable>', ['<fonction à définir dans l'espion>', ...]);

Ou

instanceObj = jasmine.createSpyObj<type objet espion>('<nom de la variable>', ['<fonction à définir dans l'espion>', ...]);
  

Le but d'un mock est de l'utiliser en tant qu'argument de fonction ou de constructeur d'une classe de façon à éviter d'utiliser l'implémentation réelle dont l'utilisateur peut être plus contraignante dans le cadre de tests.

Vérifier qu'une fonction existe dans un espion:

expect(<objet espion>.<fonction>).toBeDefined();

Par exemple, pour implémenter un comportement particulier pour le mock itemRepositoryService:

itemRepositoryService = jasmine.createSpyObj('itemRepositoryService', [ 'addNewItem', 'findItemFromId', 'findItem' ]);
// Configurer un comportement particulier dans le mock
itemRepositoryService.addNewItem.and.returnValue(5);
itemRepositoryService.findItemFromId.and.returnValue(undefined);
itemRepositoryService.findItem.and.returnValue(undefined);

addNewItem, findItemFromId, findItem sont des fonctions de l'objet itemRepositoryService.

Utilisation d'un TestBed

Un "TestBed" (i.e. banc d'essai) permet de tester un composant de façon plus complète en donnant la possibilité d'interagir avec d'autres objets:

  • Permettre l'injection de services dans le composant.
  • Tester le composant avec des composants enfants.
  • Tester le code de la classe du composant avec son template.

L'objet "TestBed" (dans @angular/core/testing) s'utilise sous forme d'un singleton:

  • TestBed.configureTestingModule(<configuration d'un module>): permet de configurer le "TestBed" avec les paramètres d'un module.
  • TestBed.createComponent(<type du composant à créer>): permet de créer un objet ComponentFixture pour tester un composant avec l'injection de dépendances.
  • TestBed.inject(<type de l'object à injecter>): permet d'injecter un objet dans la configuration du "TestBed".

Par exemple si on considère le composant suivant:

@Component({
  ...
})
export class FirstComponent {
  constructor(public itemService: ItemService) {}
}

Le service ItemService est:

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

Avec l'injecteur suivant, le service est injecté au niveau de l'application (voir Injection de dépendances dans une application Angular pour plus de détails):

@Injectable({
  providedIn: 'root'
})

Ainsi pour injecter le service ItemService dans le composant lors des tests, on peut utiliser le "TestBed" de cette façon:

describe('FirstComponent', () => {
  let component: FirstComponent;
  let fixture: ComponentFixture<FirstComponent>;

  beforeEach(() => {
    fixture = TestBed.createComponent(FirstComponent);
    component = fixture.componentInstance;
  });

  it('should create', () => {
    // La variable component contient une instance du composant FirstComponent
  });
})

Si l'injecteur du service se trouve au niveau du module:

@Injectable()
export class ItemService {}
 
@NgModule({
  providers: [ ItemService ]
})
export class CustomModule {}

ou au niveau du composant:

@Component({
  ...
  providers: [ ItemService ]
})
export class FirstComponent {
  constructor(public itemService: ItemService) {
  }
}

On peut imiter la configuration de l'injection avec le TestBed:

describe('FirstComponent', () => {
  let component: FirstComponent;
  let fixture: ComponentFixture<FirstComponent>;

  beforeEach(() => {
    // Configuration similaire à celle dans un module
    TestBed.configureTestingModule({
      declarations: [FirstComponent],
      providers: [ItemService]
    });

    fixture = TestBed.createComponent(FirstComponent);
    component = fixture.componentInstance;
  });

  it('should create', () => {
    // La variable component contient une instance du composant FirstComponent
  });
});

NO_ERRORS_SCHEMA/CUSTOM_ELEMENTS_SCHEMA

Si le template d'un composant comporte une erreur, cette erreur peut faire échouer un test. Dans les cas où on ne souhaite tester que la classe du composant, l'échec du test dû aux problèmes dans le template peut empêcher au test d'aboutir. Une solution est de configurer le module dans le "TestBed" avec NO_ERRORS_SCHEMA ou CUSTOM_ELEMENTS_SCHEMA. Ces éléments de paramétrage permettent de définir un schéma dans un module qui autorise des éléments HTML ou des propriétés avec des noms particuliers:

  • NO_ERRORS_SCHEMA: autorise n'importe quel nom d'éléments ou de propriétés. Ce paramétrage doit être utilisé avec précaution puisqu'il cache toutes les erreurs dans le template.
  • CUSTOM_ELEMENTS_SCHEMA: autorise les éléments ou les propriétés inconnus s'ils contiennent le caractère "-".

Par exemple, si on considère le composant suivant:

Template
<p>simple works!</p>
<unknown></unknown>
Classe du composant
@Component({
  selector: 'app-simple',
  templateUrl: './simple.component.html'
})
export class SimpleComponent {}

Avec le test suivant (implémentation par défaut):

describe('SimpleComponent', () => {
  let component: SimpleComponent;
  let fixture: ComponentFixture<SimpleComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ SimpleComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(SimpleComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

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

Une erreur se produira à cause de l'élément "unknown" qui ne correspond pas à un élément connu:

ERROR: 'NG0304: 'unknown' is not a known element:
1. If 'unknown' is an Angular component, then verify that it is part of this module.
2. To allow any element add 'NO_ERRORS_SCHEMA' to the '@NgModule.schemas' of this component.'

On peut configurer le module avec 'NO_ERRORS_SCHEMA':

import { NO_ERRORS_SCHEMA } from '@angular/core';
...

beforeEach(async () => {
  await TestBed.configureTestingModule({
    declarations: [ SimpleComponent ],
    schemas: [ NO_ERRORS_SCHEMA ]
  })
  .compileComponents();
});

L'erreur ne se produit plus à l'exécution du test:

✔ Browser application bundle generation complete.
Firefox 78.0 (Linux aarch64): Executed 1 of 8 (skipped 7) SUCCESS (0.067 secs / 0.029 secs)
TOTAL: 1 SUCCESS

Si on remplace NO_ERRORS_SCHEMA par CUSTOM_ELEMENTS_SCHEMA:

beforeEach(async () => {
  await TestBed.configureTestingModule({
    declarations: [ SimpleComponent ],
    schemas: [ CUSTOM_ELEMENTS_SCHEMA ]
  })
  .compileComponents();
});

L'erreur se produit de nouveau. Si on modifie le nom de l'élément dans le template du composant en introduisant le caractère "-", l'erreur ne se produit plus:

<p>simple works!</p>
<un-known></un-known>

Tester le rendu HTML

On peut tester le contenu du code HTML rendu par le template du composant. Le contenu HTML est requêtable en Javascript par l'intermédiaire du DOM de la même façon qu'une page HTML classique. La différence est qu'il faut prendre en compte les évènements Angular pour effectuer les requêtes au bon moment (voir Fonctionnement de la détection de changement pour plus de détails).

Ainsi pour que les bindings du template soient exécutés, il faut déclencher la détection de changements en exécutant la ligne suivante avant d'effectuer le test:

fixture.detectChanges();

La détection de changements n'est pas nécessaire si le contenu statique du template est requêté.

Le requêtage du code HTML peut se faire en utilisant les fonctions Javascript element.querySelector() ou element.querySelectorAll().

Par exemple pour détecter un lien dans le code HTML suivant:

<p id="itemCountLabel">Item count: {{itemCount}}</p>
<p id="itemNameLabel">Item name: {{itemName}}</p>
<p id="itemIdLabel">Item ID: {{itemId}}</p>

On peut implémenter un test de cette façon:

describe('FirstComponent', () => {
  let component: FirstComponent;
  let fixture: ComponentFixture<FirstComponent>;

  beforeEach(() => {
    fixture = TestBed.createComponent(FirstComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should display item name', () => {
    expect(fixture.nativeElement.querySelector('#itemNameLabel').textContent).toContain('Item name: element1');
  });
});

fixture.nativeElement est de type ElementRef et permet d'accéder à l'objet du DOM.

Syntaxe à utiliser avec querySelector()

Pour effectuer les requêtes avec element.querySelector() ou element.querySelectorAll(), il faut utiliser une syntaxe particulière:

Type de l'élément requêté Syntaxe Exemple
Type d'un élément HTML "<type de l'élément HTML>" Pour requêter <p></p>:
element.querySelector("p")
ID d'un élément "#<Id de l'élément>" Pour requêter <p id="itemId">Text content</p>:
element.querySelector("#itemId")
Classe CSS utilisée par un élément ".<classe CSS sur un élément HTML>" Pour requêter <p class="titleStyle"></p>:
element.querySelector(".titleStyle")
Elément ayant un attribut particulier "<élément HTML>[<attribut attendu>]" Pour requêter <p data-src></p>:
element.querySelector("p[data-src]")
Chercher suivant la valeur d'un attribut "<élément HTML>[<attribut attendu>='<valeur de l'attribut>']" Pour requêter <p data-active="1"></p>:
element.querySelector("p[data-active='1']")

En espaçant plusieurs requêtes avec un espace, on peut indiquer des conditions d'imbrications d'éléments.

Par exemple, pour requêter un élément p se trouvant dans undiv, on pourra exécuter:

element.querySelector("div p");

En espaçant avec une virgules, l'opérateur de requête est le "ou" logique.

Par exemple, pour requêter les objets p utilisant la classe CSS itemClass1 et itemClass2:

element.querySelector("p.itemClass1, p.itemClass2");

Enfin, il est possible de cumuler les conditions en requêtant suivant plusieurs critères, par exemple:
"p.itemClass1.itemClass2" pour requêter un élément p utilisant les classes CSS itemClass1 et itemClass2.

debugElement vs nativeElement

On peut accéder à l'objet brut du DOM avec la propriété ComponentFixture<T>.nativeElement. La propriété ComponentFixture<T>.debugElement permet d'encapsuler l'objet du DOM et de l'enrichir dans un objet DebugElement.

debugElement permet de requêter dans un arbre d'éléments de type DebugElement ou DebugNode (DebugElement dérive de DebugNode):

DebugElement expose des accesseurs:

  • properties pour accéder aux propriétés des éléments utilisées dans le cadre de bindings.
  • attributes pour accéder aux attributs HTML.
  • classes pour obtenir les classes CSS.
  • styles pour accéder aux styles définis de façon inline dans un élément HTML.
  • childNodes pour obtenir un tableau de DebugNode contenant les éléments enfant.
  • children pour obtenir les éléments enfants directs sous forme d'un tableau de DebugElement.

Comme pour querySelector, debugElement peut être utilisé pour effectuer des requêtes parmi les objets du DOM:

  • query(): permet d'obtenir le premier élément satisfaisant la condition de la requête.
  • queryAll(): retourne une liste d'éléments satisfaisant la condition de la requête.
  • queryAllNodes(): renvoie une liste d'objets de type DebugNode permettant de circuler dans l'arbre des objets.

La condition de la requête peut être indiquée avec un prédicat satisfaisant l'interface :

interface Predicate<T> {
  (value: T): boolean
}

On peut s'aider de By pour définir ce prédicat:

  • By.all(): tous les éléments testés répondent à la condition.
  • By.css(): permet d'indiquer une condition en testant un sélecteur CSS. Sélecteur CSS ne veut pas dire qu'on ne peut requêter que par les classes CSS. On peut effectuer des requêtes par:
    • Elément HTML: par exemple By.css('h1') pour requêter un élément <h1></h1>; By.css('button') pour requêter un bouton <button></button> etc...
    • Une classe CSS: par exemple By.css('.box') pour requêter la classe CSS box.
    • Un élément avec un identifiant: par exemple By.css('#elementId') pour requêter un élément ayant l'ID elementId.
  • By.directive(): pour filtrer des directives en indiquant explicitement leur type. Cette condition peut être utilisée pour tester des composants enfant (puisqu'un composant est un cas particulier de directive).

On peut aussi définir des prédicats particuliers en utilisant une lambda:

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

fixture.debugElement.query((debugElement: DebugElement) => { 
  return debugElement.name === 'li'; 
})

Tester le rendu d'un évènement sur le template

En plus de tester le contenu statique du template d'un composant, on peut vérifier le rendu lorsqu'un évènement survient.

Par exemple, si on considère le composant suivant comportant:

  • une zone input pour indiquer le nom de l'item à rajouter et
  • un bouton: le click sur le bouton permet de déclencher la méthode addItem() et de vider le contenu de la zone input.

L'implémentation du composant est:

Template
<div>
  <label>Item name is: 
    <input #content/>
  </label>
  <button (click)="addItem(content.value); content.value=''">Add new item</button>
</div>
Classe du composant
@Component({
  selector: 'app-example',
  templateUrl: './example.component.html'
})
export class ExampleComponent {
  public Items: Array<Item>;

  constructor(private itemService: ItemService) {   
  }

  addItem(itemName: string): void {
    this.itemService.addItem(itemName);
  }
}

On souhaite implémenter un test pour vérifier qu'en cas de click sur le bouton:

  • La méthode addItem() est déclenchée avec le bon argument
  • Le contenu de la zone input est vidé.

L'implémentation du test pourrait être:

describe('ExampleComponent', () => {
  let fixture: ComponentFixture<ExampleComponent>;
  let itemService: any;

  beforeEach(() => {
    itemService = jasmine.createSpyObj(['addItem']);

    TestBed.configureTestingModule({
      declarations: [ ExampleComponent, ItemComponent ],
      providers: [
        { provide: ItemService, useValue: itemService }
      ]
    })

    fixture = TestBed.createComponent(ExampleComponent);
    fixture.detectChanges();
  });

  fit('when triggering button click then item shall be added and input content shall be cleared', () => {    
    // On requête les éléments input et button
    let inputElement = fixture.debugElement.query(By.css('input'));
    let buttonElement = fixture.debugElement.query(By.css('button'));

    expect(inputElement).toBeTruthy();
    expect(buttonElement).toBeTruthy();

    // On entre une valeur dans la zone input
    let newItemName = 'New item';
    inputElement.nativeElement.value = newItemName;

    // On indique à Jasmine qu'on veux surveiller la méthode addItem() du composant
    spyOn(fixture.componentInstance, 'addItem');

    // On déclenche un click sur le bouton
    buttonElement.triggerEventHandler('click', null);
    
    // On déclenche la détection de changement pour que les bindings soient exécutés
    fixture.detectChanges();

    // On vérifie que la méthode addItem() a été appelée et que le contenu du l'input est vide
    expect(fixture.componentInstance.addItem).toHaveBeenCalledOnceWith(newItemName);
    expect(inputElement.nativeElement.value).toBe('');
  });
});

Mocker les éléments ou attributs entraînant des erreurs dans le template

Certains éléments ou attributs sur des éléments dans le template peuvent entraîner des erreurs dans les tests.

Par exemple si un test est exécuté avec le template suivant:

<unknown></unknown>

On obtiendra une erreur:

ERROR: 'NG0304: 'unknown' is not a known element:
1. If 'unknown' is an Angular component, then verify that it is part of this module.
2. To allow any element add 'NO_ERRORS_SCHEMA' to the '@NgModule.schemas' of this component.'

On l'a vu précédemment, on peut corriger ce problème en utilisant NO_ERRORS_SCHEMA ou CUSTOM_ELEMENTS_SCHEMA. Le gros problème de cette solution est qu'elle empêche de voir les autres problèmes dans le template.

Une solution est de mocker l'élément inconnu en utilisant une directive. L'intérêt de la directive est qu'elle n'a pas de template par rapport à un composant, elle est donc plus simple à implémenter. Ensuite, il suffit de paramétrer différemment le paramètre selector dans le cas d'un élément ou d'un attribut.

Par exemple, si on crée la directive suivante:

@Directive({
  selector: 'unknown'
})
class UnknownDirective {
}

On peut l'ajouter dans la configuration du TestBed:

describe('ExampleComponent', () => {
  let fixture: ComponentFixture<ExampleComponent>;

  @Directive({
    selector: 'unknown'
  })
  class UnknownDirective {
  }

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [ ExampleComponent, UnknownDirective ]
    });

    fixture = TestBed.createComponent(ExampleComponent);
    fixture.detectChanges();
  });

  it('should create', () => {    
       // ...
  });
});

L'exécution du test ne produira plus l'erreur.
Dans le cas d'un attribut, par exemple:

<span unknown></span>

il suffit de paramétrer le selector de la directive:

@Directive({
    selector: '[unknown]'
})
class UnknownDirective {
}

Si l'attribut a une valeur, il faut créer un paramètre d'entrée @Input() dans la directive.

L'attribut routerLink permet de faire appel au router pour passer d'une vue à l'autre.
Son utilisation dans le template peut mener à des erreurs lors de l'exécution des tests, par exemple si on considère le composant suivant:

Template
<div *ngFor="let item of Items"> 
  {{'ID: ' + item.id + '/Name: ' + item.name}} 
  <a routerLink="/detail/{{item.id}}"> - Item {{item.id}}</a>
</div> 
Classe du composant
@Component({
  selector: 'example',
  templateUrl: './example.component.html'
})
export class ExampleComponent {
  public Items: Array<Item>;

  constructor(private itemService: ItemService) { 
    this.Items = itemService.getItems();
  }

  addItem(itemName: string): void {
    this.itemService.addItem(itemName);
  }
}

On peut avoir des erreurs du type:

ERROR: 'NG0303: Can't bind to 'routerLink' since it isn't a known property of 'a'.'

Pour éviter cette erreur, on peut créer une directive (comme indiqué précédemment) avec pour paramètre selector [routerLink] et un paramètre @Input() nommé routerLink:

@Directive({
  selector: '[routerLink]',
  host: { '(click)': 'onClick()'}
})
class RouterLinkDirectiveStub {
  @Input('routerLink') routerLinkValue: any;
  linkValue: any = null;

  onClick() {
    this.linkValue = this.routerLinkValue;
  }
}

On s'abonne à l'évènement click pour affecter le membre linkValue si un click est effectué.

On implémente un test en ajoutant la directive RouteLinkDirectiveStub dans le TestBed:

describe('ExampleComponent', () => {
  let fixture: ComponentFixture<ExampleComponent>;
  let itemService: any;

  // Implémentation de la directive
  @Directive({
    selector: '[routerLink]',
    host: { '(click)': 'onClick()'}
  })
  class RouterLinkDirectiveStub {
    @Input('routerLink') routerLinkValue: any;
    linkValue: any = null;

    onClick() {
      this.linkValue = this.routerLinkValue;
    }
  }

  beforeEach(() => {
    // Configuration du service injecté dans ExampleComponent
    itemService = jasmine.createSpyObj(['addItem', 'getItems']);
    itemService.getItems.and.returnValue([
      { id: 0, name: 'item 0'},
      { id: 1, name: 'item 1'},
      { id: 2, name: 'item 2'},
      { id: 3, name: 'item 3'},
    ]);

    // Configuration du TestBed
    TestBed.configureTestingModule({
      declarations: [ ExampleComponent, RouterLinkDirectiveStub ],
      providers: [
        { provide: ItemService, useValue: itemService }
      ]
    })

    fixture = TestBed.createComponent(ExampleComponent);
    fixture.detectChanges();
  });

  fit('when clicking routerLink then link shall be properly set', () => {    
    // On requête le template pour récupérer les éléments a et les directives RouterLinkDirectiveStub
    let linkElements = fixture.debugElement.queryAll(By.css('a'));
    let routerLinkStubs = fixture.debugElement.queryAll(By.directive(RouterLinkDirectiveStub));    

    expect(linkElements.length).toBe(4);
    expect(routerLinkStubs.length).toBe(4);
    
    // On ne teste que le premier élément
    let firstLinkElement = linkElements[0];
    let firstRouterLinkStub = routerLinkStubs[0];
    expect(firstRouterLinkStub).toBeTruthy();

    // On déclenche un click sur le lien
    firstLinkElement.triggerEventHandler('click', null);

    // On vérifie que la route est correcte après click sur le lien
    expect(firstRouterLinkStub.injector.get(RouterLinkDirectiveStub).linkValue).toBe('/detail/0');
  });
});

Tester un composant avec un composant enfant

Dans le cas où un composant contient un ou plusieurs composants enfant, dans un test du composant parent il peut être difficile d'utiliser l'implémentation réelle des composants enfant. Par exemple, si le template des composants enfant provoque des erreurs ou si les composants enfant nécessitent des dépendances difficiles à mocker.
Une solution est de ne pas utiliser l'implémentation réelle du composant enfant mais d'utiliser un fake plus simple implémenté seulement dans le test.

Par exemple, si considère le composant Parent et le composant Child, Child étant un composant enfant de Parent:

  • Le composant enfant Child:
    Template
    {{'ID: ' + ItemToDisplay?.id + ' - Name: ' + ItemToDisplay?.name}} 
    
    Classe du composant
    @Component({
      selector: 'child',
      templateUrl: './child.component.html'
    })
    export class ChildComponent implements AfterContentInit {
      @Input() itemId!: number;
      ItemToDisplay: Item | undefined;
    
      constructor(private itemRepositoryService: ItemRepositoryService) {  }
    
      ngAfterContentInit(): void {
        this.ItemToDisplay = this.itemRepositoryService.findItemFromId(this.itemId);
      }
    }
    
  • Le composant Parent:
    Template
    <p id="itemCount">Items (count: {{Items.length}}):</p>
    <div *ngFor="let item of Items"> 
      <child [itemId]=item.id></child>
    </div> 
    
    Classe du composant
    @Component({
      selector: 'parent',
      templateUrl: './parent.component.html'
    })
    export class ParentComponent {
      public Items: Array<Item>;
    
      constructor(private itemService: ItemService) { 
        this.Items = itemService.getItems();
      }
    }
    

Le composant Parent possède une dépendance vers le service ItemService et le composant Child possède une dépendance vers ItemRepositoryService.

L'implémentation d'un test sur le composant Parent pourrait être:

describe('ParentComponent', () => {
  let component: ParentComponent;
  let fixture: ComponentFixture<ParentComponent>;
  let itemService: any;

  beforeEach(() => {
    // Configuration du service injecté dans le composant parent
    itemService = jasmine.createSpyObj(['addItem', 'getItems']);
    itemService.getItems.and.returnValue([
      { id: 0, name: 'item 0'},
      { id: 1, name: 'item 1'},
      { id: 2, name: 'item 2'},
      { id: 3, name: 'item 3'},
    ]);

    // Configuration du TestBed
    TestBed.configureTestingModule({
      declarations: [ ParentComponent, ChildComponent ],
      providers: [
        { provide: ItemService, useValue: itemService }
      ]
    });

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

  fit('should properly count items', () => {
    expect(fixture.nativeElement.querySelector('#itemCount').textContent).toEqual('Items (count: 4):');
  });
});

Ce test ne fonctionne pas car la dépendance vers ItemRepositoryService du composant Child n'est pas assurée. Si on considère l'hypothèse qu'on souhaite éviter d'utiliser l'implémentation réelle du composant Child car la dépendance ItemRepositoryService est difficile à mocker. On implémente un fake du composant enfant Child dans le test puis on déclare le fake dans le "TestBed":

describe('ParentComponent', () => {
  let component: ParentComponent;
  let fixture: ComponentFixture<ParentComponent>;
  let itemService: any;

  // Composant enfant "fake"
  @Component({
    selector: 'child',  // Même paramètre selector que l'implémentation réelle
    template: '<div></div>'
  })
  class FakeChildComponent {
  }

  beforeEach(() => {
    // Configuration du service injecté dans le composant parent
    itemService = jasmine.createSpyObj(['addItem', 'getItems']);
    itemService.getItems.and.returnValue([
      { id: 0, name: 'item 0'},
      { id: 1, name: 'item 1'},
      { id: 2, name: 'item 2'},
      { id: 3, name: 'item 3'},
    ]);

    // Configuration du TestBed
    TestBed.configureTestingModule({
      declarations: [ ParentComponent, FakeChildComponent ],
      providers: [
        { provide: ItemService, useValue: itemService }
      ]
    });

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

  fit('should properly count items', () => {
    expect(fixture.nativeElement.querySelector('#itemCount').textContent).toEqual('Items (count: 4):');
  });
});

Dans cet exemple, le composant FakeChildComponent n'a pas de dépendances contrairement au composant enfant d'origine.

Effectuer une recherche dans une liste d'éléments

L'exemple précédent montrait comment rechercher parmi les éléments du template en utilisant un ID. L'inconvénient de cette solution est qu'elle nécessite de modifier le template pour y introduire un identifiant utilisable par les tests. Une autre solution est de chercher parmi les éléments du template en effectuant des requêtes avec:

Par exemple, si dans l'exemple plus haut, on effectue parmi les éléments de type li, on peut utiliser la méthode debugElement.queryAll():

expect(fixture.debugElement.queryAll(By.css('li')).length).toBe(4);

Pour plus de détails sur la façon d'utiliser debugElement.queryAll() et de définir des prédicats avec By, voir debugElement vs nativeElement plus haut.

Lancer des évènements dans un composant enfant

Si un composant enfant expose un paramètre @Output() (voir @Output() + EventEmitter pour plus de détails) pour permettre un event binding avec le composant parent, dans un test on peut déclencher un évènement dans le composant enfant et vérifier sa propagation dans le composant parent.

Si on considère un composant parent contenant plusieurs instances d'un composant enfant. Le composant enfant possède des paramètres @Input() (paramètre d'entrée) et @Output() (évènement de sortie). Un event binding est implémenté entre le paramètre @Output() du composant enfant et une fonction du composant parent.

L'implémentation est du type:

  • Composant enfant:
    Template
    {{'ID: ' + ItemToDisplay?.id + ' - Name: ' + ItemToDisplay?.name}} 
    <button (click)='deleteItem()'>Delete Item</button>
    
    Classe du composant
    @Component({
      selector: 'child',
      templateUrl: './child.component.html'
    })
    export class ChildComponent implements AfterContentInit {
      // Paramètre d'entrée
      @Input() itemId!: number;
      // Evènement de sortie
      @Output() itemDeleted: EventEmitter<number>= new EventEmitter<number>();
      ItemToDisplay: Item | undefined;
    
      constructor(private itemRepositoryService: ItemRepositoryService) { }
    
      ngAfterContentInit(): void {
        this.ItemToDisplay = this.itemRepositoryService.findItemFromId(this.itemId);
      }
    
      deleteItem(): void {
        this.itemDeleted.emit(this.itemId);
      }
    }
    

    Un click sur le bouton déclenche la méthode deleteItem() qui émet l'évènement itemDeleted.

  • Composant parent:
    Template
    <p id="itemCount">Items (count: {{Items.length}}):</p>
    <ul>
      <div *ngFor="let item of Items"> 
        <li><child [itemId]=item.id (itemDeleted)='deleteItem($event)'></child></li>
      </div>  
    </ul>
    
    Classe du composant
    @Component({
      selector: 'parent',
      templateUrl: './parent.component.html'
    })
    export class ParentComponent {
      public Items: Array<Item>;
    
      constructor(private itemService: ItemService) { 
        this.Items = itemService.getItems();
      }
    
      deleteItem(itemIdToDelete: number): void {
        if (!this.itemService.deleteItem(itemIdToDelete))
          console.error(`Item ${itemIdToDelete} has not been deleted.`);
      }
    }
    

On cherche à implémenter un test qui:

  • déclenche l'évènement itemDeleted (coté composant enfant) et
  • vérifie que cet évènement s'est propagé dans le composant parent.

1ère méthode: déclencher l'évènement avec emit()

L'implémentation du test pourrait être:

describe('ParentComponent', () => {
  let component: ExampleComponent;
  let fixture: ComponentFixture<ExampleComponent>;
  let itemRepositoryService: any;
  let itemService: any;

   beforeEach(() => {
    // Implémentation des mocks pour les services
    itemService = jasmine.createSpyObj(['addItem', 'getItems']);
    itemService.getItems.and.returnValue([
      { id: 0, name: 'item 0'},
      { id: 1, name: 'item 1'},
      { id: 2, name: 'item 2'},
      { id: 3, name: 'item 3'},
    ]);

    itemRepositoryService = jasmine.createSpyObj(['findItemFromId']);

    // Configuration du TestBed avec les mocks des services
    TestBed.configureTestingModule({
      declarations: [ ExampleComponent, ItemComponent ],
      providers: [
        { provide: ItemRepositoryService, useValue: itemRepositoryService },
        { provide: ItemService, useValue: itemService }
      ],
    })

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

  fit('when deleting from item child component then item shall be deleted', () => {    
    // On requête tous les composants enfant dans le rendu HTML (recherche par directive)
    let itemComponents = fixture.debugElement.queryAll(By.directive(ItemComponent));
    expect(itemComponents.length).toBe(4);

    // On indique à Jasmine qu'on souhaite vérifier le comportement de la méthode deleteItem.
    spyOn(fixture.componentInstance, 'deleteItem');

    // On déclenche l'évènement itemDeleted dans un composant enfant
    (<ItemComponent>itemComponents[1].componentInstance).itemDeleted.emit(1);

    // On vérifie que l'évènement s'est propagé dans le composant parent
    expect(fixture.componentInstance.deleteItem).toHaveBeenCalledOnceWith(1);
  });
});

2e méthode: déclencher l'évènement avec DebugElement.triggerEventHandler()

On peut déclencher l'évènement itemDeleted avec DebugElement.triggerEventHandler().

L'implémentation du test pourrait être:

fit('when triggering itemDeleted from item child component then item shall be deleted', () => {    
  // On requête tous les composants enfant dans le rendu HTML (recherche par directive)
  let itemComponents = fixture.debugElement.queryAll(By.directive(ItemComponent));
  expect(itemComponents.length).toBe(4);

  // On indique à Jasmine qu'on souhaite vérifier le comportement de la méthode deleteItem.
  spyOn(fixture.componentInstance, 'deleteItem');

  // On déclenche l'évènement itemDeleted dans un composant enfant
  itemComponents[1].triggerEventHandler('itemDeleted', 1);

  // On vérifie que l'évènement s'est propagé dans le composant parent
  expect(fixture.componentInstance.deleteItem).toHaveBeenCalledOnceWith(1);
});

Mocker HttpClient

HttpClient est utilisé pour effectuer des requêtes HTTP, par exemple, vers une API. Si du code dans un composant ou un service contient des appels avec HttpClient, il peut être difficile d'exécuter ce code dans le cadre d'un test. A ce titre, il est possible de mocker la classe HttpClient et ainsi faciliter l'exécution des tests.

Pour mocker HttpClient, il suffit de substituer HttpClient avec la classe HttpTestingController. Pour utiliser HttpTestingController, il faut:

  • Ajouter le module HttpClientTestingModule et
  • Utiliser HttpClientController dans le module de test.

Par exemple, si on considère le code suivant:

export interface IRepoData {
  id: string;
  node_id: string;
  name: string;
}

@Injectable({
  providedIn: 'root'
})
export class RepoApiService {
  baseURL: string = 'https://api.github.com/';

  constructor(private httpClient: HttpClient) { }

  getRepos(userName: string): Observable<IRepoData[]> {
      return this.httpClient.get<IRepoData[]>(this.baseURL + 'users/' + userName + '/repos');
  }  
}

Cette fonction permet d'interroger une API à l'adresse: https://api.github.com/users/<user name>/repos, par exemple:
https://api.github.com/users/msoft/repos

Parmi les données retournées, on se contente de ne récupérer que les propriétés:

  • ID
  • node_id
  • name

On utilise l'interface IRepoData pour représenter ces données.

Pour appeler le fonction RepoApiSevice.getRepos(), une implémentation pourrait être:

Template
<p>
  Repo name:
  <input #repoName />
  <button (click)='getRepoNames(repoName.value)'>Get Repo data</button>
</p>

<ul>
  <div *ngFor="let repoName of RepoNames">
    <li>{{repoName}}</li>
  </div>
</ul>
Classe du composant
@Component({
  selector: 'app-example',
  templateUrl: './example.component.html'
})
export class ExampleComponent implements OnDestroy {
  RepoNames: string[];
  private isAlive = true;

  constructor(private repoApiService: RepoApiService) { 
    this.RepoNames = [];
  }

  getRepoNames(userName: string): void {
    this.repoApiService.getRepos(userName)
    .pipe(takeWhile(() => this.isAlive))
    .subscribe(repos => {
      this.RepoNames = repos?.map(r => r.name);
    });
  }

  ngOnDestroy(): void {
    this.isAlive = false;
  }
}

Si on indique le nom du username dans la zone input et si on clique sur le bouton, la liste des repos GitHub s'affiche:

On souhaite tester le fonction RepoApiService.getRepos() qui utilise HttpClient.

Dans un premier temps, on va donc mocker la classe HttpClient en utilisant HttpTestingController dans le TestBed:

import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';

import { IRepoData, RepoApiService } from './repo-api.service';

describe('RepoApiService', () => {
  let service: RepoApiService;
  let httpTestingController: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [
        RemoteApiService, 
      ]
    });

    service = TestBed.inject(RepoApiService);
    httpTestingController = TestBed.inject(HttpTestingController);
  });
});

On importe le module HttpClientTestingModule qui contient HttpTestingController. On utilise inject() pour instancier RepoApiService. Ainsi l'instance de HttpClient injectée dans RepoApiService est de type HttpTestingController.

On peut ensuite implémenter un test qui va appeler RepoApiService.getRepos() et vérifier que l'appel à l'API a bien été effectué:

fit('should fetch list of repos when calling API', () => {
  expect(service).toBeTruthy();

  let repoUserName = 'miscUserName';
  let expectedFetchedRepoData: IRepoData[] = [
    { id:'1', node_id: '544', name: 'firstRepoForTest' },
    { id:'2', node_id: '545', name: 'secondRepoForTest' }
  ];    
  
  service.getRepos(repoUserName).subscribe(
    actualFetchedRepoData => {
      expect(actualFetchedRepoData).toBeTruthy(); 
      expect(actualFetchedRepoData).toEqual(expectedFetchedRepoData);
    }
  );
  const request = httpTestingController.expectOne(`https://api.github.com/users/${repoUserName}/repos`);
  expect(request.request.method).toBe("GET");

  request.flush(expectedFetchedRepoData);
});

Dans ce test, on s'abonne à la fonction RepoApiService.getRepos() avec service.getRepos(repoUserName).subscribe(...).
On vérifie que HttpClient doit être appelé en effectuant une requête avec le verbe HTTP GET à l'adresse https://api.github.com/users/${repoUserName}/repos.
httpTestingController.expectOne() permet de récupérer un mock qui permettra par la suite de simuler la réponse de HttpClient avec request.flush(). La vérification des données obtenues se fait dans la lambda de l'appel service.getRepos().subscribe(...).
Il faut respecter la séquence des appels pour que le test fonctionne.

Quelques méthodes dans HttpTestingController permettent d'effectuer des vérifications:

  • HttpTestingController.match(): permet de retourner un mock TestRequest pour toutes les requêtes effectuées.
  • HttpTestingController.expectNone(): permet de vérifier qu'une requête vers une URL n'a pas été effectuée.
  • HttpTestingController.verify(): vérifie si des requêtes sont en attente. Une erreur est lancée si des requêtes sont en attente.

Dans cet exemple request est de type TestRequest:

  • TestRequest.flush() permet de simuler la réponse à une requête HTTP en indiquant le corps du message.
  • TestRequest.error() permet de simuler une erreur réseau lors de l'appel à HttpClient.
  • TestRequest.event() permet de simuler un évènement sur le flux de la réponse à la requête.
  • TestRequest.request.method permet de récupérer le verbe HTTP utilisé lors de la requête.
  • TestRequest.request.params permet de récupérer les paramètres utilisés dans la requête.

Il n'est pas obligatoire d'utiliser le TestBed pour injecter la classe HttpTestingController. On peut utiliser la méthode inject() directement dans un test, par exemple:

import { inject } from '@angular/core/testing';
// ...

fit('should fetch list of repos when calling API', () => {
  inject([RepoApiService, HttpTestingController], 
    (service: RepoApiService, httpTestingController: HttpTestingController) => 
  {
    // Implémentation du test
    // ...
  })	
});

Tester des exécutions asynchrones

Tester du code exécuté de façon asynchrone présente certaines difficultés car l'exécution n'est pas immédiate, il faut attendre un certain laps de temps pas forcément connu à l'avance pour que cette exécution soit terminée et qu'on puisse effectuer les vérifications du test.

Dans un premier temps, on va simuler dans le composant un traitement asynchrone de façon à indiquer plusieurs possibilités pour implémenter un test pour ce type de code.

Si on reprend l'exemple du paragraphe précédent, le code du composant est:

Template
<p>
  Repo name:
  <input #repoName />
  <button (click)='getRepoNames(repoName.value)'>Get Repo data</button>
</p>

<ul>
  <div *ngFor="let repoName of RepoNames">
    <li>{{repoName}}</li>
  </div>
</ul>
Classe du composant
@Component({
  selector: 'app-example',
  templateUrl: './example.component.html'
})
export class ExampleComponent implements OnDestroy {
  RepoNames: string[];
  private isAlive = true;
  RepoFetched!: boolean;


  constructor(private repoApiService: RepoApiService) { 
    this.RepoNames = [];
  }

  getRepoNames(userName: string): void {
    this.RepoFetched = false;
    this.remoteApiService.getRepos(userName)
      .subscribe(repos => {
        this.RepoNames = repos?.map(r => r.name);
        this.RepoFetched = true;
      };
  }


  ngOnDestroy(): void {
    this.isAlive = false;
  }
}

Ce code permet de récupérer la liste de nom des repos Github grâce à la fonction getRepoNames(). On va modifier cette fonction pour que son exécution soit retardée de façon à simuler un traitement asynchrone. Au préalable, on ajoute
la méthode Javascript suivante:

function executeWithTimeout(func, waitingTime) {
  var context  = this, args = arguments;
  var callback = function() {
      func.apply(context, args);
  };
  setTimeout(callback, waitingTime);
};

Cette méthode retarde l'exécution du code dans l'argument func en utilisant la méthode setTimeout(). Le temps d'attente est précisé avec l'argument waitingTime.

Pour utiliser cette méthode:

  1. On l'ajoute dans un fichier Javascript src/script.js.
  2. On indique la présence de ce fichier dans la configuration Angular dans angular.json:
    {
      ...
      "projects": {
        "angular_application_tests": {
            ...
            "architect": {
            "build": {
              "builder": "@angular-devkit/build-angular:browser",
              "options": {
                ...
                "styles": [
                  "src/styles.css"
                ],
                "scripts": ["src/script.js"]
              },
              "configurations": {
                ...
              },
              "defaultConfiguration": "production"
            },
            ...
          }
        }
      }
    }
    
  3. On ajoute une déclaration pour la méthode Javascript dans le code Typescript:
    declare function executeWithTimeout(func: any, waitingTime: number): void;
    

Pour simuler le retardement de la fonction à exécuter, on modifie la méthode getRepoNames():

getRepoNames(userName: string): void {
  executeWithTimeout(() => {
    this.RepoFetched = false;
    this.repoApiService.getRepos(userName)
    .subscribe(repos => {
      this.RepoNames = repos?.map(r => r.name);
      this.RepoFetched = true;
    });
  }, 250); // 250 ms de retard
}

Si on implémente un test sans prendre en compte le retard lors de l'exécution, ce test échoue.

Par exemple:

describe('ExampleComponent', () => {
  let component: ExampleComponent;
  let fixture: ComponentFixture<ExampleComponent>;  
  let repoApiService: any;
  
  beforeEach(() => {
    repoApiService = jasmine.createSpyObj(['getRepos']);

    TestBed.configureTestingModule({
      declarations: [ ExampleComponent, RouterLinkDirectiveStub ],

      providers: [
        { provide: RepoApiService, useValue: repoApiService },
      ],
    })

    fixture = TestBed.createComponent(ExampleComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });
  
  fit('should fetch list of repos when calling API (', () => {    
    let expectedUserName = 'UserName';

    repoApiService.getRepos.withArgs(expectedUserName).and.returnValue(of([
      { id: 1, node_id: 54, name: 'repo' }
    ]));

    component.getRepoNames(expectedUserName);

    expect(repoApiService.getRepos).toHaveBeenCalled();
    expect(component.RepoFetched).toBe(true);
  });
});

Quelques solutions sont possibles pour implémenter un test dans le cas de l'exécution asynchrone.

setTimeout()

Une première possibilité est d'introduire dans le test, un retard à l'exécution équivalent au retard de l'exécution asynchrone. On modifie le test précédent en exécutant les vérifications avec setTimeout():

fit('should fetch list of repos when calling API (setTimeout)', () => {    
  let expectedUserName = 'UserName';

  repoApiService.getRepos.withArgs(expectedUserName).and.returnValue(of([
    { id: 1, node_id: 54, name: 'repo' }
  ]));

  component.getRepoNames(expectedUserName);
  
  setTimeout(() => {
    expect(repoApiService.getRepos).toHaveBeenCalled();
    expect(component.RepoFetched).toBe(true);
  }, 300);
});

Le test réussit toutefois on n'attend pas la fin de l'exécution de setTimeout(). Pour que Karma attende la fin de l'exécution du test, on modifie le test de cette façon:

fit('should fetch list of repos when calling API (setTimeout)', (done) => {    
  let expectedUserName = 'UserName';

  repoApiService.getRepos.withArgs(expectedUserName).and.returnValue(of([
    { id: 1, node_id: 54, name: 'repo' }
  ]));

  component.getRepoNames(expectedUserName);
  
  setTimeout(() => {
    expect(repoApiService.getRepos).toHaveBeenCalled();
    expect(component.RepoFetched).toBe(true);
    done();
  }, 300);
});

Le test réussit cependant les inconvénients de cette solution sont:

  • La durée d'exécution du test est rallongée à cause de setTimeout().
  • Si on connaît pas le temps de réponse, il sera difficile de configurer le temps d'attente dans l'appel à setTimeout().

Utiliser fakeAsync

Angular utilise la bibliothèque Zone.js pour intercepter les évènements qui se déclenchent dans le browser de façon à permettre en particulier, la détection de changement (cf. Fonctionnement de la détection de changement). Zone.js permet de mettre en place un contexte d'exécution sous forme d'une zone. Pour les besoins de tests asynchrones, l'objet fakeAsync permet de mettre en place une zone dans laquelle les évènements seront interceptés pour qu'ils ne soient pas exécutés normalement:

Dans le cas de l'exemple, si on utilise fakeAsync, le test devient:

fit('should fetch list of repos when calling API (fakeAsync)', <any>fakeAsync(() => {    
  let expectedUserName = 'UserName';

  repoApiService.getRepos.withArgs(expectedUserName).and.returnValue(of([
    { id: 1, node_id: 54, name: 'repo' }
  ]));

  component.getRepoNames(expectedUserName);

  tick(300);
  
  expect(repoApiService.getRepos).toHaveBeenCalled();
  expect(component.RepoFetched).toBe(true);
}));

tick() permet de simuler le passage du temps. Si on ne connaît pas le temps d'exécution, on peut utiliser flush(). flush() exécute toutes les macrotasks en attente d'exécution. Si des macrotasks sont en cours d'exécution, flush() avance l'horloge d'exécution de la zone pour vérifier si les macrotasks ont été réellement exécutées.

Dans le cas de l'exemple, flush() peut être utilisé à la place de tick():

fit('should fetch list of repos when calling API (fakeAsync)', <any>fakeAsync(() => {    
  let expectedUserName = 'UserName';

  repoApiService.getRepos.withArgs(expectedUserName).and.returnValue(of([
    { id: 1, node_id: 54, name: 'repo' }
  ]));

  component.getRepoNames(expectedUserName);

  flush();
  
  expect(repoApiService.getRepos).toHaveBeenCalled();
  expect(component.RepoFetched).toBe(true);
}));

Promise

Dans le cas d'une promise, on peut utiliser:

  • flush() comme pour l'exemple précédent car les promises sont des microtasks.
  • ComponentFixture<T>.whenStable(): permet d'obtenir une promise qui va attendre les promises en cours d'exécution. Ainsi si on utilise ComponentFixture<T>.whenStable().then(...), on pourra exécuter le code effectuant les vérifications quand toutes les promises auront achevé leur exécution. Si on utilise ComponentFixture<T>.whenStable().then(...) dans un test, il faut utiliser waitForAsync() pour que Karma attende la fin de l'exécution du code dans la partie then(...).

Dans le cas de l'exemple, on va modifier le code du composant pour qu'une promise soit en attente d'exécution. Le code de getRepoNames() devient:

Template
<p>
  Repo name:
  <input #repoName />
  <button (click)='getRepoNames(repoName.value)'>Get Repo data</button>
</p>

<ul>
  <div *ngFor="let repoName of RepoNames">
    <li>{{repoName}}</li>
  </div>
</ul>
Classe du composant
@Component({
  selector: 'app-example',
  templateUrl: './example.component.html'
})
export class ExampleComponent implements OnDestroy {
  RepoNames: string[];
  private isAlive = true;
  RepoFetched!: boolean;

  constructor(private repoApiService: RepoApiService) { 
    this.RepoNames = [];
  }

  getRepoNames(userName: string): void {
    this.RepoFetched = false;
    var p = firstValueFrom(this.repoApiService.getRepos(userName)
      .pipe(map(repos => {
      this.RepoNames = repos?.map(r => r.name);
      this.RepoFetched = true;
    })));
  }

  ngOnDestroy(): void {
    this.isAlive = false;
  }
}

En utilisant ComponentFixture<T>.whenStable() dans le test pour attendre la fin de l'exécution de la promise, le code devient:

fit('should fetch list of repos when calling API (whenStable)', waitForAsync(() => {    
  let expectedUserName = 'UserName';

  repoApiService.getRepos.withArgs(expectedUserName).and.returnValue(of([
    { id: 1, node_id: 54, name: 'repo' }
  ]));

  component.getRepoNamesAsyncPromise(expectedUserName);

  fixture.whenStable().then(() => {
    expect(repoApiService.getRepos).toHaveBeenCalled();
    expect(component.RepoFetched).toBe(true);  
  });
}));
Références

Leave a Reply