IMDW320 - Tests et Intégration Continue/CI - 09/02/2024

Description

Compétences visées

  • Écrire des tests automatisés maintenables
  • Intégrer la ou les phase de tests aux pipelines CI étudiés dans le module CD/IINF170

Pourquoi tester ?

Quels problèmes cherche-t-on à résoudre quand on teste notre code ?

L’ingénierie logicielle est plus “empirique” que d’autres disciplines d’ingénierie reposant - par exemple - sur des calculs précis.

Concevoir, développer et maintenir une application fiable et évolutive s’avère complexe, d’autant plus que de nombreuses personnes sont impliquées.

Pour le dire plus simplement : il est difficile de produire des programmes informatiques exempts de bugs.

Tester une application manuellement est compliqué et chronophage : au moindre changement, il faudrait tout retester, car il est difficile d’être certain·e qu’un changement mineur n’ait pas d’impacts imprévus.

On va parler ici de tests automatisés : des tests que l’on va pouvoir effectuer régulièrement afin de s’assurer que notre code fonctionne correctement. Ces tests vont consister en du code spécifique qui va tester le code de l’application proprement dite (ou de ses composants).

Les tests visent l’amélioration de la robustesse et de la qualité des applications, sous différents aspects :

  • La réduction des bugs dans les applications livrées.
  • La facilitation des refactorisations et des mises à jour du code.
  • L’augmentation de la confiance dans le code lors des déploiements.
  • L’amélioration de la documentation - des tests bien écrits et lisibles pouvant servir à documenter le comportement du code de l’application.

Concepts

  • Qualité
  • Tests
  • Assertions
  • Definition of done
  • TDD

Types de tests

  • Tests unitaires : tester une unité (fonction, classe) de façon isolée
  • Tests d’intégration : tester plusieurs composants de l’application de concert (cela inclut du code qui appelle des dépendances externes telles que bases de données, APIs tierces)
  • Tests fonctionnels : tester une fonctionnalité précise (ce qui peut impliquer des tes)
  • Tests end-to-end : tester l’application de la même façon que le ferait un·e utilisateur ou utilisatrice.

TDD

Le TDD ou Test-Driven Development se traduit en développement guidé par les tests. C’est une méthodologie de développement qui implique d’écrire d’abord des tests avant d’écrire le code à tester.

Cette approche comporte plusieurs avantages :

  • Favoriser la réflexion a priori sur ce qu’on cherche à faire, sur comment le faire.
  • Délimiter précisément le périmètre de ce que doit effectuer notre code : faire juste ce qu’il faut pour satisfaire une exigence, et pas plus.
  • Détecter immédiatement les problèmes au fur et à mesure qu’ils apparaissent.

L’approche TDD incite à reconsidérer la définition d’une fonctionnalité terminée (Definition of done) : une fonctionnalité est considérée comme complète lorsqu’elle est implémentée, vérifiée (code review), documentée, intégrée et testée.

Tests unitaires

Un test unitaire permet de tester une unité telle que :

  • fonction
  • classe
  • module

Nous allons commencer avec des tests unitaires de fonctions en TypeScript. Les outils que nous allons voir ici serviront également pour d’autres types de tests.

Mise en place

Créez un nouveau répertoire intro-tests-typescript et ouvrez-le dans votre IDE.

Placez-vous dans ce dossier et initialisez le projet avec les commandes suivantes :

git init
npm init -y
yarn add --dev typescript @types/node @types/jest ts-node jest ts-jest
npx tsc --init
mkdir src
mkdir test

Dans l’ordre, on a initialisé un dépôt Git, initialisé un package.json, installé TypeScript, Jest (framework de test), initialisé tsconfig.json.

Créez un fichier .gitignore et ajoutez-y node_modules.

Un premier test “naïf”.

Dans un premier temps, on ne va pas utiliser tous les outils qu’on vient d’installer, mais aborder les tests avec une approche simpliste.

Créez un dossier src et créez un fichier math.ts contenant le code suivant :

// src/math.ts
export const add = (a: number, b: number): number => a + b;

export const sub = (a: number, b: number): number => a - b;

Ce code est volontairement trivial, afin de se concentrer sur les tests.

À quoi sert un test ? À vérifier que le code son écrit se comporte de la façon attendue (expected). Il s’agit donc d’exécuter notre code, et de comparer ses résultats à ce qu’on attend.

Par exemple, si on appelle la fonction add(2, 2), on s’attend en retour à recevoir 4. Voyons comment traduire cela en code.

Sous test/, créez un fichier math.test.ts, avec ce contenu :

// test/math.test.ts
import assert from "node:assert";
import { add } from "../src/math";

// on va appeler la fonction `add` avec 2 paramètres.
const result = add(4, 5);
// on s'attend à ce que le résultat soit 9.
const expected = 9;
// Si le résultat est différent de l'attendu, la ligne suivante va _throw_ une erreur.
assert.equal(
  result,
  expected,
  `add - Failure! Expected ${expected}, received: ${result}.`
);
console.log("add - Success!");

Pour lancer ce test, vous pouvez exécuter :

npx ts-node test/math.test.ts

En principe, vous verrez juste s’afficher Success!.

Vous pouvez ajouter et committer tout le contenu du répertoire en l’état.

Pour voir comment le test se comporterait, modifiez temporairement la fonction add du fichier math.ts :

// Renvoie volontairement un résultat incorrect
export const add = (a: number, b: number): number => 0;

Relancez : npx ts-node test/math.test.ts. Vous devriez obtenir un résultat semblable à ceci :

/home/johndoe/Code/intro-tests-typescript/test/math.test.ts:10
assert.equal(
       ^
AssertionError [ERR_ASSERTION]: add - Failure! Expected 9, received: 0.
    at Object.<anonymous> (/home/johndoe/Code/intro-tests-typescript/test/math.test.ts:10:8)
    at Module._compile (node:internal/modules/cjs/loader:1376:14)
    at Module.m._compile (/home/johndoe/Code/intro-tests-typescript/node_modules/ts-node/src/index.ts:1618:23)
    at Module._extensions..js (node:internal/modules/cjs/loader:1435:10)
    at Object.require.extensions.<computed> [as .ts] (/home/johndoe/Code/intro-tests-typescript/node_modules/ts-node/src/index.ts:1621:12)
    at Module.load (node:internal/modules/cjs/loader:1207:32)
    at Function.Module._load (node:internal/modules/cjs/loader:1023:12)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:135:12)
    at phase4 (/home/johndoe/Code/intro-tests-typescript/node_modules/ts-node/src/bin.ts:649:14)
    at bootstrap (/home/johndoe/Code/intro-tests-typescript/node_modules/ts-node/src/bin.ts:95:10) {
  generatedMessage: false,
  code: 'ERR_ASSERTION',
  actual: 0,
  expected: 9,
  operator: '=='
}

Que nous dit ce long message d’erreur ? Il faut se pencher sur les dernières lignes.

Le code d’erreur est ERR_ASSERTION. Il s’agit d’une erreur d’assertion. Assertion est synonyme d’affirmation. En écrivant assert.equal(result, expected);, on indique que result devrait être égal à expected.

La ligne actual correspond ici à la valeur renvoyée par add, et stockée dans result.

La ligne expected correspond à la valeur attendue.

La valeur réelle (actual) étant différente de l’attendue, une erreur a été générée.

Ce test simple illustre un principe général des tests : on va définir nos attentes avec des lignes de code similaires à notre assert.equal(). Si le code est correct, l’assertion ou attente définie par assert.equal() va être satisfaite. Dans le cas contraire, une erreur va être produite.

Remettez le code de add.ts dans son état antérieur en renvoyant a + b au lieu de 0.

Peut mieux faire…

Le test ci-dessus, bien qu’il fonctionne, n’est pas idéal. Dans le cas présent, on n’a testé qu’une seule des deux fonctions de notre module math.ts.

Si on ajoute des fonctions à ce module et qu’on souhaite toutes les tester, on va devoir :

  • Soit ajouter beaucoup de code de test à math.test.ts, et le structurer (par exemple en fonctions) pour que chaque test de chaque fonction n’impacte pas les autres tests.
  • Soit écrire plusieurs fichiers (add.test.ts, sub.test.ts), et dans ce cas, écrire une sorte de “script” pour les lancer tous.

De plus, si on se contente de simples messages de succès/erreur comme ci-dessus, il va rapidement devenir difficile de se repérer dans la “sortie” console produite par l’exécution des tests.

En bref, cette façon de faire est beaucoup trop simpliste pour être efficace sur un projet substantiel.

Des outils à la rescousse

Les méthodologies de test logiciel existent depuis suffisamment longtemps pour que des outils efficaces aient été développés dans tous les langages de programmation.

En JavaScript, les frameworks Mocha et Jasmine sont longtemps restés les plus utilisés. On peut citer également QUnit.

Facebook/Meta a introduit Jest comme framework de test, notamment pour tester React et les applications écrites avec.

Jest est devenu depuis le principal framework de test JavaScript/TypeScript. Mais la communauté continue à produire de nouveaux outils, et des challengers comme vitest ont fait leur apparition.

À quoi servent ces outils ? Ils vont faciliter :

  • l’organisation/structuration des tests,
  • leur écriture,
  • leur exécution,
  • le reporting (affichage des résultats)

La plupart fournissent des fonctionnalités similaires, avec même certains noms de fonctions identiques.

Nous allons utiliser Jest car c’est le plus complet et le plus utilisé. Nous l’avons déjà installé lors de l’initialisation du projet.

Jest - Un premier test

On va continuer à travailler dans le même dossier.

Remplacez le contenu de test/math.test.ts par ceci :

import assert from "assert";
import { add } from "../src/math";

test("add should return 5 for 2 + 3", () => {
  const expected = 5;
  const actual = add(2, 3);
  assert.equal(actual, expected);
});

test("add should return 9 for 4 + 5", () => {
  const expected = 9;
  const actual = add(4, 5);
  assert.equal(actual, expected);
});

À noter : Jest se sert de l’extension des fichiers pour détecter les fichiers de tests. Il faut leur donner l’extension .test.js ou .spec.js en JavaScript ; .test.ts ou .test.ts en TypeScript.

On rentrera dans les détails un peu plus tard, mais voici déjà quelques indications sur ce qu’apporte l’utilisation de Jest.

Ici, on a écrit deux tests, chacun étant défini par l’appel à la fonction built-in (intégrée) test fournie par Jest.

Cette fonction prend deux arguments :

  • un label pour décrire explicitement ce que fait le test et ce qu’on attend.
  • une fonction anonyme contenant le test proprement dit.

Un avantage immédiat - par rapport à l’approche “naïve” précédente - est que Jest nous fournit une façon d’écrire plusieurs tests, indépendant les uns des autres. Si l’un échoue ou réussit, cela n’affectera pas les autres.

Configurer Jest pour TypeScript

Nous avons déjà installé toutes les dépendances nécessaires.

Mais si on lance en l’état npx jest pour invoquer Jest, on va obtenir une erreur, car il manque un fichier de configuration pour faire fonctionner Jest avec TypeScript.

Lancez :

npx ts-jest config:init

Cela va créer un fichier jest.config.js avec les des paramètres prédéfinis (presets) pour TypeScript.

Dans ce fichier, ajoutez la ligne verbose: true pour aboutir à ceci :

/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
  preset: "ts-jest",
  testEnvironment: "node",
  verbose: true,
};

Tant qu’à effectuer un peu de configuration, modifiez la valeur derrière "test" dans le package.json. Vous aviez jusqu’ici :

  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },

Modifiez le fichier de façon à obtenir ceci :

  "scripts": {
    "test": "jest",
    "testw": "jest --watch"
  },

Grâce à cela, vous pourrez :

  • lancer Jest en mode “one-shot” avec npm test
  • lancer Jest en mode “watch” avec npm run testw ou yarn testw

Lancez d’abord npm test. Vous devriez obtenir :

$ npm test

> code@1.0.0 test
> jest

 PASS  test/math.test.ts
  ✓ add should return 5 for 2 + 3 (1 ms)
  ✓ add should return 9 for 4 + 5

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        1.184 s, estimated 2 s
Ran all test suites.

Vous pouvez déjà remarquer un autre bénéfice par rapport à l’approche naïve initiale : Jest fournit un reporting clair et lisible sur les tests qui ont réussi.

Vous pouvez essayer npm testw : Jest se lance et ne quitte pas. Il va attendre que le code et/ou les tests soient modifiés, et relancer les tests à chaque modification.

Comparateurs (matchers) Jest

Les tests que nous avons écrits auraient pu fonctionner quasiment tels quels avec un autre framework de test comme Mocha ou Jasmine. Mais Jest fournit des outils que ces derniers n’ont pas, à savoir une bibliothèque d’assertions intégrée.

assert est la bibliothèque d’assertions livrée en standard avec Node.js, mais elle est assez limitée. Les assertions écrites avec les méthodes fournies par ce module ne sont pas très lisibles ou explicites.

D’autres bibliothèques existent, telles que Chai, qui permet d’écrire d’assertion dans des styles plus proches d’une façon de parler naturelle. Chai est très souvent utilisé avec Mocha et Jasmine.

Il se trouve que Jest fourni sa propre bibliothèque d’assertions.

Dans les deux tests, remplacez :

assert.equal(actual, expected);

Par :

expect(actual).toBe(expected);

Ceci peut se lire ainsi : “on s’attend à ce que le résultat effectif (actual) de l’appel de fonction soit la valeur attendue (expected)”.

C’est une façon plus naturelle d’écrire des assertions.

toBe est un “comparateur” ou matcher. Jest propose un grand nombre de matchers, décrits dans la section Utilisation des comparateurs de sa documentaton.

Exercices

Exercice 1

Écrivez des tests pour la fonction sub. Vous pouvez les écrire dans math.test.ts, à la suite des tests existants.

Exercice 2

Implémentez, suivant l’approche TDD, la fonction mul.

  • Écrivez d’abord les tests.
  • Dans un premier temps, vos tests vont échouer car le code à tester n’existe pas encore.
  • Écrivez ensuite la fonction mul dans math.ts.

Des solutions possibles de ces exercices sont proposées ici.

Organiser ses tests

Après les exercices, vos tests doivent ressembler à :

import { add, sub, mul } from "../src/math";

test("add should return 5 for 2 + 3", () => {
  // ...
});

test("add should return 9 for 4 + 5", () => {
  // ...
});

test("sub should return 3 for 8 - 5", () => {
  // ...
});

test("sub should return 4 for 13 - 9", () => {
  // ...
});

test("mul should return 27 for 9 * 3", () => {
  // ...
});

test("mul should return 55 for 55 * 11", () => {
  // ...
});

On peut organiser tout cela un peu mieux : par exemple, regrouper ensemble les tests relatifs à une fonction.

Pour cela, nous allons utiliser une autre fonction fournie par jest : la fonction describe, qui prend également comme arguments un label et une fonction anonyme.

Voici à quoi pourraient ressembler des tests mieux organisés. On en profite pour remplacer test par it, ce qui est la même chose - mais plus fréquemment utilisé et plus “explicite” : “it should return 5…”.

import { add, sub, mul } from "../src/math";

describe("math module", () => {
  describe("add function", () => {
    it("should return 5 for 2 + 3", () => {
      const expected = 5;
      const actual = add(2, 3);
      expect(actual).toBe(expected);
    });

    it("should return 9 for 4 + 5", () => {
      const expected = 9;
      const actual = add(4, 5);
      expect(actual).toBe(expected);
    });
  });

  describe("sub function", () => {
    it("should return 3 for 8 - 5", () => {
      const expected = 3;
      const actual = sub(8, 5);
      expect(actual).toBe(expected);
    });

    it("should return 4 for 13 - 9", () => {
      const expected = 4;
      const actual = sub(13, 9);
      expect(actual).toBe(expected);
    });
  });

  describe("mul function", () => {
    it("should return 27 for 9 * 3", () => {
      const expected = 27;
      const actual = mul(9, 3);
      expect(actual).toBe(expected);
    });

    it("should return 55 for 55 * 11", () => {
      const expected = 55;
      const actual = mul(11, 5);
      expect(actual).toBe(expected);
    });
  });
});

Les describe peuvent être imbriqués. Outre la lisibilité des tests eux-mêmes, cela rend le reporting plus lisible également :

$ yarn test
yarn run v1.22.18
$ jest
 PASS  test/math.test.ts
  math module
    add function
      ✓ should return 5 for 2 + 3 (3 ms)
      ✓ should return 9 for 4 + 5 (1 ms)
    sub function
      ✓ should return 3 for 8 - 5 (1 ms)
      ✓ should return 4 for 13 - 9
    mul function
      ✓ should return 27 for 9 * 3 (1 ms)
      ✓ should return 55 for 55 * 11

Test Suites: 1 passed, 1 total
Tests:       6 passed, 6 total
Snapshots:   0 total
Time:        1.97 s, estimated 3 s
Ran all test suites.
✨  Done in 3.30s.

Tester du code renvoyant des erreurs

On n’a pas abordé la fonction div du module math.ts. Elle peut donner lieu à un cas de figure intéressant : la division par zéro n’est en principe pas possible !

Si vous exécutez console.log(10/0) dans la console d’un navigateur, vous obtenez : Infinity. C’est un comportement commun avec d’autres langages conformes au standard IEEE 754 (arithmétique des nombres à virgule flottante).

On pourrait décider que notre fonction div lève une exception plutôt que de renvoyer Infinity.

Jest fournit un matcher permettant de s’assurer qu’une fonction renvoie une erreur.

Voici un exemple (vous pouvez l’ajouter dans un fichier de test, par exemple test/throws.test.ts, puis lancer npm test).

function throwsError() {
  throw new Error("An error");
}

it("should throw", () => {
  expect(() => {
    throwsError();
  }).toThrow("An error");
});

On enveloppe (wrap) l’appel dont on sait qu’il va lever une exception, dans une fonction fléchée. Jest va appeler cette fonction fléchée et vérifier qu’elle lève l’exception (dont on peut optionnellement préciser le message en argument de toThrow).

Exercice 3

À partir de cet exemple, écrivez deux tests puis une implémentation pour div, suivant cette spécification :

  • div(a, b) doit renvoyer le résultat de la division de a par b.
  • Si le 2ème argument b passé à div vaut zéro, elle doit lever une exception avec le message Can't divide by zero.
  • L’un de vos tests doit tester le cas où on passe b valant 0.
  • L’autre cas de test doit tester un cas ou b est non-nul.

Solution ici si vous êtes bloqué·e.

Identité vs Égalité

Une distinction cruciale, dans les comparateurs Jest, est celle entre toBe et toEqual.

toBe ne fonctionne que sur des types primitifs (number, string, boolean).

On ne peut pas l’utiliser pour vérifier l’égalité des valeurs de tableaux ou ou d’objets.

Pour cela, il faut utiliser toEqual. Vous pouvez créer ces fichiers.

// src/array-utils.ts
export const removeSecondFromArr = (arr: any[]) =>
  arr.filter((it: any, i: number) => i !== 1);
// test/array-utils.test.ts
import { removeSecondFromArr } from "../src/array-utils";

it("should remove the second element", () => {
  const actual = removeSecondFromArr([1, 2, 3, 5, 7]);
  expect(actual).toEqual([1, 3, 5, 7]);
});

Autres comparateurs et fonctionnalités de Jest

toBeDefined(), toBeUndefined()

// src/array-utils.ts (ajouter)
export const findFirstEven = (arr: number[]) =>
  arr.find((n: number) => n % 2 === 0);
// test/array-utils.test.ts
import { findFirstEven } from "../src/array-utils";

describe("findFirstEven", () => {
  it("should return something", () => {
    const actual = findFirstEven([5, 9, 14, 17]);
    expect(actual).toBeDefined();
  });
  it("should return nothing", () => {
    const actual = findFirstEven([5, 9, 13, 17]);
    expect(actual).toBeUndefined();
  });
});

Inversion avec .not

On va ici utiliser un nouveau matcher : toContain, qui permet de vérifier qu’un élément est présent dans un tableau ; et le combiner avec .not, qui permet d’inverser une comparaison.

// src/array-utils.ts
export const keepEven = (arr: number[]) =>
  arr.filter((n: number) => n % 2 === 0);
// test/array-utils.test.ts
import { keepEven } from "../src/array-utils";

describe("filterEven", () => {
  it("result should not contain 1", () => {
    const actual = keepEven([1, 2, 4, 6]);
    expect(actual).not.toContain(1);
  });
  it("result should contain 6", () => {
    const actual = keepEven([3, 6, 9]);
    expect(actual).toContain(6);
  });
});

Autres matchers communs

Se familiariser avec les matchers Jest prend un peu de temps ! Il est utile de parcourir la documentation à cette fin.

Tests asynchrones

Les tests vus jusqu’ici étaient synchrones.

Jest permet également de tester du code asynchrone.

Voir cette section de la documentation.

Avec du code asynchrone, le callback fourni à it doit :

  • soit retourner une Promise
  • soit utiliser async/await

Prenons un exemple pas forcément réaliste : une version asynchrone de la fonction div :

// ajouter dans math.ts
export const divAsync = async (a: number, b: number): Promise<number> => {
  if (b === 0) {
    return Promise.reject(new Error("Can't divide by zero"));
  }
  return Promise.resolve(a / b);
};

Notez que les callbacks fournis à it sont précédés d’async.

// dans math.test.ts
import { add, sub, mul, div, divAsync } from "../src/math";

describe("math module", () => {
  // ... tests existants ...

  describe("divAsync function", () => {
    it("should resolve a number", async () => {
      // ici on "await" pas la promise
      const actual = divAsync(24, 4);
      // mais on await le expect
      await expect(actual).resolves.toBe(6);
    });

    it("should reject", async () => {
      // ici on "await" pas la promise
      const actual = divAsync(7, 0);
      // mais on await le expect
      await expect(actual).rejects.toThrow("Can't divide by zero");
    });
  });
});

Autre exercice

SkillMatcher

On va aborder des tests un peu plus complexes, sur une classe.

Voici le squelette d’une classe SkillMatcher et de types associés.

export type Candidate = {
  name: string;
  skills: string[];
};

export class SkillMatcher {
  private candidates: Candidate[];

  constructor(candidates: Candidate[]) {}

  /**
   * Renvoie les candidats ayant les compétences demandées
   */
  public getCandidatesHavingSkills(skills: string[]): Candidate[] {}

  /**
   * Renvoie un objet indiquant la "fréquence" des compétences dans la base de candidats.
   *
   * Par exemple : { HTML: 7, JavaScript: 4, Git: 9 }
   */
  public getSkillStats(): Record<string, number> {}
}

Voici comment le code “client” va utiliser cette classe :

const sampleCandidates: Candidate[] = [
  {
    name: "Irene Kennedy",
    skills: ["HTML", "CSS", "React"],
  },
  {
    name: "Raul Ramirez",
    skills: ["HTML", "CSS", "Angular", ".NET"],
  },
  { name: "Bobby Diaz", skills: ["C", "C++", "Rust"] },
];
const matcher = new SkillMatcher(sampleCandidates);
const webCandidates = matcher.getCandidatesHavingSkills(["HTML", "CSS"]);
// devrait renvoyer [
//  { name: "Irene Kennedy", ... },
//  { name: "Raul Ramirez", ... },
// ]

const stats = matcher.getSkillStats();
// devrait renvoyer { HTML: 2, CSS: 2, React: 1, .... }

Vous allez devoir écrire les tests et les implémentations pour la classe SkillMatcher : le constructeur et les deux méthodes.

Vous pouvez copier le squelette fourni dans src/skill-matcher.ts et créer le test dans test/skill-matcher.test.ts.

Tests d’intégration

Principe

Les tests précédents ont consisté à tester des unités indépendantes : fonctions, classes, etc.

Dans une application complexe, plusieurs composants interagissent entre eux.

Un test d’intégration est un test qui implique plusieurs composants d’une application.

“Composant” est à entendre au sens large :

  • Du code qu’on a écrit
  • Une base de données
  • Une API tierce
  • etc.

Repo

On va illustrer le concept de tests d’intégration avec des tests de endpoints sur une API Node.js / Express.

Vous allez partir de ce repo de base à forker (puis cloner le fork).

Le repo contient une app Express / TypeScript configurée avec :

  • Jest,
  • une BDD SQLite3,
  • des migrations permettant de faire évoluer le schéma de BDD pour créer des tables sample et candidate,
  • des fonctions d’accès à la BDD avec un “modèle” permettant les opérations de lecture, création et mise à jour sur la table sample (src/models/sample.ts).
  • un routeur simple, avec pour l’instant une seule route : POST /api/samples qui renvoie une réponse vide.

Il vous restera notamment une dépendance à ajouter, et des routes à implémenter.

La dépendance en question s’appelle supertest et permet d’effectuer des requêtes sur une app Express, sans avoir à démarrer le serveur.

Installez-la avec :

yarn add --dev supertest @types/supertest

Exemple de test d’intégration

Vous pouvez créer un fichier test/app.integration.test.ts, avec ce contenu :

// test/app.integration.test.ts
import request from "supertest";
import app from "../src/app";

describe("Home page integration", () => {
  it('should send "Hello, World!" if no param is given', async () => {
    // Act
    const res = await request(app)
      .get("/")
      // permet de faire une assertion sur le code HTTP de la réponse
      .expect(200);

    // Assert
    expect(res.body).toHaveProperty("message", "Hello, World!");
  });
});

Explications :

  • On envoie une requête sur l’app qu’a préalablement importée,
  • C’est une requête GET sur le endpoint /.
  • On s’attend à ce qu’elle renvoie un code 200 : .expect(200) est déjà une assertion que permet d’effectuer supertest.
  • On effectue une assertion plus classique avec un matcher de Jest.

Exercices

1. Ajouter un 2ème test case pour ces tests d’intégration.

Ajoutez un deuxième test case, vérifiant que la home page devrait renvoyer Hello, Name! si un paramètre est fourni dans l’URL comme ceci : ?name=Name.

Ensuite, modifiez src/app.ts pour faire passer les tests.

2. Remettre en place un pipeline d’intégration continue.

Je l’ai supprimé du repo de base : reprenez-en un (ça ne doit pas être dur à trouver), et adaptez-le :

  • enlevez/commentez les parties de build d’images Docker et déploiement pour l’instant
  • ne conservez que les stages install, lint, build et ajoutez-une stage test au bon endroit

3. Écrire des tests d’intégration pour les opérations CRUD sur la table sample.

Vous pouvez écrire des tests d’intégration pour les opérations CRUD sur le routeur samples.ts.

Pour vous donner quelques lignes directrices :

Vous pouvez regarder les tests du modèle sample en regardant test/models/sample.test.ts. Cela peut vous donner des idées sur : - comment nettoyer une base de données (ou une table spécifique) avant de relancer chaque test. - comment vérifier que le contenu d’un tableau contient certains éléments, sans qu’on soit certain de leur ordre. Exemple avec les lignes mises en évidence sur le repo.

Référez-vous à la doc de supertest pour des exemples de requêtes en POST, etc.

Vous allez devoir implémenter les endpoints suivants, avec ces “spécifications”.

POST vers /api/samples

  • Corps de requête : vous aller envoyer une requête JSON contenant une propriété name. Exemple : { "name": "My sample" }.
  • À partir du moment où vous avez envoyé des données respectant ce format (avec name non-vide), le endpoint va insérer un objet sample dans la base de données en utilisant sampleModel.create et le renvoyer, avec un status code 201 (Created).
  • Si le corps de requête n’est pas conforme (propriété name absente ou vide), renvoyer une erreur 400 avec un body ayant la forme { error: 'Property "name" missing or empty' }.
  • Si une erreur d’insertion se produit, renvoyer une erreur 500 sous la forme { error: '<Erreur renvoyée par SQLite>' }. Vous pouvez utiliser un block try/catch et renvoyer { error: err.message } (err étant l’erreur “catchée”).

PUT vers /api/samples/:id

  • Dans la 1ère partie de votre test, vous allez devoir insérer manuellement un sample dans la base de données, comme on le fait ici
  • Ensuite, avec supertest, vous allez envoyer une requête PUT vers le endpoint /api/samples/<id> en remplaçant <id> par l’id du sample que vous venez de créer.
  • Les contraintes sur le corps de requête sont identiques à celles sur le endpoint POST.
  • Si la requête est correcte, mettre à jour le modèle en utilisant sampleModel.updateOne, et renvoyer l’objet mis à jour avec un code de statut 200.
  • Une “difficulté” supplémentaire - qui peut être un cas de test à rajouter : si vous envoyez une requête vers /api/samples/<id> avec un id qui n’existe pas, le modèle sampleModel.updateOne va renvoyer undefined. Vous pouvez tester ce cas pour renvoyer une erreur 404 avec par exemple { error: "sample not found" }.

GET vers /api/samples/:id

  • Dans la 1ère partie de votre test, insérer manuellement un sample comme dans le endpoint PUT
  • envoyer une requête GET vers /api/samples/<id> en remplaçant <id> par l’id du sample que vous venez de créer.
  • le endpoint va utiliser sampleModel.getOneById pour récupérer la donnée en base.
  • Si la donnée existe, on l’envoie avec un code 200.
  • Sinon, on envoie une erreur 404 avec { error: "sample not found" }.

GET vers /api/samples

  • Dans la 1ère partie de votre test (Arrange), insérer manuellement plusieurs samples comme ici.
  • Le endpoint va interroger la base en utilisant sampleModel.getAll pour récupérer tous les samples.
  • Il va renvoyer ce tableau d’objets avec un code 200.

4. Répéter la même chose pour le CRUD sur candidate.

  • Vous pourrez ajouter quelques contraintes : par exemple, que le body contienne un email valide ; qu’il contienne un tableau skills de chaînes de caractères (qui seront stockés “sérialisés dans la colonne skills sous la forme skill1,skill2,skill3.
  • Vous pouvez faire les vérifications sur les données entrantes (name, email, body) manuellement ou aller un peu plus loin en utilisant une bibliothèque de validation comme Express Validator.

Solutions des exercices - tests unitaires

Solution exercice 1

Tests pour sub.

// test/math.test.ts
// on a supprimé l'import d'assert, ajouté sub
import { add, sub } from "../src/math";

// < ... tests pour add ... >

test("sub should return 3 for 8 - 5", () => {
  const expected = 3;
  const actual = sub(8, 5);
  expect(actual).toBe(expected);
});

test("sub should return 4 for 13 - 9", () => {
  const expected = 4;
  const actual = sub(13, 9);
  expect(actual).toBe(expected);
});

Solution exercice 2

Tests pour mul.

// test/math.test.ts
import { add, sub, mul } from "../src/math";

// < ... tests pour add & sub ... >

test("mul should return 27 for 9 * 3", () => {
  const expected = 27;
  const actual = mul(9, 3);
  expect(actual).toBe(expected);
});

test("mul should return 55 for 55 * 11", () => {
  const expected = 55;
  const actual = mul(11, 5);
  expect(actual).toBe(expected);
});

Implémentation :

// src/math.ts
// ...

export const mul = (a: number, b: number): number => a * b;

Solution exercice 3

Tests pour mul.

// test/math.test.ts
import { add, sub, mul, div } from "../src/math";

// < ... tests pour add, sub, sub ... >

describe("div function", () => {
  test("div should return 7 for 56 / 8", () => {
    const expected = 7;
    const actual = div(56, 8);
    expect(actual).toBe(expected);
  });

  test("div should throw for 5 / 0", () => {
    expect(() => {
      div(5, 0);
    }).toThrow("Can't divide by zero");
  });
});

Implémentation :

// src/math.ts
// ...

export const div = (a: number, b: number): number => {
  if (b === 0) {
    throw new Error("Can't divide by zero");
  }
  return a / b;
};