J10 · Tests unitaires, d'intégration et e2e
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()etjest.mock(). - Construire un module de test avec
Test.createTestingModuleet 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
ProjectsServiceetTasksService, le parcours register → login → projet → tâche → statut, et imposer un seuil de couverture avecnpm run test:cov.
Spring → NestJS
| Spring | NestJS | À retenir |
|---|---|---|
JUnit 5 : @Test, @Nested, @BeforeEach | it(), 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 |
ArgumentCaptor | fn.mock.calls[0] | Tous les appels sont enregistrés |
| Mock typé de Mockito | jest.Mocked<T> | Vérification à la compilation seulement |
@MockBean | { provide: X, useValue: mock } ou .overrideProvider(X).useValue(mock) | Remplacement par token |
@SpringBootTest + MockMvc | Test.createTestingModule({ imports: [AppModule] }) + supertest | L'application démarre sans ouvrir de port fixe |
@DataJpaTest | Test d'intégration avec une vraie base (Testcontainers) | TaskFlow utilise un service Compose dédié |
JaCoCo + jacoco:check | coverageThreshold + npm run test:cov | Le 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 :
| Mockito | Jest |
|---|---|
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() |
@Spy | jest.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().
// 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 :
Test.createTestingModule({ imports: [AppModule] }).compile()construit tout le graphe ;moduleRef.createNestApplication()puisapp.init()initialise l'application sans ouvrir de port ;request(app.getHttpServer())(supertest) ouvre un port éphémère et envoie les requêtes ;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émarrage | docker compose up -d db-test avant les tests | Le test démarre son conteneur via l'API Docker |
| Port | Fixe (5433) | Aléatoire, lu dans le test |
| Isolation | Une base partagée, vidée par le test | Un conteneur par suite |
| CI GitLab | Service postgres:16-alpine du job | Demande 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) :
// 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 :
{ 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 :
| Technique | Ce qu'elle prouve | Exemple TaskFlow |
|---|---|---|
Mock (jest.fn()) | Les décisions du service et ses appels | TasksService : émission de l'événement, accès vérifié |
| Fake (implémentation en mémoire) | Les règles métier avec un état réaliste | ProjectsService avec InMemoryProjectsStore |
| Vraie base | Les requêtes, les migrations, les contraintes | Tests 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.
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é.
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);
});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()niemit(); - accès refusé (
assertAccessrejette uneNotFoundException) → l'erreur remonte et rien n'est enregistré ; - tâche inconnue →
NotFoundException; markOverdue()→ un seulupdate(), avec la date de l'horloge fixe. Le test lit les arguments dansrepository.update.mock.calls[0], comme unArgumentCaptor;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).
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:e2eLa préparation :
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 quemain.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 :
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 :
| Test | Résultat attendu |
|---|---|
| Requête sans token | 401 application/problem+json, avec le x-correlation-id envoyé |
Champ inconnu, name vide, ?page=0, ?status=LATE, id non UUID | 400 |
Projet d'un autre membre / /v1/users en MEMBER | 404 / 403 |
| Même email deux fois | 409 |
| Archivage d'un projet | Ses tâches disparaissent de la liste |
/health et /health/live | 200, base up |
| 6e login dans la minute | 429, 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 :
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}Seuil de couverture
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.createTestingModuleetuseValue(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'
EventEmitter2ettoHaveBeenCalledWith(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-testdu 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.
awaitoublié devantexpect(...).rejects. Le test se termine avant la vérification et passe toujours. Écrivezawait expect(promesse).rejects.toBeInstanceOf(...).- Mocks partagés entre tests. Un
jest.fn()créé au niveau dudescribegarde ses appels :jest.clearAllMocks()dansbeforeEach, 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()dansmain.ts. Les tests e2e n'exécutent pasmain.ts: déclarez les éléments globaux comme providersAPP_*et partagez le reste danssetupApp().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(scripttest: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èssave(), 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
argon2parjest.mock()dansauth.service.spec.tset 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
coverageThresholddansjest.config.ts, lancernpm run test:cov, puis relever un seuil au-dessus du résultat (par exemplebranches: 80) pour le voir échouer. - [ ] Écrire le test unitaire de
JwtAuthGuard(une route@Public()passe sans token) et observer la couverture deguards.tsmonter dans le rapport. - [ ] Retirer
import 'reflect-metadata'deenv.validation.spec.tset observer l'erreur. - [ ] Optionnel : écrire le test Testcontainers ci-dessus.
- [ ] Noter dans
LEARNINGS.mdce qui diffère de Mockito et de@SpringBootTest, puisgit tag jour-10.