Avant d’aller plus loin dans le fonctionnement des tests pour Angular, on va d’abord se concentrer sur les tests de classes, qui ne nécessitent pas l’aide du framework de test fourni par Angular. Ce n’est en rien une perte de temps car dans la réalité il arrive souvent de tester « directement » des portions de code sans passer par les outils d’Angular 😉.

Plan global du cours

Ecrire un test avec Jasmine

Comme nous l’avons vu dans la première partie, Jasmine est un « behavior-driven development (BDD) framework » qui est un choix très populaire pour celui qui veut efficacement tester son application JavaScript. L’avantage d’écrire des tests en suivant la méthodologie « BDD » est que le code de test produit sera proche du language courant, et donc facilement compréhensible par des équipes extérieures.

Mais avant cela, nous devons expliciter les principaux termes utilisés dans Jasmine.

Vocabulaire Jasmine

Il existe quelques fonctions importantes de Jasmine (describe, it et expect) avec lesquelles nous devons nous familiariser.

  • describe : utile pour regrouper une série de tests. Le groupe de tests ainsi créé est connu sous le nom de « suite de tests ». La fonction describe prend deux paramètres, une chaîne et une fonction de rappel, au format suivant :
describe(string describing the test suite, callback);

Il est possible d’utiliser autant de fonctions describe() que l’on veut. Cela dépend de la manière dont on voudra organiser nos tests en suites. Il est également possible d’imbriquer plusieurs fonctions describe() pour arriver à des suites de tests très structurés.

  • it : cette fonction crée un test spécifique, qui se place généralement à l’intérieur d’une fonction describe(). Comme cette dernière, la fonction it() prend deux paramètres (une chaîne et une fonction de rappel) au format suivant :
it(string describing the test, callback);

Le test sera créé à l’intérieur de la fonction de rappel en créant une assertion à l’aide de la fonction expect.

  • expect : cette fonction est la pierre angulaire du test unitaire. C’est elle qui va nous dire si le test « passe » ou bien « échoue ». On dit que l’on fait une assertion, c’est à dire qu’on va affirmer que quelque chose est vrai. Dans Jasmine, l’assertion est en deux parties : la fonction expect() elle même et le « matcher ». La fonction expect() recueille la valeur réelle provenant du code à tester (par exemple, une valeur booléenne). La fonction « matcher » quand à elle va. décrire la valeur attendue. Les exemples de fonctions « matcher » incluent toBe(), toContain(), toThrow(), toEqual(), toBeTruthy(), toBeNull(), et bien plus encore. Plus d’informations sur la page de Jasmine. On utilisera cette fonction de la façon suivante :
expect(value).matcher(expected_value)

Gardez à l’esprit que l’idéal est d’avoir une seule assertion par test. Lorsque vous avez plusieurs assertions dans un même test, chaque assertion doit être vraie pour que le test réussisse.

Premier test

Dans ce premier test, nous utiliserons la fonction describe() pour regrouper les tests dans une suite puis la fonction it() pour séparer les tests individuels. Ce test consiste à vérifier que la valeur booléenne true est égale à la valeur booléenne true.

describe('Chapter #2', () => { ①
  it('first test', () => { ②
    expect(true).toBeTrue(); ③
  });
});
  • ① → regroupe les tests dans une suite
  • ② → sépare les tests individuels
  • ③ → affirme que true est identique à true à l’aide du « matcher » toBeTrue()

Pour lancer la suite de tests, il suffit d’utiliser la commande suivante (nous sommes dans un contexte Angular 😉) :

# ./node_modules/.bin/ng serve

Jasmine utilise le navigateur Chrome, ce dernier devrait automatiquement s’ouvrir comme sur la figure 2.1.

Figure 2.1 – On vérifie que true est effectivement égal à true.

Et si le test échoue ?

Dans ce cas Jasmine va tout de suite nous alerter. Ajoutons le test suivant à notre suite :

describe('Chapter #2', () => {
  ...

  it('3 + 4 should be equal to 7', () => {
    expect(3 + 4).toBe(8);
  });
});

Snippet 2.1

Comme on peut s’en douter, lorsqu’un test échoue, on obtient une erreur. Sous le test défaillant, on aperçoit la trace de la pile d’exécution, ce qui est très utile pour repérer rapidement quelle portion du code pose problème. Ici on n’a que deux tests, mais dans un contexte d’une suite avec des milliers de tests, c’est très utile…

Figure 2.2 – Echec du test unitaire.

La pile d’exécution est toujours présentée dans le même ordre : les appels les plus récents en premier. Il suffit donc d’inspecter la première ligne de la pile pour évaluer d’un coup d’oeil la situation 😉 !

Tester une classe

Tester une classe Javascript se fait directement, sans passer par l’artillerie lourde du framework Angular. En guise d’exemple, nous allons créer deux fichiers :

Figure 2.3 – La classe User et son fichier de tests
  1. src/app/models/user.ts : une classe (User) qui représente un utilisateur.
  2. src/app/models/user.spec.ts : le fichier des tests unitaires. Il doit se trouver dans le même répertoire que le code qu’il teste (ici la classe User).

La classe User, qui représente un utilisateur, est très simple :

export class User {
  uid: number;
  username: string;
  email: string;

  constructor(uid: number, username: string, email: string) {
    this.uid = uid;
    this.username = username;
    this.email = email;
  }

  getUsername(): string {
    return this.username;
  }

  getUid(): number {
    return this.uid;
  }
}

src/app/models/user.ts

Avant d’aller plus loin, nous devons faire un peu de théorie. Dans les fichiers de tests, la première chose à faire est d’importer les dépendances. On pourra le faire comme ceci :

import { User } from "./user";

Ensuite, nous allez créer une suite de tests à l’aide de la méthode describe(), que l’on pourra nommer « User Class Test » :

describe('User Class Test', () => {
});

A l’intérieur de la fonction describe(), nous devons créer une variable qui contiendra notre futur objet instancié :

describe('User Class Test', () => {
  let user: User;
});

Chaque variable doit être réinitialisée avant d’exécuter un test. La réinitialisation des variables qui ont été manipulées dans un test permet de s’assurer que chaque test s’exécute indépendamment et que les variables précédemment manipulées n’interfèrent pas avec les tests suivants. La prévention de telles interférences permet d’éviter des effets secondaires indésirables. Un exemple typique pourrait être la modification d’une variable dans un test, puis l’utilisation accidentelle de la variable modifiée dans un autre test.

La partie des tests où nous définissons des variables comme celle-ci est connue sous le nom de « set up ». Dans ce « set up », nous utiliserons la méthode beforeEach() pour initialiser la variable user à chaque exécution d’un test. Nous pouvons le faire directement sous la déclaration de la variable user :

beforeEach(() => {
  user = new User(3, 'nicolas', 'test@test.com');
});

La fonctions beforeEach sera systématiquement utilisée pour configurer les tests et exécuter des expressions avant leur exécution. Ici on se contente d’initialiser un variables, mais parfois il y a plus de choses à faire.

Bien. Nous y sommes presque. Comment tester notre classe. Dans ce type de situation, il est usuel de s’assurer que l’objet s’instancie correctement. Pour ce faire, nous allons utiliser la négation d’un matcher existant, à savoir not.toBeNull(). C’est à dire que le succès du test sera atteint que si notre objet n’est « null » :

it('should have a valid constructor', () => {
  expect(user).not.toBeNull();
});

Précisons qu’à l’instar de la fonction beforeEach, nous disposons d’une fonction similaire afterEach à ceci près qu’elle s’exécute après chaque test. Il s’agit de l’endroit idéal pour s’assurer que les instances de variables sont détruites, ce qui évitera les potentielles fuites de mémoire. Ici on pourrait réinitialiser notre variable :

afterEach(() => {
  user = null;
});

Dans ce cas précis ce n’est pas très pertinent car le « set up » du test ré-initialise la variable à chaque fois.

A présent, assemblons tout ça pour obtenir un tout cohérent :

import { User } from "./user"; ①

describe('User Class Test', () => {
  let user: User; ②

  beforeEach(() => { ③
    user = new User(3, 'nicolas', 'test@test.com');
  });

  it('should have a valid constructor', () => { ④
    expect(user).not.toBeNull();
  })

  afterEach(() => { ⑤
  });
});

src/app/models/user.spec.ts

Récapitulons les différentes étapes qui composent ce test :

  • ① → on importe la classe User, l’unique dépendance de ce test.
  • ② → déclaration de la variable user, de type User.
  • ③ → la fonction beforeEach() sera exécutée avant chaque test unitaire, comme son nom l’indique 🙂.
  • ④ → teste la variable user, qui ne doit pas être null.
  • ⑤ → la fonction afterEach() sera exécutée après chaque test unitaire, comme son nom l’indique 🙂.

Devons-nous commencer la description du test (premier argument de it)avec « should » ? Vous l’avez peut-être remarqué mais jusqu’à présent tous les tests ont commencé leur description avec « should ». C’est une syntaxe courante qui rend les tests plus faciles à lire. Mais ce n’est pas une obligation – vous devez rédiger vos descriptions de la manière la plus logique pour vous et votre équipe.

Tout est prêt. Voyons le résultat dans Jasmine :

Figure 2.4 – Le test passe.

Folie ! Tout fonctionne à merveille. Améliorons tout de suite notre test en vérifiant que le constructeur initialise bien nos propriétés en ajoutant ces 3 tests unitaires :

it('should set « username » property correctly', () => {
  expect(user.username).toEqual('nicolas');
});

it('should set « email » property correctly', () => {
  expect(user.email).toEqual('test@test.com');
});

it('should set « uid » property correctly', () => {
  expect(user.uid).toBe(3);
});

Bien vérifieur que les nouveaux tests passent. Jasmine devrait donc afficher un résultat semblable à la figure 2.5 :

Figure 2.5 – Les setters fonctionnent.

Pour être complet, on pourra ajouter un test pour chaque méthode de la classe :

it('should get the « uid » property', () => {
  expect(user.getUid()).toBe(3);
});

it('should get the « username » property', () => {
  expect(user.getUsername()).toBe('nicolas');
});

Par la suite, s’assurer que tous les tests passent :

Figure 2.6 – Une belle suite de tests unitaires.

Ce que nous avons appris

L’écriture de tests unitaires de base peut s’avérer très utile sur certains éléments du framework Angular comme des fonctions simples, des classes et parfois même des services. En dehors de Jasmine, ces tests n’utiliseront pas de dépendances, ce qui les rends très facile à écrire et à maintenir.

On a commencé à explorer doucement la partie « comportement » du framework Jasmine en se forçant à décrire du mieux possible ce que le test unitaire doit vérifier. Une bonne pratique consiste à toujours écrire les énoncés des tests en anglais, car le code lui même utilise des mots anglais.

La plupart des tests unitaires que vous serez amené à écrire auront toujours le même schéma : d’abord une section d’importation des dépendances, puis une section pour créer la suite de tests, une section pour configurer les tests, une section pour les tests eux-mêmes et pour finir une section pour « nettoyer » la mémoire entre chaque tests.