Cours NestJS · Module 7: Tests

E2E Testing - simuler une campagne entière

6 min de lecture
Dans cette leçon6

La catapulte tire, les pièces s'accordent. Mais une campagne, ce n'est pas un seul tir : les éclaireurs trouvent l'ennemi, un messager porte l'ordre, la légion se forme, les machines frappent, le camp se replie au crépuscule. Vérifier chaque étape séparément ne te dira pas si l'expédition entière parvient à son terme.

Un test E2E - de bout en bout - parcourt cette route en entier. Il démarre une vraie application, envoie de vraies requêtes et vérifie ce que verrait un utilisateur. Aucune doublure : si la validation, l'authentification ou une écriture en base échoue en chemin, le test l'attrape.

Où vivent les tests E2E

Nous gardons les tests E2E à part du reste, car ils ont leur propre configuration et leur propre rythme :

1test/
2  app.e2e-spec.ts
3  jest-e2e.json

Le fichier de test porte le suffixe .e2e-spec.ts, et leur configuration Jest se trouve dans test/jest-e2e.json. C'est elle qui demande à Jest de chercher les fichiers selon un autre motif et fixe un délai plus long, car la route complète dure plus qu'un appel de méthode. Tu les lances par une commande distincte, npm run test:e2e, justement pour qu'ils ne ralentissent pas l'exécution rapide des tests unitaires.

Quatre étapes vers une application en marche

La préparation de l'application se déroule toujours de la même façon, et l'ordre mérite d'être connu :

1describe('Legions (e2e)', () => {
2  let app: INestApplication;
3
4  beforeAll(async () => {
5    const moduleFixture = await Test.createTestingModule({
6      imports: [AppModule],
7    }).compile();
8
9    app = moduleFixture.createNestApplication();
10    app.useGlobalPipes(new ValidationPipe());
11    await app.init();
12  });
13
14  afterAll(async () => {
15    await app.close();
16  });
17});

Première étape : Test.createTestingModule({ imports: [AppModule] }) - nous importons AppModule, la racine de toute l'application. C'est la différence avec un test d'intégration, où nous prenions un seul module : ici, tout entre.

Deuxième étape : .compile() résout les dépendances et crée les instances.

Troisième étape : createNestApplication() transforme le module en application capable de traiter une requête.

Quatrième étape : await app.init() la démarre.

Entre la troisième et la quatrième étape se glisse une chose facile à oublier : les éléments globaux normalement configurés dans main.ts. ValidationPipe, les filtres d'exceptions, un préfixe de routes - aucun n'entre automatiquement, car main.ts ne s'exécute jamais dans un test. Si tu les omets, l'application de test acceptera des données que la production rejetterait - et le test passera à tort.

Parcourir toute la route

L'application en place, nous envoyons des requêtes avec la bibliothèque supertest - la même que celle rencontrée dans les tests d'intégration :

1it('crée une légion et permet de la récupérer', async () => {
2  const created = await request(app.getHttpServer())
3    .post('/legions')
4    .send({ name: 'Legio I' })
5    .expect(201);
6
7  await request(app.getHttpServer())
8    .get(`/legions/${created.body.id}`)
9    .expect(200)
10    .expect((res) => {
11      expect(res.body).toEqual({
12        id: created.body.id,
13        name: 'Legio I',
14      });
15    });
16});

La chaîne se lit comme la description d'une conversation avec le serveur : request(app.getHttpServer()) prend le serveur de l'application, .post('/legions') choisit la méthode et l'adresse, .send({...}) ajoute le corps de la requête, .expect(201) vérifie le code de réponse. Retiens cet ordre - c'est le squelette de tout test E2E.

Mais le plus intéressant est ailleurs : la seconde requête utilise le résultat de la première. Nous avons enregistré une légion, puis nous l'avons récupérée par l'identifiant renvoyé par le serveur. Voilà ce qu'aucun test de niveau inférieur ne fera - nous vérifions non pas un endpoint isolé, mais un scénario utilisateur composé de plusieurs étapes.

toEqual contre toBe

Dans cette dernière assertion, nous avons utilisé toEqual, et ce n'est pas un hasard :

1expect(res.body).toEqual({ id: 1, name: 'Legio I' });   // passe
2expect(res.body).toBe({ id: 1, name: 'Legio I' });      // échoue

La différence est fondamentale. toBe demande s'il s'agit du même objet - la même case mémoire. La réponse du serveur est arrivée par le réseau et a été reconstruite depuis du JSON, elle ne sera donc jamais le même objet que ton littéral ; toBe échoue toujours ici.

toEqual compare en profondeur : il parcourt la structure et vérifie la valeur de chaque champ. Pour les objets et les tableaux, c'est le bon choix. Laisse toBe aux nombres, aux chaînes et aux booléens, où « le même » et « égal » ne font qu'un.

Quand ne pas faire d'E2E - un contrôleur en isolation

La route complète donne le plus de certitude mais coûte le plus de temps. Quand tu veux vérifier la logique propre du contrôleur - s'il transmet les bons arguments - construis-le avec un service mocké :

1const mockService = { findAll: jest.fn().mockResolvedValue([]) };
2
3const module = await Test.createTestingModule({
4  controllers: [LegionsController],
5  providers: [{ provide: LegionsService, useValue: mockService }],
6}).compile();
7
8const controller = module.get(LegionsController);
9
10it('transmet le filtre de statut au service', async () => {
11  await controller.findAll('active');
12
13  expect(mockService.findAll).toHaveBeenCalledWith({ status: 'active' });
14});

Le service est ici une doublure, nous examinons donc le seul contrôleur. L'assertion toHaveBeenCalledWith vérifie avec quels arguments la doublure a été appelée - et c'est le seul moyen de confirmer que le contrôleur a correctement traduit un paramètre de requête en objet de filtre. La valeur de retour ne nous intéresse pas ici ; c'est la conversation entre les couches qui compte.

Le choix entre les deux tient à une question : est-ce que je vérifie la logique d'une couche ou la route à travers toutes ? E2E pour les scénarios, isolation pour les détails.

Récapitulatif

La campagne a parcouru toute la route :

  • un test E2E démarre une vraie application et parcourt la route complète d'une requête, sans doublures,
  • les fichiers portent le suffixe .e2e-spec.ts, la configuration se trouve dans test/jest-e2e.json, et npm run test:e2e les lance,
  • quatre étapes de préparation : createTestingModule({ imports: [AppModule] }), .compile(), createNestApplication(), await app.init(),
  • les éléments globaux de main.ts n'entrent pas automatiquement - ValidationPipe et les filtres doivent être ajoutés, sinon le test passe à tort,
  • la chaîne supertest : request(app.getHttpServer()), .post('/adresse'), .send({...}), .expect(201),
  • la force de l'E2E est le scénario en plusieurs étapes, où chaque requête utilise le résultat de la précédente,
  • toEqual compare structure et valeurs en profondeur - le choix pour les objets ; toBe vérifie l'identité et ne convient qu'aux valeurs primitives,
  • pour examiner le seul contrôleur, remplace le service par une doublure et utilise toHaveBeenCalledWith pour vérifier les arguments transmis,
  • app.close() dans afterAll est obligatoire.

Dans la prochaine leçon, nous regarderons les doublures de plus près - tu découvriras leurs quatre sortes et la différence entre jest.fn() et jest.spyOn(). En attendant, retiens ceci : l'E2E vérifie si la campagne parvient à son terme ; l'isolation vérifie si un messager porte le bon ordre.

Code de cette leçon : src/e2e-testing.spec.ts
1// E2E Testing - Simulation d'une expédition complète
2import { Test, TestingModule } from '@nestjs/testing';
3import { INestApplication, ValidationPipe } from '@nestjs/common';
4import * as request from 'supertest';
5import { Controller, Get, Post, Body, Param, Module } from '@nestjs/common';
6import { Injectable } from '@nestjs/common';
7
8// Service des légions
9@Injectable()
10class LegionService {
11  private legions = [
12    { id: 1, name: 'Legio X', province: 'Gallia', soldiers: 5000 },
13  ];
14
15  findAll() { return this.legions; }
16  findOne(id: number) { return this.legions.find(l => l.id === id); }
17  create(data: any) {
18    const legion = { id: this.legions.length + 1, ...data };
19    this.legions.push(legion);
20    return legion;
21  }
22}
23
24// Contrôleur
25@Controller('legions')
26class LegionController {
27  constructor(private service: LegionService) {}
28
29  @Get()
30  findAll() { return this.service.findAll(); }
31
32  @Get(':id')
33  findOne(@Param('id') id: string) {
34    return this.service.findOne(parseInt(id));
35  }
36
37  @Post()
38  create(@Body() data: any) {
39    return this.service.create(data);
40  }
41}
42
43// Module
44@Module({
45  controllers: [LegionController],
46  providers: [LegionService],
47})
48class LegionModule {}
49
50// Tests E2E
51describe('Legion E2E Tests', () => {
52  let app: INestApplication;
53
54  beforeAll(async () => {
55    const moduleeFixture: TestingModule = await Test.createTestingModule({
56      imports: [LegionModule],
57    }).compile();
58
59    app = moduleeFixture.createNestApplication();
60    app.useGlobalPipes(new ValidationPipe());
61    await app.init();
62  });
63
64  afterAll(async () => {
65    await app.close();
66  });
67
68  it('GET /legions - should return all legions', () => {
69    return request(app.getHttpServer())
70      .get('/legions')
71      .expect(200)
72      .expect((res) => {
73        expect(Array.isArray(res.body)).toBe(true);
74        expect(res.body.length).toBeGreaterThan(0);
75      });
76  });
77
78  it('POST /legions - should create a legion', () => {
79    return request(app.getHttpServer())
80      .post('/legions')
81      .send({ name: 'Legio XIV', province: 'Pannonia', soldiers: 4500 })
82      .expect(201)
83      .expect((res) => {
84        expect(res.body.name).toBe('Legio XIV');
85        expect(res.body.id).toBeDefined();
86      });
87  });
88
89  it('GET /legions/:id - should return single legion', () => {
90    return request(app.getHttpServer())
91      .get('/legions/1')
92      .expect(200)
93      .expect((res) => {
94        expect(res.body.name).toBe('Legio X');
95      });
96  });
97});
98

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. Lors du test d'un controller NestJS, les services devraient être :

  2. 2. expect(result).toEqual(expected) vérifie :

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

Tâches pratiques dans le jeu

  • Éditeur de code

    Teste l'endpoint GET du controller des légions

  • Tri horizontal

    Ordonne l'assertion vérifiant un appel de mock avec des arguments :

  • Éditeur de code

    Crée un test E2E pour l'endpoint GET /legions

  • Tri vertical

    Ordonne les étapes d'un test E2E avec supertest dans le bon ordre :

  • Tri vertical

    Ordonne les étapes de préparation de l'application pour un test E2E :