Skip to content

J10 · Tests unitaires, d'intégration et e2e ​

Jour 10 · lundi 23 novembre · Livrable : Couverture sur les modules clés

Jest tient à la fois le rôle de JUnit, de Mockito et d'AssertJ. @nestjs/testing fournit l'équivalent d'un contexte Spring de test : un module construit pour le test, dans lequel vous remplacez les providers que vous voulez. Avec supertest, vous testez l'application HTTP complète (guards, pipes, filtre) contre un vrai PostgreSQL. Dans TaskFlow : 27 tests unitaires, 8 tests e2e et un seuil de couverture qui fait échouer la CI.

Objectifs ​

  • Écrire des tests Jest avec describe, it, beforeEach, jest.fn() et jest.mock().
  • Construire un module de test avec Test.createTestingModule et des providers mockés (useValue).
  • Mocker un repository TypeORM avec getRepositoryToken(Entity).
  • Lancer des tests e2e avec supertest sur une base PostgreSQL dédiée (service Compose ou Testcontainers).
  • Tester les règles métier de ProjectsService et TasksService, le parcours register → login → projet → tâche → statut, et imposer un seuil de couverture avec npm run test:cov.

Spring → NestJS ​

SpringNestJSÀ retenir
JUnit 5 : @Test, @Nested, @BeforeEachit(), describe(), beforeEach()Des fonctions, pas des annotations
AssertJ assertThat(x).isEqualTo(y)expect(x).toEqual(y)Inclus dans Jest
Mockito mock(), when(...).thenReturn(...)jest.fn(), .mockResolvedValue(...)mockResolvedValue pour une méthode qui renvoie une Promise
verify(mock).publishEvent(event)expect(fn).toHaveBeenCalledWith(event)Égalité récursive des arguments
ArgumentCaptorfn.mock.calls[0]Tous les appels sont enregistrés
Mock typé de Mockitojest.Mocked<T>Vérification à la compilation seulement
@MockBean{ provide: X, useValue: mock } ou .overrideProvider(X).useValue(mock)Remplacement par token
@SpringBootTest + MockMvcTest.createTestingModule({ imports: [AppModule] }) + supertestL'application démarre sans ouvrir de port fixe
@DataJpaTestTest d'intégration avec une vraie base (Testcontainers)TaskFlow utilise un service Compose dédié
JaCoCo + jacoco:checkcoverageThreshold + npm run test:covLe build échoue sous le seuil

Comprendre ​

Jest : describe, it, jest.fn(), jest.mock(), beforeEach ​

Jest est à la fois le lanceur, la bibliothèque d'assertions et l'outil de mock. describe() regroupe des tests, it() en déclare un, beforeEach() / afterEach() / beforeAll() / afterAll() encadrent l'exécution. Les tests d'un fichier s'exécutent dans l'ordre ; les fichiers, eux, tournent en parallèle dans plusieurs workers. ts-jest compile le TypeScript à la volée.

jest.fn() crée une fonction qui enregistre ses appels (fn.mock.calls) et renvoie ce que vous configurez :

MockitoJest
when(repo.findById(id)).thenReturn(task)repository.findOneBy.mockResolvedValue(task)
thenThrow(new NotFoundException())mockRejectedValue(new NotFoundException())
thenAnswer(inv -> ...)mockImplementation((arg) => ...)
verify(repo, never()).save(any())expect(repository.save).not.toHaveBeenCalled()
@Spyjest.spyOn(objet, 'méthode')

jest.Mocked<T> type un objet de mocks : jest.Mocked<Pick<EventEmitter2, 'emit'>> n'accepte que emit, et events.emit.mockReturnValue(...) est vérifié par le compilateur.

jest.mock('module', fabrique) remplace un module entier pour tout le fichier de test (l'équivalent le plus proche est Mockito.mockStatic() sur les fonctions d'une bibliothèque). L'appel est remonté au-dessus des import à la compilation : la fabrique s'exécute avant le reste du fichier et ne doit pas lire ses variables. Créez les jest.fn() dans la fabrique, puis récupérez-les avec jest.mocked().

ts
// Exemple hors TaskFlow : remplacer argon2 dans auth.service.spec.ts
import * as argon2 from 'argon2';

jest.mock('argon2', () => ({
  hash: jest.fn().mockResolvedValue('$argon2id$fake'),
  verify: jest.fn().mockResolvedValue(true),
}));

it('refuses a wrong password', async () => {
  jest.mocked(argon2.verify).mockResolvedValueOnce(false);
  // ... appeler service.login() et attendre une UnauthorizedException
});

Un mock créé une fois au niveau du describe garde l'historique de ses appels d'un test à l'autre : le test de UsersService appelle jest.clearAllMocks() dans son beforeEach, celui de TasksService recrée ses mocks à chaque test. Et une assertion asynchrone doit être attendue : sans await, expect(promesse).rejects... n'est pas vérifiée avant la fin du test, qui passe quoi qu'il arrive.

Le starter NestJS 12, dans sa variante CommonJS + Jest (l'autre choix de nest new est ESM + Vitest, J1), lance Jest avec node --experimental-vm-modules ./node_modules/jest/bin/jest.js (scripts test, test:cov, test:e2e). Ce drapeau active le support ESM de Jest ; Node peut afficher ExperimentalWarning: VM Modules is an experimental feature…, sans conséquence. TaskFlow reste un projet CommonJS compilé par ts-jest, et jest.mock() fonctionne comme d'habitude. Passez par npm test plutôt que npx jest pour garder les mêmes options.

Test.createTestingModule et providers mockés ​

Test.createTestingModule({ providers: [...] }) construit un module Nest en mémoire avec seulement ce que vous déclarez. .compile() résout le graphe d'injection comme au démarrage, puis moduleRef.get(Service) renvoie l'instance à tester. Chaque dépendance se remplace par son token (providers) :

  • { provide: ProjectsService, useValue: mock } : un objet de mocks ;
  • { provide: PROJECTS_STORE, useClass: InMemoryProjectsStore } : une implémentation de test (un fake) ;
  • { provide: CLOCK, useValue: () => now } : une valeur fixe.

Si une dépendance manque, compile() échoue avec « Nest can't resolve dependencies of the TasksService… » et indique l'argument introuvable. Pour un test qui importe AppModule, .overrideProvider(X).useValue(mock) avant .compile() joue le rôle de @MockBean.

Où l'analogie casse : il n'y a pas de scan du classpath. Le module de test ne contient que ce que vous listez, ce qui le rend rapide et explicite. Un guard, un filtre ou un intercepteur se teste même sans Nest, par simple new (guards.spec.ts, all-exceptions.filter.spec.ts). Dans ce cas, chargez reflect-metadata vous-même si le code lit des métadonnées (piège).

Tests e2e avec supertest et une base dédiée ​

Un test e2e démarre la vraie application et lui envoie des requêtes HTTP :

  1. Test.createTestingModule({ imports: [AppModule] }).compile() construit tout le graphe ;
  2. moduleRef.createNestApplication() puis app.init() initialise l'application sans ouvrir de port ;
  3. request(app.getHttpServer()) (supertest) ouvre un port éphémère et envoie les requêtes ;
  4. afterAll(() => app.close()) ferme le pool PostgreSQL, les crons et le reste. Sans cela, Jest reste ouvert : « Jest did not exit one second after the test run has completed ».

Ce qui s'applique : tout ce qui est déclaré dans AppModule, dont les APP_PIPE, APP_GUARD, APP_INTERCEPTOR et APP_FILTER (filtre global). Ce qui ne s'applique pas : main.ts. D'où setupApp(), appelée par main.ts et par le test e2e (versioning, helmet, CORS, logger).

La base : Jest fixe NODE_ENV=test s'il n'est pas défini, donc ConfigModule charge .env.test (profils), qui pointe sur le service db-test du Compose (port 5433, données en tmpfs). Le script test:e2e passe --runInBand : les fichiers e2e s'exécutent l'un après l'autre, puisqu'ils partagent la même base.

Base PostgreSQL de test : Compose ou Testcontainers ​

Deux façons d'obtenir une base jetable :

Service Compose db-test (TaskFlow)Testcontainers
Démarragedocker compose up -d db-test avant les testsLe test démarre son conteneur via l'API Docker
PortFixe (5433)Aléatoire, lu dans le test
IsolationUne base partagée, vidée par le testUn conteneur par suite
CI GitLabService postgres:16-alpine du jobDemande Docker dans le job (Docker-in-Docker)

L'équivalent de @DataJpaTest (une vraie base, sans démarrer toute l'application) avec @testcontainers/postgresql (npm i -D @testcontainers/postgresql, Docker requis) :

ts
// Exemple hors TaskFlow : test/tasks-table.e2e-spec.ts
// (nommé *.e2e-spec.ts pour être pris par test/jest-e2e.json)
import 'reflect-metadata';
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { DataSource, QueryFailedError } from 'typeorm';
import { postgresOptions } from '../src/database/postgres-options';
import { Task } from '../src/tasks/entities/task.entity';

describe('tasks table (real PostgreSQL)', () => {
  let container: StartedPostgreSqlContainer;
  let dataSource: DataSource;

  beforeAll(async () => {
    container = await new PostgreSqlContainer('postgres:16-alpine').start();
    dataSource = new DataSource({
      ...postgresOptions({
        host: container.getHost(),
        port: container.getPort(), // port de l'hôte, choisi au hasard
        username: container.getUsername(),
        password: container.getPassword(),
        database: container.getDatabase(),
      }),
      entities: [join(__dirname, '..', 'src', '**', '*.entity.ts')],
    });
    await dataSource.initialize();
    await dataSource.runMigrations(); // les migrations versionnées de TaskFlow
  }, 60_000); // le premier téléchargement de l'image peut être long

  afterAll(async () => {
    await dataSource?.destroy();
    await container?.stop();
  });

  it('rejects a task whose project does not exist', async () => {
    await expect(
      dataSource.getRepository(Task).insert({ title: 'Orphan', projectId: randomUUID() }),
    ).rejects.toBeInstanceOf(QueryFailedError);
  });
});

Ce test vérifie ce qu'un mock ne peut pas vérifier : les migrations, la clé étrangère, le SQL réellement généré.

Mocker un repository avec getRepositoryToken ​

@InjectRepository(Task) est un raccourci pour @Inject(getRepositoryToken(Task)) : le repository n'est pas une classe que vous avez écrite, son token est calculé à partir de l'entité (repository). Dans le test, fournissez ce token avec un objet qui ne contient que les méthodes appelées :

ts
{ provide: getRepositoryToken(Task), useValue: { findOneBy: jest.fn(), save: jest.fn(), update: jest.fn() } }

Si le service appelle une méthode absente du mock, le test échoue avec TypeError: this.tasks.find is not a function : ajoutez-la au mock.

Choisir le bon niveau :

TechniqueCe qu'elle prouveExemple TaskFlow
Mock (jest.fn())Les décisions du service et ses appelsTasksService : émission de l'événement, accès vérifié
Fake (implémentation en mémoire)Les règles métier avec un état réalisteProjectsService avec InMemoryProjectsStore
Vraie baseLes requêtes, les migrations, les contraintesTests e2e sur db-test

Un mock de repository ne prouve pas que LessThan(today) produit le bon SQL : c'est le rôle des tests sur une vraie base.

Cas TaskFlow ​

Tests unitaires de ProjectsService et TasksService ​

ProjectsService est testé avec un fake : le vrai InMemoryProjectsStore derrière le token PROJECTS_STORE. C'est le bénéfice de l'abstraction introduite en J3 : aucune base, aucun mock à configurer.

ts
describe('ProjectsService', () => {
  let service: ProjectsService;

  beforeEach(async () => {
    // Like a @SpringBootTest slice: only the providers this test needs, with
    // the real in-memory store standing in for the database.
    const moduleRef = await Test.createTestingModule({
      providers: [ProjectsService, { provide: PROJECTS_STORE, useClass: InMemoryProjectsStore }],
    }).compile();
    service = moduleRef.get(ProjectsService);
  });

  it('makes the creator a member who can read the project', async () => {
    const project = await service.create({ name: 'Website' }, alice);
    await expect(service.findOne(project.id, alice)).resolves.toMatchObject({ name: 'Website' });
  });

  it('answers 404, not 403, to a user outside the project', async () => {
    const project = await service.create({ name: 'Website' }, alice);
    await expect(service.findOne(project.id, bob)).rejects.toBeInstanceOf(NotFoundException);
  });

Les six tests couvrent les règles métier : le créateur devient membre et peut lire le projet ; un non-membre reçoit 404 et non 403 ; un membre ne liste que ses projets, un admin les voit tous ; filtre et pagination ; archivage puis suppression logique ; un projet supprimé logiquement est fermé à ses anciens membres comme à un admin, tâches comprises (assertAccess() rejette une NotFoundException, J8).

TasksService dépend d'un repository, de ProjectsService, d'EventEmitter2 et de l'horloge : tout est mocké.

ts
describe('TasksService', () => {
  let service: TasksService;
  // Hand-written mocks: only the methods the service calls, each a jest.fn()
  // (Mockito's mock() + when()). jest.Mocked<T> keeps them typed.
  let repository: { findOneBy: jest.Mock; save: jest.Mock; update: jest.Mock };
  let events: jest.Mocked<Pick<EventEmitter2, 'emit'>>;
  let projects: jest.Mocked<Pick<ProjectsService, 'assertAccess'>>;

  beforeEach(async () => {
    repository = { findOneBy: jest.fn(), save: jest.fn((task: Task) => Promise.resolve(task)), update: jest.fn() };
    events = { emit: jest.fn() };
    projects = { assertAccess: jest.fn().mockResolvedValue(undefined) };

    const moduleRef = await Test.createTestingModule({
      providers: [
        TasksService,
        // The token @InjectRepository(Task) asks for.
        { provide: getRepositoryToken(Task), useValue: repository },
        { provide: EventEmitter2, useValue: events },
        { provide: ProjectsService, useValue: projects },
        { provide: CLOCK, useValue: () => now },
      ],
    }).compile();
    service = moduleRef.get(TasksService);
  });
ts
it('saves the new status, then emits task.status.changed', async () => {
  repository.findOneBy.mockResolvedValue(Object.assign(new Task(), { id: 't1', projectId: 'p1', status: TaskStatus.Todo }));

  const task = await service.updateStatus('t1', TaskStatus.Done, user);

  expect(task.status).toBe(TaskStatus.Done);
  expect(repository.save).toHaveBeenCalledTimes(1);
  expect(events.emit).toHaveBeenCalledWith(
    TASK_STATUS_CHANGED,
    new TaskStatusChangedEvent('t1', 'p1', TaskStatus.Todo, TaskStatus.Done, 'u1', now),
  );
});

Les autres tests de TasksService vérifient :

  • statut inchangé → ni save() ni emit() ;
  • accès refusé (assertAccess rejette une NotFoundException) → l'erreur remonte et rien n'est enregistré ;
  • tâche inconnue → NotFoundException ;
  • markOverdue() → un seul update(), avec la date de l'horloge fixe. Le test lit les arguments dans repository.update.mock.calls[0], comme un ArgumentCaptor ;
  • markOverdue() à 1 h du matin en été (horloge à 23 h UTC la veille) → la date retenue est celle de Paris (cron OVERDUE). Ce test crée son propre module pour fixer une autre horloge.

npm test exécute 27 tests répartis dans 8 fichiers *.spec.ts, rangés à côté du code : services (auth, users, projects, tasks), RolesGuard, AllExceptionsFilter, LoggingInterceptor et la validation de l'environnement. Ce dernier test ne démarre pas Nest et commence donc par import 'reflect-metadata'.

Parcours e2e : register → login → projet → tâche → statut ​

Prérequis : la base de test et un fichier .env.test local (non versionné, copié depuis .env.example).

bash
docker compose up -d db-test
cp .env.example .env.test   # puis NODE_ENV=test, DB_PORT=5433 et DB_NAME=taskflow_test
npm run test:e2e

La préparation :

ts
describe('TaskFlow (e2e)', () => {
  let app: INestApplication<App>;
  let http: ReturnType<typeof request>;

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
    app = moduleRef.createNestApplication();
    setupApp(app); // the same versioning, helmet and logger as main.ts
    await app.init();

    const dataSource = app.get(DataSource);
    await dataSource.runMigrations();
    await dataSource.query('TRUNCATE users, projects, tasks, project_members CASCADE');
    http = request(app.getHttpServer());
  });

  afterAll(async () => {
    await app.close();
  });
  • setupApp(app) applique les mêmes réglages HTTP que main.ts.
  • Les migrations versionnées sont appliquées, puis les tables vidées : chaque exécution part d'une base propre (migrations).

Jamais sur une vraie base

Le test exécute TRUNCATE users, projects, tasks, project_members CASCADE. Si .env.test pointe sur votre base de développement, ses données sont effacées. Gardez une base dédiée (db-test, port 5433).

Le parcours demandé par le plan :

ts
it('register -> login -> project -> task -> status change', async () => {
  const register = await http.post('/v1/auth/register').send({ email: 'alice@example.com', password }).expect(201);
  expect(register.body.passwordHash).toBeUndefined(); // @Exclude + ClassSerializerInterceptor
  expect(register.body.role).toBe(Role.Member);

  const login = await http.post('/v1/auth/login').send({ email: 'alice@example.com', password }).expect(200);
  const auth = { Authorization: `Bearer ${login.body.accessToken}` };

  const project = await http.post('/v1/projects').set(auth).send({ name: 'Website' }).expect(201);
  const task = await http
    .post(`/v1/projects/${project.body.id}/tasks`)
    .set(auth)
    .send({ title: 'Write the README', dueDate: '2026-11-24' })
    .expect(201);
  expect(task.body.status).toBe('TODO');

  await http.patch(`/v1/tasks/${task.body.id}/status`).set(auth).send({ status: 'DONE' }).expect(200);
  const done = await http.get(`/v1/projects/${project.body.id}/tasks?status=DONE`).set(auth).expect(200);
  expect(done.body).toHaveLength(1);
});

Les 8 tests e2e passent sur un vrai PostgreSQL. Les sept autres vérifient :

TestRésultat attendu
Requête sans token401 application/problem+json, avec le x-correlation-id envoyé
Champ inconnu, name vide, ?page=0, ?status=LATE, id non UUID400
Projet d'un autre membre / /v1/users en MEMBER404 / 403
Même email deux fois409
Archivage d'un projetSes tâches disparaissent de la liste
/health et /health/live200, base up
6e login dans la minute429, code: "TOO_MANY_REQUESTS"

Le dernier test dépend des précédents : ils ont déjà consommé les 5 logins par minute autorisés par @Throttle sur /v1/auth/login (sécurité). Il doit rester le dernier du fichier.

La configuration Jest des tests e2e :

json
{
  "moduleFileExtensions": ["js", "json", "ts"],
  "rootDir": ".",
  "testEnvironment": "node",
  "testRegex": ".e2e-spec.ts$",
  "transform": {
    "^.+\\.(t|j)s$": "ts-jest"
  }
}

Seuil de couverture ​

ts
coverageThreshold: {
  global: { branches: 60, functions: 70, lines: 70, statements: 70 },
},

npm run test:cov lance les tests unitaires avec --coverage et compare le résultat aux seuils. Sous un seuil, Jest se termine en erreur et le job unit-tests de la CI échoue (CI). Résultat sur TaskFlow : 86,3 % des lignes, 87,7 % des instructions, 75 % des fonctions, 71,4 % des branches. Le rapport HTML est dans coverage/lcov-report/index.html.

La mesure ne porte que sur les tests unitaires (les tests e2e n'y contribuent pas) et sur les fichiers retenus par collectCoverageFrom dans jest.config.ts, soit 7 fichiers : les quatre services, auth/guards.ts (JwtAuthGuard et RolesGuard), le filtre global et l'intercepteur de logs. Le rapport montre ce que les tests unitaires laissent de côté : JwtAuthGuard, TasksService.create() et list(), que seuls les tests e2e exercent. Un fichier qu'aucun motif ne retient n'apparaît pas du tout : relisez la liste des fichiers du rapport après chaque changement de collectCoverageFrom.

Un seuil sert de plancher contre les régressions, pas d'objectif : 100 % de lignes couvertes par des tests sans assertion utile ne prouvent rien.

Situations ​

  • Vous devez tester un service sans base → Test.createTestingModule et useValue (module de test).
  • Le service injecte @InjectRepository(Task) → { provide: getRepositoryToken(Task), useValue: mock } (repository).
  • Vous devez vérifier qu'un événement est émis → mock d'EventEmitter2 et toHaveBeenCalledWith (tests unitaires).
  • Le résultat dépend de la date du jour → injectez une horloge (token CLOCK) et fixez-la dans le test (tokens).
  • Vous devez remplacer tout un module npm (argon2, un SDK) → jest.mock() (Jest).
  • Vous voulez tester guards, pipes et filtre ensemble → e2e sur AppModule + setupApp() + supertest (e2e).
  • Il vous faut une base PostgreSQL jetable → service db-test du Compose ou Testcontainers (base de test).
  • La CI doit échouer sous un seuil de couverture → coverageThreshold + npm run test:cov (couverture).

Pièges ​

« Reflect.getMetadata is not a function »

Nest charge reflect-metadata au démarrage. Un test qui ne démarre pas Nest (validation de l'environnement, plainToInstance) doit commencer par import 'reflect-metadata' (piège).

429 au milieu des tests e2e

Le throttler compte aussi les requêtes des tests : la suite e2e a elle-même atteint la limite des 5 logins par minute. Le test du 429 est placé en dernier ; ajouter des logins avant lui le fait passer plus tôt et casse les tests suivants.

  • await oublié devant expect(...).rejects. Le test se termine avant la vérification et passe toujours. Écrivez await expect(promesse).rejects.toBeInstanceOf(...).
  • Mocks partagés entre tests. Un jest.fn() créé au niveau du describe garde ses appels : jest.clearAllMocks() dans beforeEach, ou recréez les mocks.
  • Méthode absente du mock. TypeError: ... is not a function : le service appelle une méthode que le mock ne déclare pas.
  • app.useGlobalPipes() dans main.ts. Les tests e2e n'exécutent pas main.ts : déclarez les éléments globaux comme providers APP_* et partagez le reste dans setupApp().
  • app.close() oublié. Pool de connexions et crons restent actifs, Jest ne s'arrête pas.
  • Fichiers e2e en parallèle sur une même base. Gardez --runInBand (script test:e2e).
  • Motif de couverture qui ne correspond à rien. Le fichier disparaît du rapport sans avertissement (couverture).

Exercices ​

  • [ ] Tests unitaires de ProjectsService : membre créateur, 404 pour un non-membre, vue admin, filtre et pagination, archivage et suppression logique, projet supprimé fermé à ses anciens membres.
  • [ ] Tests unitaires de TasksService : événement émis après save(), rien si le statut est inchangé, accès vérifié avant l'écriture, tâche inconnue, markOverdue() avec une horloge fixe, y compris à 1 h du matin en été.
  • [ ] Mocker le repository avec getRepositoryToken(Task), puis retirer une méthode du mock et lire l'erreur obtenue.
  • [ ] Remplacer argon2 par jest.mock() dans auth.service.spec.ts et comparer avec l'appel réel.
  • [ ] Test e2e register → login → création de projet → création de tâche → changement de statut, sur db-test.
  • [ ] Ajouter coverageThreshold dans jest.config.ts, lancer npm run test:cov, puis relever un seuil au-dessus du résultat (par exemple branches: 80) pour le voir échouer.
  • [ ] Écrire le test unitaire de JwtAuthGuard (une route @Public() passe sans token) et observer la couverture de guards.ts monter dans le rapport.
  • [ ] Retirer import 'reflect-metadata' de env.validation.spec.ts et observer l'erreur.
  • [ ] Optionnel : écrire le test Testcontainers ci-dessus.
  • [ ] Noter dans LEARNINGS.md ce qui diffère de Mockito et de @SpringBootTest, puis git tag jour-10.

Pour aller plus loin ​