Depuis la première version du framework AngularJS (2009), celui-ci embarque le couple Karma / Jasmine comme framework de test unitaire. A la sortie d’Angular en Septembre 2016, ce nouveau framework utilise les même outils de tests unitaires que son prédécesseur. Ces outils se font vieillissants et l’arrivée de nouveaux frameworks de test comme Jest, qui sont à la fois plus rapides (exécution des tests en parallèle), plus simples (configuration simple, pas de librairies tierces pour l’assertion ou mock, tests faciles à écrire grâce à une API simple d’utilisation).

Jest, la simplicité pour réaliser vos tests unitaires sans pleurer

Le framework Jest,  mis au point par Facebook pour tester ses applications front développées sous ReactJS, cet outil a trouvé grâce aux yeux de nombreux développeurs de par sa simplicité et son efficacité. Supporté par une grosse communauté, ce framework a été adapté pour fonctionner sur tout l’écosystème JavaScript tant au niveau front sur React, Angular ou Vue que back avec Node.

Nous verrons dans cet article comment remplacer facilement (en quelques minutes) le duo de test Karma / Jasmine par Jest ; puis nous verrons à partir d’une application simple (mais plus complexe que le simple ‘Hello World’ d’Angular) comment effectuer nos tests unitaires Angular et comment générer un rapport de couverture de code (code coverage) afin de l’intégrer dans différents outils comme par exemple Sonar.

Pré-requis:

@angular/cli installé sur votre machine

Remplacement de Karma/Jasmin par Jest

Avant de commencer notre changement de framework de test, nous allons générer une application Angular via le CLI :

ng new hero-team-selector --routing=false --style=scss

Nous avons maintenant notre application de base, le Hello World d’Angular.

Retirer le système de test actuel Karma.

On supprime les dépendances node avec la commande suivante :

npm remove karma karma-chrome-launcher karma-coverage-istanbul-reporter karma-jasmine karma-jasmine-html-reporter

Puis on supprime le fichier de configuration de Karma et le fichier de test généré par le CLI Angular au moment de la création du projet.

rm src/karma.conf.js src/test.ts

Installer Jest

Il faut installer deux composants jest et @angular-builders/jest

npm install -D jest @angular-builders/jest @types/jest

Mise à jour de la configuration Typescript

Dans le fichier src/tsconfig.spec.json :

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

Dans le fichier tsconfig.json:

"compilerOptions": {
    ...
    "types": ["jest"]
}

Dans le fichier angular.json remplacer le build angular Karma par Jest:

"projects": {
    ...
    "[your-project]": {
         ...
         "architect": {
                ...
                "test": {
                          "builder": "@angular-builders/jest:run"

Ajouter un fichier permettant la surcharge de la configuration de Jest, src/jest.config.js :

const {defaults} = require('jest-config');
 
 
module.exports = {
  verbose: true,
  collectCoverage: true,
  collectCoverageFrom: ["src/**/*.ts"]
}

ET voilà la configuration est terminée… c’était quand même simple 😉

Il ne reste plus qu’à exécuter vos tests.

ng test

Sur un projet venant d’être initialisé via le CLI Angular, vous devez obtenir le résultat suivant:

Les tests sur votre fichier app.component.ts doivent être à 100%.

Effectuer les premiers tests

Pour voir comment réaliser nos premiers tests avec Jest, nous allons récupérer le projet suivant : https://github.com/ineat/Angular-Jest-Tutorial sur la branche 02-Hero-App-Untested.

NOTE : Pensez à executer npm install -D avant de lancer ng testn

Lorsqu’on exécute les tests sur le projet que nous venons de récupérer, nous avons 8 tests unitaires qui sont en erreurs.

Nous allons corriger ces erreurs :

Dans le fichier src/app/core/app.component.spec.ts

  • La première erreur vient du fait que nous avons modifié le template du fichier app.component.html. Il ne contient plus de titre H1, il faut donc supprimer les deux tests inutiles en fin de fichier.
  • La seconde erreur provient du fait que nous avons ajouté le Router via la balise <router-outlet>, il faut donc importer le RouterTestingModule.
import { TestBed, async } from '@angular/core/testing';
import { AppComponent } from './app.component';

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

describe('AppComponent', () => {
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [RouterTestingModule],
      declarations: [AppComponent]
    }).compileComponents();
  }));

  it('should create the app', () => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.debugElement.componentInstance;
    expect(app).toBeTruthy();
  });
});

Normalement, à ce stade, Il reste 5 tests en erreurs.

src/app/hero-team-selector/components/team/team.component.spec.ts

  • Il manque les composants HeroCard et EmptyCard , il faut donc les importer dans les declarations.
beforeEach(async(() => {
  TestBed.configureTestingModule({
    declarations: [TeamComponent, HeroCardComponent, EmptyCardComponent]
  }).compileComponents();
}));

Il reste 4 tests en erreurs.

src/app/hero-team-selector/components/hero-list/hero-list.component.spec.ts

  • Il manque le composant HeroListItem , il faut donc l’importer dans les declarations.
import { async, ComponentFixture, TestBed } from '@angular/core/testing';

import { HeroListComponent } from './hero-list.component';

import { HeroListItemComponent } from '../hero-list-item/hero-list-item.component';

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

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ HeroListComponent, HeroListItemComponent ]
    })
    .compileComponents();
  }));
...

 

  • Nous avons également l’Input list qui n’est pas défini pour notre test unitaire, il faut l’ajouter au niveau du bloc beforeEach
beforeEach(() => {
  fixture = TestBed.createComponent(HeroListComponent);
  component = fixture.componentInstance;
  component.title = 'hero';
  component.list = [
    {
      id: 2,
      name: 'Hulk',
      real_name: 'Robert Bruce Banner',
      thumb:
        'https://i.annihil.us/u/prod/marvel/i/mg/5/a0/538615ca33ab0/standard_xlarge.jpg',
      image: 'https://i.annihil.us/u/prod/marvel/i/mg/e/e0/537bafa34baa9.jpg',
      description:
        'Caught in a gamma bomb explosion while trying to save the life of a teenager, Dr. Bruce Banner was transformed into the incredibly powerful creature called the Hulk. An all too often misunderstood hero, the angrier the Hulk gets, the stronger the Hulk gets.'
    },
    {
      id: 3,
      name: 'IronMan',
      real_name: 'Anthony Edward "Tony" Stark',
      thumb:
        'http://i.annihil.us/u/prod/marvel/i/mg/9/c0/527bb7b37ff55/standard_xlarge.jpg',
      image: 'https://i.annihil.us/u/prod/marvel/i/mg/c/60/55b6a28ef24fa.jpg',
      description: ''
    }
  ];
  fixture.detectChanges();
});

Il reste 3 tests en erreurs.

src/app/hero-team-selector/components/hero-list-item/hero-list-item.component.spec.ts

  • Nous avons également l’Input item qui n’est pas défini pour notre test unitaire, il faut l’ajouter au niveau du bloc beforeEach
beforeEach(() => {
  fixture = TestBed.createComponent(HeroListItemComponent);
  component = fixture.componentInstance;
  component.item = {
    id: 3,
    name: 'IronMan',
    real_name: 'Anthony Edward "Tony" Stark',
    thumb:
      'http://i.annihil.us/u/prod/marvel/i/mg/9/c0/527bb7b37ff55/standard_xlarge.jpg',
    image: 'https://i.annihil.us/u/prod/marvel/i/mg/c/60/55b6a28ef24fa.jpg',
    description: ''
  };
  fixture.detectChanges();
});

Il reste 2 tests en erreurs.

src/app/hero-team-selector/containers/team-selector/team-selector.component.spec.ts

 

  • Il manque les composants HeroList, HeroListItem, Team, HeroCard, EmptyCard, il faut donc les importer dans les declarations.
beforeEach(async(() => {
  TestBed.configureTestingModule({
    declarations: [
      TeamSelectorComponent,
      HeroListComponent,
      HeroListItemComponent,
      TeamComponent,
      HeroCardComponent,
      EmptyCardComponent
    ]
  }).compileComponents();
}));

Il reste 1 test en erreurs.

src/app/hero-team-selector/components/hero-card/hero-card.component.spec.ts

 

  • L’Input member n’est pas défini, on va le déclarer dans le bloc beforeEach.
fixture = TestBed.createComponent(HeroCardComponent);
  component = fixture.componentInstance;
  component.member = {
    id: 3,
    name: 'IronMan',
    real_name: 'Anthony Edward "Tony" Stark',
    thumb:
      'http://i.annihil.us/u/prod/marvel/i/mg/9/c0/527bb7b37ff55/standard_xlarge.jpg',
    image: 'https://i.annihil.us/u/prod/marvel/i/mg/c/60/55b6a28ef24fa.jpg',
    description: ''
  };
  fixture.detectChanges();
});

Tous les tests sont passés \o/

 

Nous allons maintenant améliorer la couverture de test en ajoutant nos propres tests unitaires :

Commençons par le composant HeroCard, la méthode remove() n’est pas testée. Pour rectifier ce manque, nous allons simuler l’EventEmitter dans le bloc beforeEach:

beforeEach(() => {
    fixture = TestBed.createComponent(HeroCardComponent);
    component = fixture.componentInstance;
    spyOn(component.removeMember, 'emit');
    component.member = {
      id: 3,
      name: 'IronMan',
      real_name: 'Anthony Edward "Tony" Stark',
      thumb:
        'http://i.annihil.us/u/prod/marvel/i/mg/9/c0/527bb7b37ff55/standard_xlarge.jpg',
      image: 'https://i.annihil.us/u/prod/marvel/i/mg/c/60/55b6a28ef24fa.jpg',
      description: ''
    };
    fixture.detectChanges();
  });

 

Puis nous allons écrire le test associé à la méthode:

...
  it('should emit removeHero event when call remove function', () => {
    component.remove();
    expect(component.removeMember.emit).toHaveBeenCalledWith(component.member);
  });
...

Voilà notre composant HeroCard testé a 100%, au suivant…

Le composant HeroList, la méthode onSelect() n’est pas testé. Pour corriger ce manque, nous allons à nouveau simuler, comme pour le composant HeroCard,  l’EventEmitter dans le bloc beforeEach, puis nous allons écrire le test :

...
  it('should emit selectItem event when onSelect function is call', () => {
    component.onSelect(component.list[0]);
    expect(component.selectItem.emit).toHaveBeenCalledWith({
      type: component.title,
      member: component.list[0]
    });
  });
...

Test suivant, le composant HeroListItem. Il faut comme pour les deux composants précédents, tester la méthode select() qui utilise un EventEmitter. On va donc procéder de la même manière…

...
  it('should emit SelectItem event when method select was called', () => {
    component.select();
    expect(component.selectItem.emit).toHaveBeenCalledWith(component.item);
  });
...

Il nous reste deux composants dont la couverture de code est imparfaite : TeamComponent et TeamSelectorComponent.

Pour le premier, il faut tester la fonction onRemoveMember() qui comporte également un EventEmitter, on sait maintenant comment tester cette fonction :

...
  it('should emit event removeMember with formatted object memberToRemove', () => {
    const member = {
      id: 3,
      name: 'IronMan',
      real_name: 'Anthony Edward "Tony" Stark',
      thumb:
        'http://i.annihil.us/u/prod/marvel/i/mg/9/c0/527bb7b37ff55/standard_xlarge.jpg',
      image: 'https://i.annihil.us/u/prod/marvel/i/mg/c/60/55b6a28ef24fa.jpg',
      description: ''
    };

    component.onRemoveMember(member);
    expect(component.removeMember.emit).toHaveBeenLastCalledWith({
      type: component.title,
      member
    });
  });
...

Et pour le dernier fichier, nous avons deux méthodes qui sont pas testées, les méthodes onSelectMember() et onRemoveMember():

On commence par simuler l’appel à la méthode window.alert.

...
  beforeEach(() => {
    fixture = TestBed.createComponent(TeamSelectorComponent);
    component = fixture.componentInstance;
    spyOn(window, 'alert');
    fixture.detectChanges();
  });
...

Puis pour la méthode onSelectMember(), nous allons tester les différents cas de figure :

...
  it('should add Hero in heroesTeam when onSelectMember is called with Hero', () => {
    component.wickedTeam = [];
    component.heroesTeam = [];
    component.onSelectMember(hero);
    expect(component.heroesTeam.length).toEqual(1);
  });

  it('should called window.alert when you try to add hero with full heroesTeam', () => {
    component.wickedTeam = [];
    component.heroesTeam = [{}, {}, {}, {}, {}];
    component.onSelectMember(hero);
    expect(component.heroesTeam.length).toEqual(5);
    expect(window.alert).toHaveBeenCalled();
  });

  it('should add Hero in wickedTeam when onSelectMember is called with Wicked', () => {
    component.wickedTeam = [];
    component.heroesTeam = [];
    component.onSelectMember(wicked);
    expect(component.wickedTeam.length).toEqual(1);
  });

  it('should called window.alert when you try to add hero with full wickedTeam', () => {
    component.wickedTeam = [{}, {}, {}, {}, {}];
    component.heroesTeam = [];
    component.onSelectMember(wicked);
    expect(component.wickedTeam.length).toEqual(5);
    expect(window.alert).toHaveBeenCalled();
  });
...

Et pour la méthode onRemoveMember(), nous allons également tester les deux cas:

...
  it('should remove hero from heroesTeam when onRemoveMember was called with an hero', () => {
    component.wickedTeam = [];
    component.heroesTeam = [
      {
        id: 2,
        name: 'Hulk',
        real_name: 'Robert Bruce Banner',
        thumb:
          'https://i.annihil.us/u/prod/marvel/i/mg/5/a0/538615ca33ab0/standard_xlarge.jpg',
        image: 'https://i.annihil.us/u/prod/marvel/i/mg/e/e0/537bafa34baa9.jpg',
        description:
          'Caught in a gamma bomb explosion while trying to save the life of a teenager, Dr. Bruce Banner was transformed into the incredibly powerful creature called the Hulk. An all too often misunderstood hero, the angrier the Hulk gets, the stronger the Hulk gets.'
      },
      {
        id: 1,
        name: 'Hulk',
        real_name: 'Robert Bruce Banner',
        thumb:
          'https://i.annihil.us/u/prod/marvel/i/mg/5/a0/538615ca33ab0/standard_xlarge.jpg',
        image: 'https://i.annihil.us/u/prod/marvel/i/mg/e/e0/537bafa34baa9.jpg',
        description:
          'Caught in a gamma bomb explosion while trying to save the life of a teenager, Dr. Bruce Banner was transformed into the incredibly powerful creature called the Hulk. An all too often misunderstood hero, the angrier the Hulk gets, the stronger the Hulk gets.'
      },
      {
        id: 3,
        name: 'Hulk',
        real_name: 'Robert Bruce Banner',
        thumb:
          'https://i.annihil.us/u/prod/marvel/i/mg/5/a0/538615ca33ab0/standard_xlarge.jpg',
        image: 'https://i.annihil.us/u/prod/marvel/i/mg/e/e0/537bafa34baa9.jpg',
        description:
          'Caught in a gamma bomb explosion while trying to save the life of a teenager, Dr. Bruce Banner was transformed into the incredibly powerful creature called the Hulk. An all too often misunderstood hero, the angrier the Hulk gets, the stronger the Hulk gets.'
      }
    ];

    component.onRemoveMember(heroToRemove);
    expect(component.heroesTeam.length).toEqual(2);
  });

  it('should remove wicked from wickedTeam when onRemoveMember was called with an wicked', () => {
    component.wickedTeam = [
      {
        id: 2,
        name: 'Hulk',
        real_name: 'Robert Bruce Banner',
        thumb:
          'https://i.annihil.us/u/prod/marvel/i/mg/5/a0/538615ca33ab0/standard_xlarge.jpg',
        image: 'https://i.annihil.us/u/prod/marvel/i/mg/e/e0/537bafa34baa9.jpg',
        description:
          'Caught in a gamma bomb explosion while trying to save the life of a teenager, Dr. Bruce Banner was transformed into the incredibly powerful creature called the Hulk. An all too often misunderstood hero, the angrier the Hulk gets, the stronger the Hulk gets.'
      },
      {
        id: 1,
        name: 'Hulk',
        real_name: 'Robert Bruce Banner',
        thumb:
          'https://i.annihil.us/u/prod/marvel/i/mg/5/a0/538615ca33ab0/standard_xlarge.jpg',
        image: 'https://i.annihil.us/u/prod/marvel/i/mg/e/e0/537bafa34baa9.jpg',
        description:
          'Caught in a gamma bomb explosion while trying to save the life of a teenager, Dr. Bruce Banner was transformed into the incredibly powerful creature called the Hulk. An all too often misunderstood hero, the angrier the Hulk gets, the stronger the Hulk gets.'
      },
      {
        id: 3,
        name: 'Hulk',
        real_name: 'Robert Bruce Banner',
        thumb:
          'https://i.annihil.us/u/prod/marvel/i/mg/5/a0/538615ca33ab0/standard_xlarge.jpg',
        image: 'https://i.annihil.us/u/prod/marvel/i/mg/e/e0/537bafa34baa9.jpg',
        description:
          'Caught in a gamma bomb explosion while trying to save the life of a teenager, Dr. Bruce Banner was transformed into the incredibly powerful creature called the Hulk. An all too often misunderstood hero, the angrier the Hulk gets, the stronger the Hulk gets.'
      }
    ];
    component.heroesTeam = [];

    component.onRemoveMember(wickedToRemove);
    expect(component.wickedTeam.length).toEqual(2);
  });
...

Voici le résultat de notre dernier test…

 

Voilà, notre application est testée à 100% !

Couche service

Nous allons ajouter la couche service qui fait appel à une API afin de récupérer la liste des héros et de … méchants. Pour cela, il faut récupérer la branche 04-Hero-App-Service.

Une fois la branche récupérée, nous allons modifier le test du composant TeamSelectorComponent afin d’y injecter le service que nous avons récupéré :

...
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      declarations: [
        TeamSelectorComponent,
        HeroListComponent,
        HeroListItemComponent,
        TeamComponent,
        HeroCardComponent,
        EmptyCardComponent
      ],
      providers: [CharactersService]
    }).compileComponents();
  }));
...

Puis nous allons ensuite tester notre service en simulant (mock) l’appel Http vers l’API. Le « mock » est réalisé avec les outils mis à notre disposition dans module de test du client Http d’Angular (HttpCLientTestingModule).

Dans le fichier de test du service, nous allons ajouter l’HttpServiceController pour l’injecter au niveau de beforeEach:

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

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

    httpTestingController = TestBed.get(HttpTestingController);
    service = TestBed.get(CharactersService);
  });

  afterEach(() => {
    httpTestingController.verify();
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });
});

Attention à ne pas oublier la méthode httpTestingController.verify() afin de vérifier qu’aucun appel Http n’est en cours avant de fermer le test.

Puis nous allons ajouter deux tests afin de vérifier les deux méthodes de notre service.

Pour la méthode fetchHeroes():

...
  it('should return array of heroes when fetchHeroes method was called', () => {
    const mockHeroes = [
      {
        id: 1,
        name: 'Deadpool',
        real_name: 'Wade Wilson',
        thumb:
          'https://i.annihil.us/u/prod/marvel/i/mg/9/90/5261a86cacb99/standard_xlarge.jpg',
        image: 'https://i.annihil.us/u/prod/marvel/i/mg/9/90/5261a86cacb99.jpg',
        description: ''
      },
      {
        id: 2,
        name: 'Hulk',
        real_name: 'Robert Bruce Banner',
        thumb:
          'https://i.annihil.us/u/prod/marvel/i/mg/5/a0/538615ca33ab0/standard_xlarge.jpg',
        image: 'https://i.annihil.us/u/prod/marvel/i/mg/e/e0/537bafa34baa9.jpg',
        description:
          'Caught in a gamma bomb explosion while trying to save the life of a teenager, Dr. Bruce Banner was transformed into the incredibly powerful creature called the Hulk. An all too often misunderstood hero, the angrier the Hulk gets, the stronger the Hulk gets.'
      }
    ];

    service.fetchHeroes().subscribe(heroes => {
      expect(heroes.length).toEqual(2);
      expect(heroes[0].name).toEqual('Deadpool');
    });

    const req = httpTestingController.expectOne('http://localhost:8080/hero');

    expect(req.request.method).toEqual('GET');

    req.flush(mockHeroes);
  });
...

Puis pour la méthode fetchWicked():

...
  it('should return array of heroes when fetchHeroes method was called', () => {
    const mockWicked = [
      {
        id: 1,
        name: 'Loki',
        real_name: 'Loki',
        thumb:
          'http://i.annihil.us/u/prod/marvel/i/mg/d/90/526547f509313/standard_xlarge.jpg',
        image: 'http://i.annihil.us/u/prod/marvel/i/mg/d/90/526547f509313.jpg',
        description: ''
      },
      {
        id: 2,
        name: 'Thanos',
        real_name: 'Thanos',
        thumb:
          'http://i.annihil.us/u/prod/marvel/i/mg/6/40/5274137e3e2cd/standard_xlarge.jpg',
        image: 'http://i.annihil.us/u/prod/marvel/i/mg/6/40/5274137e3e2cd.jpg',
        description:
          'The Mad Titan Thanos, a melancholy, brooding individual, consumed with the concept of death, sought out personal power and increased strength, endowing himself with cybernetic implants until he became more powerful than any of his brethren.'
      }
    ];

    service.fetchWicked().subscribe(wicked => {
      expect(wicked.length).toEqual(2);
      expect(wicked[0].name).toEqual('Loki');
    });

    const req = httpTestingController.expectOne('http://localhost:8080/wicked');

    expect(req.request.method).toEqual('GET');

    req.flush(mockWicked);
  });
...

Notre service est testé, toute notre application est testée.
Le rapport de couverture de code est disponible dans le répertoire coverage.

Pour terminer, voici un lien utile :

CheatSheet (https://github.com/sapegin/jest-cheat-sheet/blob/master/Readme.md) qui regroupe différentes méthodes disponibles au niveau de l’API pour effectuer vos tests avec Jest.