Cours NestJS · Module 7: Tests

Mocking - simuler les conditions de combat

6 min de lecture
Dans cette leçon6

Tu veux tester un service qui verse la solde. L'ennui, c'est qu'il puise dans une base de données et dans le trésor externe de l'Empire. Le test exigerait donc une base en fonctionnement, un réseau et le serveur d'autrui - il serait lent, et il tomberait à la première coupure de liaison alors même que ton code serait irréprochable.

La légion s'entraîne autrement. Sur le terrain de manœuvre, on dresse des mannequins à la place de vrais adversaires. Un mannequin ne rend pas les coups, mais il permet de vérifier si le légionnaire tient la formation. Dans les tests, ces mannequins s'appellent des test doubles - des doublures.

Quatre sortes de mannequins

Les doublures diffèrent par ce qu'elles savent faire. Le quatuor entier mérite d'être connu, car ces noms reviennent dans toute la documentation - les voici du plus simple au plus élaboré :

  1. Dummy - un mannequin de paille. Il remplit l'emplacement d'un paramètre que la méthode n'utilisera pas de toute façon. Il ne fait rien.
  2. Stub - un mannequin muni d'une réponse toute prête. Interrogé, il dit toujours la même chose, sans l'ombre d'une logique. "Le légionnaire d'id 1 ? Voici Marcus."
  3. Spy - un mannequin qui retient les coups. Il répond comme un stub, mais note en plus combien de fois on l'a appelé et avec quoi.
  4. Mock - un mannequin muni d'attentes. Non seulement il enregistre les appels, mais il sert à vérifier les interactions : le service a-t-il vraiment appelé le repository, et exactement une fois ?

En pratique, Jest brouille la frontière entre les trois derniers - une même fonction fait souvent office de stub et de spy à la fois. Mais la distinction des rôles demeure : un stub fournit des données, un spy observe, un mock vérifie.

jest.fn() - un mannequin bâti de zéro

L'outil de base crée une fonction de substitution vide :

1const findOne = jest.fn();

Cette fonction ne fait rien et renvoie undefined, mais Jest suit chacun de ses appels - il retient combien de fois elle a été déclenchée et avec quels arguments. jest.fn() est donc déjà un espion à lui seul ; il ne lui manque qu'une réponse.

Nous ajoutons la réponse par l'une de quatre méthodes, qui diffèrent par ce qu'elles renvoient :

1const repo = {
2  findOne: jest.fn().mockResolvedValue({ id: 1, name: 'Marcus' }),
3  count: jest.fn().mockReturnValue(42),
4  save: jest.fn().mockRejectedValue(new Error('Trésor indisponible')),
5};

mockReturnValue renvoie une valeur immédiatement, de façon synchrone - adapté aux méthodes qui ne sont pas asynchrones. mockResolvedValue renvoie une promesse résolue, elle convient donc partout où le code fait un await. mockRejectedValue renvoie une promesse rejetée - c'est avec elle que tu vérifies que ton service gère correctement une panne.

Il existe aussi une variante à suffixe Once : mockReturnValueOnce ne répond ainsi que la première fois, puis revient au comportement par défaut. Elle sert quand tu veux que le premier appel échoue et que la nouvelle tentative réussisse.

Nous glissons la doublure ainsi préparée à la place de la vraie dépendance :

1const module = await Test.createTestingModule({
2  providers: [
3    PayService,
4    { provide: getRepositoryToken(Legionary), useValue: repo },
5  ],
6}).compile();

useValue dit à NestJS : quand quelqu'un demande le repository des légionnaires, donne-lui cet objet à la place du vrai. Le service n'y verra rien - il reçoit quelque chose doté des mêmes méthodes.

jest.spyOn() - un espion sur une méthode existante

Parfois tu ne veux pas bâtir une doublure de zéro, mais observer un vrai objet. C'est le rôle de la seconde méthode :

1const spy = jest.spyOn(legionService, 'findAll');

La différence entre les deux est fondamentale, et c'est elle qu'on interroge le plus souvent. jest.fn() crée une fonction nouvelle, qui n'existait nulle part auparavant. jest.spyOn() prend une méthode existante d'un objet existant et l'enveloppe d'observation.

L'essentiel est ce que spyOn par défaut ne fait pas : il ne remplace pas le comportement. La vraie méthode s'exécute toujours, et tu vois seulement qu'elle a été appelée. Quand tu veux aussi substituer une réponse, tu l'ajoutes exactement comme précédemment :

1jest.spyOn(legionService, 'findAll').mockResolvedValue([]);

Désormais, la vraie méthode ne s'exécute plus. Ce couple - observer, ou observer et remplacer - c'est tout spyOn.

Vérification - la doublure a-t-elle été appelée

Puisque les doublures enregistrent les appels, nous pouvons les interroger dans les assertions :

1expect(spy).toHaveBeenCalledTimes(1);
2expect(repo.findOne).toHaveBeenCalledWith({ where: { id: 1 } });

toHaveBeenCalledTimes vérifie le nombre d'appels, toHaveBeenCalledWith les arguments. C'est à cet instant que la doublure joue le rôle de mock : peu nous importe désormais ce qu'elle a renvoyé, seul compte de savoir si la conversation a eu lieu et sous quelle forme.

Attention toutefois à ne pas en abuser. Tester chaque appel lie le test à la structure interne du service - un petit refactoring cassera alors les tests, alors même que le comportement du code n'a pas changé. Vérifie les interactions là où l'interaction est elle-même le but : qu'un e-mail a été envoyé, qu'une écriture en base a eu lieu. Pour de simples lectures, vérifier le résultat suffit.

Ranger après la manœuvre

Les doublures retiennent les appels - y compris ceux du test précédent. Sans nettoyage, toHaveBeenCalledTimes(1) verra deux appels dans le second test :

1afterEach(() => {
2  jest.clearAllMocks();
3});

clearAllMocks remet à zéro les compteurs et les arguments enregistrés de toutes les substitutions. C'est l'une de ces lignes dont l'absence ne se manifeste que lorsque les tests se mettent à passer ou à échouer selon leur ordre d'exécution - le genre de défaut le plus difficile à traquer. Mets-la en place tout de suite.

Récapitulatif

Le terrain de manœuvre est prêt, les mannequins sont en place :

  • les test doubles remplacent les vraies dépendances, un test n'a donc besoin ni de base ni de réseau,
  • le quatuor du plus simple au plus complexe : Dummy (bouche-trou), Stub (réponse toute prête), Spy (enregistre les appels), Mock (vérifie les interactions),
  • jest.fn() crée une fonction nouvelle et en suit les appels dès le départ,
  • les réponses : mockReturnValue de façon synchrone, mockResolvedValue une promesse résolue, mockRejectedValue une promesse rejetée, les variantes ...Once n'agissent qu'une fois,
  • useValue dans createTestingModule substitue la doublure à la vraie dépendance,
  • jest.spyOn() observe une méthode existante et ne change pas son comportement par défaut - seul mockResolvedValue le remplace,
  • toHaveBeenCalledTimes vérifie le nombre d'appels, toHaveBeenCalledWith les arguments,
  • ne vérifie pas chaque appel : un test attaché à la structure interne se brise au moindre refactoring,
  • jest.clearAllMocks() dans afterEach remet les compteurs à zéro - sans quoi les tests se mettent à dépendre de leur ordre.

Dans la prochaine leçon, nous verrons quelle part du code tes tests ont réellement atteinte - tu découvriras le test coverage. En attendant, retiens ceci : une doublure est un mannequin sur le terrain de manœuvre ; jest.fn() la construit, jest.spyOn() en déguise un vrai objet, et les assertions demandent si le légionnaire a seulement porté un coup.

Code de cette leçon : src/mocking.spec.ts
1// Mocking - Simuler les conditions maritimes
2import { Test, TestingModule } from '@nestjs/testing';
3import { Injectable } from '@nestjs/common';
4
5// Interface d'un service externe
6interface WeatherAPI {
7  getConditions(region: string): Promise<{
8    wind: number;
9    waves: number;
10    visibility: number;
11  }>;
12}
13
14// Service de navigation - dépend de WeatherAPI
15@Injectable()
16class NavigationService {
17  constructor(private weatherApi: WeatherAPI) {}
18
19  async canSail(region: string): Promise<boolean> {
20    const conditions = await this.weatherApi.getConditions(region);
21    return conditions.wind < 50 && conditions.waves < 5;
22  }
23
24  async getRouteRisk(region: string): Promise<string> {
25    const conditions = await this.weatherApi.getConditions(region);
26    if (conditions.wind > 70) return 'extreme';
27    if (conditions.wind > 50) return 'high';
28    if (conditions.wind > 30) return 'moderate';
29    return 'low';
30  }
31}
32
33describe('NavigationService - Mocking', () => {
34  let service: NavigationService;
35
36  // Mock WeatherAPI - on simule les conditions météo
37  const mockWeatherApi: WeatherAPI = {
38    getConditions: jest.fn(),
39  };
40
41  beforeEach(async () => {
42    const modulee: TestingModule = await Test.createTestingModule({
43      providers: [
44        NavigationService,
45        { provide: 'WeatherAPI', useValue: mockWeatherApi },
46      ],
47    }).compile();
48
49    service = new NavigationService(mockWeatherApi);
50    jest.clearAllMocks();
51  });
52
53  it('should allow sailing in calm conditions', async () => {
54    // Configuration du mock - mer calme
55    (mockWeatherApi.getConditions as jest.Mock).mockResolvedValue({
56      wind: 20, waves: 2, visibility: 10,
57    });
58
59    const canSail = await service.canSail('Mediterranean');
60    expect(canSail).toBe(true);
61    expect(mockWeatherApi.getConditions).toHaveBeenCalledWith('Mediterranean');
62  });
63
64  it('should prevent sailing in storm', async () => {
65    // Configuration du mock - tempête
66    (mockWeatherApi.getConditions as jest.Mock).mockResolvedValue({
67      wind: 80, waves: 8, visibility: 1,
68    });
69
70    const canSail = await service.canSail('Atlantic');
71    expect(canSail).toBe(false);
72  });
73
74  it('should assess route risk correctly', async () => {
75    (mockWeatherApi.getConditions as jest.Mock).mockResolvedValue({
76      wind: 55, waves: 4, visibility: 5,
77    });
78
79    const risk = await service.getRouteRisk('North Sea');
80    expect(risk).toBe('high');
81  });
82
83  it('should handle API errors gracefully', async () => {
84    (mockWeatherApi.getConditions as jest.Mock).mockRejectedValue(
85      new Error('Weather service unavailable')
86    );
87
88    await expect(service.canSail('Unknown')).rejects.toThrow(
89      'Weather service unavailable'
90    );
91  });
92});
93

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. jest.fn() dans les tests crée :

  2. 2. Quelle est la différence entre jest.fn() et jest.spyOn() ?

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

Tâches pratiques dans le jeu

  • Éditeur de code

    Mocke le repository de base de données à l'aide de jest.fn()

  • Clique dans l'ordre

    Ordonne la configuration correcte de mockResolvedValue :

  • Tri vertical

    Ordonne les variantes de mocking du synchrone à l'asynchrone :

  • Éditeur de code

    Installe un espion sur la méthode findAll() du service des légions

  • Clique dans l'ordre

    Ordonne l'assertion vérifiant le nombre d'appels d'un mock :

  • Tri vertical

    Ordonne les Test Doubles du plus simple au plus complexe :