Cours JavaScript et TypeScript · Module 11: Tests avec Jest

Tester TypeScript avec Jest

8 min de lecture
Dans cette leçon5

TypeScript attrapera un texte passé à la place d'un nombre, mais il n'attrapera pas une erreur de logique : une fonction qui renvoie une santé de 110 au lieu de 100 est irréprochable du point de vue des types. De son côté, Jest ne comprend pas tout seul la syntaxe de TypeScript, il faut donc que quelqu'un la lui traduise. Cette leçon réunit les deux mondes.

TypeScript ajoute un système de types à JavaScript, mais les tests vérifient toujours le comportement au runtime. À Jurassic Park, TypeScript, c'est comme la documentation des espèces : elle nous dit à quoi nous attendre, mais ce sont les tests qui vérifient que les dinosaures se comportent vraiment ainsi. Dans la pyramide des tests, TypeScript fait partie des fondations d'analyse statique, et les tests travaillent un étage plus haut.

Configuration de ts-jest

Pour tester TypeScript avec Jest, nous avons besoin de ts-jest. C'est un transformer : avant que Jest exécute un fichier .ts, ts-jest le compile en JavaScript. Le paquet @types/jest fournit les types de describe, it et expect, pour que l'éditeur et le compilateur les reconnaissent. Je suppose que tu as déjà jest et typescript dans ton projet, sinon ajoute-les à la même commande :

1npm install --save-dev ts-jest @types/jest

Au lieu de @types/jest, tu peux utiliser le paquet @jest/globals et importer describe, it et expect explicitement. @types/jest est maintenu par la communauté DefinitelyTyped, c'est pourquoi la documentation de Jest conseille que sa version corresponde le plus possible à celle de Jest.

Il suffit maintenant de dire à Jest de faire passer les fichiers par ts-jest. Une seule ligne preset s'en charge, et testMatch restreint la recherche aux fichiers .test.ts :

1// jest.config.js
2module.exports = {
3  preset: 'ts-jest',
4  testEnvironment: 'node',
5  testMatch: ['**/*.test.ts'],
6};

Tu peux aussi générer ce fichier avec la commande npx ts-jest config:init. L'option testEnvironment: 'node' signifie que les tests tournent dans Node.js pur, sans navigateur simulé. Il existe aussi une deuxième voie : babel-jest avec le preset @babel/preset-typescript. Babel se contente de supprimer les types sans les vérifier, alors que ts-jest signale par défaut les erreurs de types et fait échouer le test. C'est pourquoi je te recommande ts-jest si ce contrôle supplémentaire compte pour toi.

Tester les interfaces et les types

L'interface Dinosaur décrit la forme d'un spécimen. Le champ diet n'accepte que trois valeurs, car c'est une union de types littéraux. L'écriture Dinosaur['diet'] dans le paramètre récupère le type de ce seul champ, donc la fonction n'acceptera pas de régime hors de la liste :

1// dinosaur.ts
2interface Dinosaur {
3  name: string;
4  species: string;
5  health: number;
6  diet: 'carnivore' | 'herbivore' | 'omnivore';
7}
8
9function createDinosaur(
10  name: string,
11  species: string,
12  diet: Dinosaur['diet']
13): Dinosaur {
14  return {
15    name,
16    species,
17    health: 100,
18    diet,
19  };
20}

Le test compare tout l'objet renvoyé avec toEqual, et vérifie séparément que la santé est un nombre compris entre 0 et 100 :

1// dinosaur.test.ts
2describe('createDinosaur', () => {
3  it('should return object matching Dinosaur interface', () => {
4    const dino = createDinosaur('Rex', 'T-Rex', 'carnivore');
5
6    // TypeScript vérifie les types au compile-time
7    // Les tests vérifient les valeurs au runtime
8    expect(dino).toEqual({
9      name: 'Rex',
10      species: 'T-Rex',
11      health: 100,
12      diet: 'carnivore',
13    });
14  });
15
16  it('should have health as number', () => {
17    const dino = createDinosaur('Blue', 'Velociraptor', 'carnivore');
18    expect(typeof dino.health).toBe('number');
19    expect(dino.health).toBeGreaterThanOrEqual(0);
20    expect(dino.health).toBeLessThanOrEqual(100);
21  });
22});

Une interface ne peut pas être testée directement : après la compilation, elle disparaît sans laisser de trace et n'existe pas au runtime. On teste donc les valeurs, et le compilateur s'occupe des types. L'appel createDinosaur('Pinky', 'Compsognathus', 'fruitarian') ne passerait pas la compilation, et ts-jest signalerait une erreur avant même que le test démarre.

Tester les fonctions génériques

Repository<T> est une interface avec un paramètre de type T : le même repository peut stocker des dinosaures, des gardiens ou des œufs, et TypeScript veille à ce qu'on ne mélange pas les espèces dans un seul repository. La contrainte T extends { id: string } exige que chaque élément ait un champ id :

1// repository.ts
2interface Repository<T> {
3  items: T[];
4  add(item: T): void;
5  findById(id: string): T | undefined;
6  getAll(): T[];
7}
8
9function createRepository<T extends { id: string }>(): Repository<T> {
10  const items: T[] = [];
11
12  return {
13    items,
14    add(item: T) {
15      items.push(item);
16    },
17    findById(id: string) {
18      return items.find(item => item.id === id);
19    },
20    getAll() {
21      return [...items];
22    },
23  };
24}

Dans le test, on remplace T par une interface auxiliaire TestDino, et beforeEach crée un repository tout neuf avant chaque test :

1// repository.test.ts
2interface TestDino {
3  id: string;
4  name: string;
5  dangerous: boolean;
6}
7
8describe('Repository<T>', () => {
9  let repo: Repository<TestDino>;
10
11  beforeEach(() => {
12    repo = createRepository<TestDino>();
13  });
14
15  it('should add and retrieve items', () => {
16    const dino: TestDino = {
17      id: '001',
18      name: 'Rex',
19      dangerous: true,
20    };
21
22    repo.add(dino);
23
24    expect(repo.getAll()).toHaveLength(1);
25    expect(repo.findById('001')).toEqual(dino);
26  });
27
28  it('should return undefined for missing item', () => {
29    expect(repo.findById('999')).toBeUndefined();
30  });
31
32  it('should return copy of items array', () => {
33    repo.add({ id: '001', name: 'Rex', dangerous: true });
34
35    const items = repo.getAll();
36    items.push({ id: '002', name: 'Blue', dangerous: true });
37
38    // Le tableau d'origine n'a pas changé
39    expect(repo.getAll()).toHaveLength(1);
40  });
41});

Le dernier test veille sur une promesse importante : getAll renvoie une copie du tableau ([...items]), donc un push sur le résultat ne modifie pas le repository. Si quelqu'un, lors d'un refactoring, renvoyait directement items, l'alarme retentirait. Remarque aussi que findById renvoie exactement l'objet qu'on a ajouté, donc même toBe passerait à la place de toEqual.

Tester les classes TypeScript

La classe Enclosure combine plusieurs mécanismes de TypeScript. private cache le tableau des dinosaures au code extérieur à la classe, et public readonly dans le constructeur est un raccourci grâce auquel TypeScript crée lui-même les champs en lecture seule name, capacity et securityLevel. La méthode addDinosaur lève des exceptions quand l'enclos est plein et quand la sécurité est trop faible pour des carnivores :

1// enclosure.ts
2class Enclosure {
3  private dinosaurs: Dinosaur[] = [];
4
5  constructor(
6    public readonly name: string,
7    public readonly capacity: number,
8    public readonly securityLevel: number
9  ) {}
10
11  addDinosaur(dino: Dinosaur): void {
12    if (this.dinosaurs.length >= this.capacity) {
13      throw new Error('Enclosure is full');
14    }
15    if (dino.diet === 'carnivore' && this.securityLevel < 8) {
16      throw new Error('Security level too low for carnivores');
17    }
18    this.dinosaurs.push(dino);
19  }
20
21  getDinosaurCount(): number {
22    return this.dinosaurs.length;
23  }
24
25  isEmpty(): boolean {
26    return this.dinosaurs.length === 0;
27  }
28}

Les tests utilisent createDinosaur du premier exemple et un beforeEach qui, avant chaque test, installe un nouvel enclos de trois places avec un niveau de sécurité 9 :

1// enclosure.test.ts
2describe('Enclosure', () => {
3  let enclosure: Enclosure;
4
5  beforeEach(() => {
6    enclosure = new Enclosure('Zone A', 3, 9);
7  });
8
9  it('should create enclosure with properties', () => {
10    expect(enclosure.name).toBe('Zone A');
11    expect(enclosure.capacity).toBe(3);
12    expect(enclosure.securityLevel).toBe(9);
13  });
14
15  it('should start empty', () => {
16    expect(enclosure.isEmpty()).toBe(true);
17    expect(enclosure.getDinosaurCount()).toBe(0);
18  });
19
20  it('should add dinosaur', () => {
21    const dino = createDinosaur('Rex', 'T-Rex', 'carnivore');
22    enclosure.addDinosaur(dino);
23
24    expect(enclosure.getDinosaurCount()).toBe(1);
25    expect(enclosure.isEmpty()).toBe(false);
26  });
27
28  it('should throw when full', () => {
29    const dinos = [
30      createDinosaur('Rex', 'T-Rex', 'carnivore'),
31      createDinosaur('Blue', 'Velociraptor', 'carnivore'),
32      createDinosaur('Delta', 'Velociraptor', 'carnivore'),
33    ];
34
35    dinos.forEach(d => enclosure.addDinosaur(d));
36
37    const extra = createDinosaur('Echo', 'Velociraptor', 'carnivore');
38    expect(() => enclosure.addDinosaur(extra)).toThrow('Enclosure is full');
39  });
40
41  it('should reject carnivore with low security', () => {
42    const lowSecurity = new Enclosure('Zone B', 5, 5);
43    const rex = createDinosaur('Rex', 'T-Rex', 'carnivore');
44
45    expect(() => lowSecurity.addDinosaur(rex)).toThrow(
46      'Security level too low for carnivores'
47    );
48  });
49});

Le test ne va pas fouiller dans le tableau privé dinosaurs, car le compilateur ne le permettrait pas. Il vérifie l'état via les méthodes publiques getDinosaurCount et isEmpty, c'est-à-dire comme le reste du parc utilise la classe. C'est une bonne règle : teste le comportement visible de l'extérieur, et non les détails d'implémentation.

Vérifier la forme d'un objet (matchers asymétriques)

Attention au vocabulaire : en TypeScript, une « type assertion » est l'écriture valeur as Type, qui se contente de faire taire le compilateur et ne vérifie rien au runtime. Les matchers asymétriques permettent de vérifier que des valeurs ont la forme attendue, même quand tu ne connais pas leurs valeurs exactes. getApiResponse est un exemple de fonction qui renvoie une réponse d'API. expect.objectContaining n'exige que les champs listés, expect.any(String) accepte n'importe quelle chaîne, et expect.arrayContaining un tableau contenant les éléments indiqués :

1test('response has correct shape', () => {
2  const response = getApiResponse();
3
4  // Vérification de la structure de l'objet
5  expect(response).toEqual(
6    expect.objectContaining({
7      status: expect.any(String),
8      data: expect.any(Array),
9      timestamp: expect.any(Number),
10    })
11  );
12
13  // Vérification des éléments du tableau
14  expect(response.data).toEqual(
15    expect.arrayContaining([
16      expect.objectContaining({
17        id: expect.any(String),
18        name: expect.any(String),
19      })
20    ])
21  );
22});

De telles assertions résistent aux données variables : un identifiant ou un horodatage change à chaque appel, et le test passe quand même, tant que la forme correspond. C'est l'équivalent d'une interface au runtime : il vérifie dans le code en cours d'exécution ce que TypeScript a vérifié à la compilation.

Dans les tests TypeScript, describe, it et test fonctionnent comme en JavaScript, et it n'est toujours qu'un alias de test. Dans le projet final, tu combineras tous les outils de ce module, et dans le laboratoire ci-dessous, tu verras une simulation des tests de la classe Enclosure et du repository générique.

Retiens : TypeScript est le catalogue des espèces, et les tests sont l'observation sur le terrain - ce n'est qu'ensemble qu'ils te disent que le dinosaure est bien celui qu'il prétend être.

Code de cette leçon : index.js
1// Tester TypeScript avec Jest
2console.log("=== Jurassic Park - TypeScript Testing ===\n");
3
4let passed = 0, failed = 0;
5function assert(cond, msg) {
6  if (cond) { passed++; console.log("  [PASS] " + msg); }
7  else { failed++; console.log("  [FAIL] " + msg); }
8}
9
10// --- Configuration de ts-jest ---
11console.log("--- Configuration de ts-jest ---\n");
12console.log("npm install --save-dev ts-jest @types/jest\n");
13console.log("// jest.config.js");
14console.log("module.exports = {");
15console.log("  preset: 'ts-jest',");
16console.log("  testEnvironment: 'node',");
17console.log("};\n");
18
19// --- Tester les interfaces ---
20console.log("--- Tester les interfaces et classes TypeScript ---\n");
21
22// Simulation de TypeScript (c'est du JS dans Sandpack)
23function createDinosaur(name, species, diet) {
24  return { name, species, health: 100, diet };
25}
26
27class Enclosure {
28  constructor(name, capacity, securityLevel) {
29    this.name = name;
30    this.capacity = capacity;
31    this.securityLevel = securityLevel;
32    this.dinosaurs = [];
33  }
34
35  addDinosaur(dino) {
36    if (this.dinosaurs.length >= this.capacity) {
37      throw new Error("Enclosure is full");
38    }
39    if (dino.diet === "carnivore" && this.securityLevel < 8) {
40      throw new Error("Security level too low for carnivores");
41    }
42    this.dinosaurs.push(dino);
43  }
44
45  getDinosaurCount() { return this.dinosaurs.length; }
46  isEmpty() { return this.dinosaurs.length === 0; }
47}
48
49// Tests Enclosure
50console.log("Suite : Enclosure\n");
51
52let enclosure = new Enclosure("Zone A", 3, 9);
53assert(enclosure.name === "Zone A", "has correct name");
54assert(enclosure.capacity === 3, "has correct capacity");
55assert(enclosure.isEmpty(), "starts empty");
56assert(enclosure.getDinosaurCount() === 0, "count is 0");
57
58const rex = createDinosaur("Rex", "T-Rex", "carnivore");
59enclosure.addDinosaur(rex);
60assert(!enclosure.isEmpty(), "not empty after add");
61assert(enclosure.getDinosaurCount() === 1, "count is 1");
62
63// Test : throw when full
64enclosure = new Enclosure("Zone B", 2, 9);
65enclosure.addDinosaur(createDinosaur("Rex1", "T-Rex", "carnivore"));
66enclosure.addDinosaur(createDinosaur("Rex2", "T-Rex", "carnivore"));
67try {
68  enclosure.addDinosaur(createDinosaur("Rex3", "T-Rex", "carnivore"));
69  assert(false, "should throw when full");
70} catch (e) {
71  assert(e.message === "Enclosure is full", "throws 'Enclosure is full'");
72}
73
74// Test : low security
75console.log("\nSuite : Security levels\n");
76const lowSec = new Enclosure("Zone C", 5, 5);
77try {
78  lowSec.addDinosaur(createDinosaur("Rex", "T-Rex", "carnivore"));
79  assert(false, "should reject carnivore");
80} catch (e) {
81  assert(e.message === "Security level too low for carnivores", "rejects low security");
82}
83
84// --- Generic Repository ---
85console.log("\nSuite : Generic Repository\n");
86
87function createRepository() {
88  const items = [];
89  return {
90    items,
91    add(item) { items.push(item); },
92    findById(id) { return items.find(i => i.id === id) || undefined; },
93    getAll() { return [...items]; },
94  };
95}
96
97const repo = createRepository();
98repo.add({ id: "001", name: "Rex", dangerous: true });
99
100assert(repo.getAll().length === 1, "has 1 item");
101assert(repo.findById("001").name === "Rex", "finds by id");
102assert(repo.findById("999") === undefined, "undefined for missing");
103
104// Test immutability
105const all = repo.getAll();
106all.push({ id: "002", name: "Blue", dangerous: true });
107assert(repo.getAll().length === 1, "getAll returns copy (immutable)");
108
109// --- expect.objectContaining ---
110console.log("\nSuite : Assertions structurelles\n");
111
112function getApiResponse() {
113  return {
114    status: "ok",
115    data: [{ id: "001", name: "Rex" }],
116    timestamp: Date.now(),
117  };
118}
119
120const response = getApiResponse();
121assert(typeof response.status === "string", "status is string");
122assert(Array.isArray(response.data), "data is array");
123assert(typeof response.timestamp === "number", "timestamp is number");
124assert(typeof response.data[0].id === "string", "item.id is string");
125assert(typeof response.data[0].name === "string", "item.name is string");
126
127console.log(`\n=== Résultats : ${passed} réussis, ${failed} échoués ===`);

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Quel preset est nécessaire dans la configuration de Jest pour tester TypeScript ?

  2. 2. Quelle est la différence entre test() et it() dans Jest ?

Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Tri vertical

    Ordonne les étapes de configuration des tests TypeScript avec Jest :

  • Tri horizontal

    Ordonne les éléments d'un test TypeScript typé :

  • Clique dans l'ordre

    Ordonne les éléments d'un test qui lève une exception dans une classe TypeScript :

  • Tri vertical

    Ordonne les étapes pour tester un repository générique :

  • Tri vertical

    Ordonne les éléments de la syntaxe jest.mock avec un argument de module :

  • Tri horizontal

    Ordonne les éléments d'une assertion vérifiant la forme d'un objet :

  • Clique dans l'ordre

    Ordonne les couches de la pyramide des tests de la base (le plus de tests) au sommet (le moins) :

  • Tri vertical

    Ordonne les étapes pour mocker un module externe dans le bon ordre :

  • Tri vertical

    Ordonne les étapes pour écrire un test complet du début à la fin :

  • Tri horizontal

    Ordonne les éléments de l'utilisation de spyOn :

  • Clique dans l'ordre

    Ordonne les étapes pour mocker un module dans Jest :

  • Tri vertical

    Ordonne les étapes pour tester du code asynchrone avec des fake timers :

Articles utiles