Testing Node.js — node:test, Vitest, Jest et au-delà
TL;DR — En 2026, trois familles dominent :
node --test(intégré, zéro dépendance, idéal pour libs et microservices), Vitest (DX exceptionnelle, ESM-first, alimenté par Vite, le défaut pour les apps modernes) et Jest (legacy, encore omniprésent mais lent et capricieux en ESM). Au-dessus, on empile supertest (HTTP), testcontainers (intégration avec vraies dépendances), fast-check (property-based) et Pact (contract testing). La règle d'or : un test doit échouer pour une seule raison ; tout le reste — mocks, snapshots, coverage — n'est qu'outillage autour de cette idée.
🧠 Mental model — ASCII + analogie
Imagine la testing pyramid revisitée pour Node :
┌──────────────┐
│ E2E │ ← Playwright / Cypress (lents, fragiles)
│ (5%) │
┌───┴──────────────┴────┐
│ Intégration │ ← testcontainers, supertest
│ (15%) │ vraies DB, vraies queues
┌───┴──────────────────────┴───┐
│ Unitaires │ ← node:test, Vitest
│ (80%) │ pure logic, mocks ciblés
└─────────────────────────────┘Et le flow d'un test :
AAA pattern
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Arrange │ → │ Act │ → │ Assert │
└─────────┘ └─────────┘ └─────────┘
setup, invoke vérif
fixtures SUT expectationsAnalogie : un test, c'est un microscope. Plus il est ciblé (unitaire), plus il grossit. Mais on a aussi besoin de jumelles (E2E) pour voir l'ensemble du paysage. Les deux sont complémentaires, ils ne se remplacent pas.
🛠️ Code minimal — node:test (intégré)
Le test runner natif de Node est stable depuis Node 20. Il ne nécessite aucune dépendance, supporte les sous-tests, le mocking, les snapshots (Node 22+) et la couverture (--experimental-test-coverage puis stable en 22).
// src/math.ts
export const add = (a: number, b: number) => a + b
export const divide = (a: number, b: number) => {
if (b === 0) throw new Error('divide by zero')
return a / b
}// src/math.test.ts
import { test, describe, before, after, mock } from 'node:test'
import assert from 'node:assert/strict'
import { add, divide } from './math.ts'
describe('math', () => {
before(() => console.log('suite start'))
after(() => console.log('suite end'))
test('add additionne deux nombres', () => {
assert.equal(add(2, 3), 5)
})
test('divide lève si diviseur nul', () => {
assert.throws(() => divide(1, 0), /divide by zero/)
})
test('sous-tests', async (t) => {
await t.test('positif', () => assert.equal(divide(10, 2), 5))
await t.test('négatif', () => assert.equal(divide(-10, 2), -5))
})
})Exécution :
# Node 22+ : strip TS natif
node --test --experimental-strip-types src/**/*.test.ts
# Avec coverage (stable depuis Node 22)
node --test --experimental-test-coverage src/**/*.test.ts
# Watch mode
node --test --watch src/**/*.test.ts
# Reporter LCOV pour CI
node --test --test-reporter=lcov --test-reporter-destination=coverage.lcov src/**/*.test.ts🛠️ Code minimal — Vitest
Vitest est le standard de facto pour les applis modernes. Configuration ESM, HMR pendant les tests, compatibilité Jest API (describe, it, expect), mais 10× plus rapide en moyenne.
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: false, // pas d'API globale, on importe (cleaner)
environment: 'node', // ou 'jsdom' / 'happy-dom' pour browser
coverage: {
provider: 'v8', // ultra-rapide, plus précis que c8
reporter: ['text', 'html', 'lcov'],
thresholds: { lines: 80, functions: 80, branches: 75 },
},
pool: 'forks', // 'threads' (défaut) ou 'forks' pour isolation
testTimeout: 5_000,
setupFiles: ['./tests/setup.ts'],
},
})// src/user.service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { UserService } from './user.service.ts'
describe('UserService', () => {
let repo: { findById: ReturnType<typeof vi.fn> }
let service: UserService
beforeEach(() => {
repo = { findById: vi.fn() }
service = new UserService(repo as any)
})
it('retourne le user si trouvé', async () => {
repo.findById.mockResolvedValue({ id: '1', name: 'Alice' })
await expect(service.get('1')).resolves.toMatchObject({ name: 'Alice' })
expect(repo.findById).toHaveBeenCalledExactlyOnceWith('1')
})
it('lève NotFoundError si absent', async () => {
repo.findById.mockResolvedValue(null)
await expect(service.get('x')).rejects.toThrow('User not found')
})
})🛠️ HTTP avec supertest
Pour tester un serveur HTTP (Express, Fastify, Hono) sans démarrer un vrai port :
import { test } from 'node:test'
import assert from 'node:assert/strict'
import request from 'supertest'
import { buildApp } from './app.ts'
test('GET /health renvoie 200', async () => {
const app = buildApp()
const res = await request(app).get('/health')
assert.equal(res.status, 200)
assert.deepEqual(res.body, { status: 'ok' })
})
test('POST /users valide le body', async () => {
const app = buildApp()
const res = await request(app)
.post('/users')
.send({ email: 'not-an-email' })
.set('Content-Type', 'application/json')
assert.equal(res.status, 400)
assert.match(res.body.error, /email/)
})Fastify expose app.inject() qui fait la même chose sans dépendance externe :
const res = await app.inject({ method: 'GET', url: '/health' })
assert.equal(res.statusCode, 200)🛠️ Intégration avec testcontainers
Les tests d'intégration mentent moins que les mocks. testcontainers lance de vraies dépendances dans Docker pour chaque suite.
import { describe, it, beforeAll, afterAll, expect } from 'vitest'
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql'
import { Pool } from 'pg'
describe('UserRepository (integration)', () => {
let container: StartedPostgreSqlContainer
let pool: Pool
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16-alpine')
.withDatabase('test')
.withUsername('test')
.withPassword('test')
.start()
pool = new Pool({ connectionString: container.getConnectionUri() })
await pool.query(`CREATE TABLE users (id text primary key, name text)`)
}, 60_000)
afterAll(async () => {
await pool.end()
await container.stop()
})
it('insère et lit un user', async () => {
await pool.query(`INSERT INTO users VALUES ('1', 'Alice')`)
const { rows } = await pool.query(`SELECT * FROM users WHERE id = $1`, ['1'])
expect(rows[0]).toEqual({ id: '1', name: 'Alice' })
})
})C'est plus lent que des mocks, mais infiniment plus fiable. Stratégie pragmatique : mocks pour les unités, containers pour les repositories et les adaptateurs externes.
🛠️ Property-based avec fast-check
Au lieu d'écrire trois cas particuliers, on laisse le runner générer 1000 entrées aléatoires et vérifier une propriété invariante.
import { test } from 'node:test'
import fc from 'fast-check'
import { sortAsc } from './sort.ts'
test('sortAsc est idempotent', () => {
fc.assert(fc.property(
fc.array(fc.integer()),
(arr) => {
const once = sortAsc([...arr])
const twice = sortAsc(sortAsc([...arr]))
return JSON.stringify(once) === JSON.stringify(twice)
},
))
})
test('sortAsc préserve la longueur', () => {
fc.assert(fc.property(
fc.array(fc.integer()),
(arr) => sortAsc([...arr]).length === arr.length,
))
})Quand fast-check trouve un contre-exemple, il rétrécit automatiquement (shrinking) jusqu'à l'input minimal qui casse — souvent une révélation.
🛠️ Contract testing avec Pact (introduction)
Quand deux services communiquent par HTTP, on veut garantir que le consommateur et le producteur se mettent d'accord sur le contrat sans tests E2E lents.
// consumer side
import { PactV3, MatchersV3 } from '@pact-foundation/pact'
const { like, eachLike } = MatchersV3
const provider = new PactV3({ consumer: 'web-app', provider: 'user-api' })
provider
.given('user 1 exists')
.uponReceiving('a request for user 1')
.withRequest({ method: 'GET', path: '/users/1' })
.willRespondWith({
status: 200,
body: like({ id: '1', name: 'Alice', roles: eachLike('admin') }),
})
await provider.executeTest(async (mockServer) => {
const res = await fetch(`${mockServer.url}/users/1`)
expect(res.status).toBe(200)
})Le contrat (pact.json) est ensuite publié sur un broker (Pactflow) et rejoué côté provider. Si le provider casse le contrat, le pipeline du provider échoue — pas en prod.
🎯 Patterns courants
1. AAA — Arrange / Act / Assert
Trois blocs visibles, séparés par une ligne vide. Évite le test qui mélange setup et assertions au milieu.
it('crée un user et hash le password', async () => {
// Arrange
const dto = { email: '[email protected]', password: 'secret123!' }
// Act
const user = await service.create(dto)
// Assert
expect(user.email).toBe(dto.email)
expect(user.passwordHash).not.toBe(dto.password)
expect(user.passwordHash.startsWith('$argon2')).toBe(true)
})2. Test data builder
Évite la duplication des fixtures et la fragilité quand un champ change.
class UserBuilder {
private data = { id: 'u-1', email: '[email protected]', age: 30, roles: ['user'] }
withEmail(e: string) { this.data.email = e; return this }
withRoles(...r: string[]) { this.data.roles = r; return this }
build() { return { ...this.data } }
}
const admin = new UserBuilder().withRoles('admin').build()3. Mock vs Spy vs Stub
Trois concepts souvent confondus :
| Concept | Rôle | Exemple |
|---|---|---|
| Stub | Remplace une fonction par une réponse canned | vi.fn().mockReturnValue(42) |
| Spy | Observe une fonction réelle sans la remplacer | vi.spyOn(obj, 'method') |
| Mock | Stub + assertions sur les appels (combinaison) | expect(fn).toHaveBeenCalledWith(...) |
Règle : stub pour les dépendances, spy pour les effets de bord, mock quand on veut vérifier l'interaction.
Comment un staff raisonne sur le choix d'un test double. La vraie question n'est pas "quel double ?" mais "qu'est-ce que je veux que ce test casse pour ?". On distingue deux familles d'assertions :
| State verification (préféré) | Interaction verification (parcimonie) | |
|---|---|---|
| Ce qu'on vérifie | L'état observable après l'Act (valeur retournée, DB, événement émis) | Que telle dépendance a été appelée avec tels args |
| Couplage à l'interne | Faible — survit aux refactors | Fort — casse quand on réorganise les collaborateurs |
| Double typique | Stub / fake | Mock |
| Quand l'utiliser | 90 % du temps | Quand l'effet est un command sans état observable (envoyer un email, publier un message) |
Le smell numéro un en revue : un test plein de toHaveBeenCalledWith sur des collaborateurs internes. Ça teste comment le code est écrit, pas ce qu'il fait. Un refactor légitime (extraire une méthode, inverser deux appels) casse 40 tests rouges sans qu'aucun comportement n'ait changé — le test devient une taxe au lieu d'un filet. Réserve l'interaction verification aux frontières de commande (le port qui envoie réellement quelque chose au monde extérieur) et vérifie l'état partout ailleurs.
Le fake, le test double oublié. Entre le stub naïf et le vrai container, il y a le fake : une implémentation légère mais réelle (un repo en Map, une horloge contrôlable, un bus en mémoire). Un fake ne ment pas comme un stub (il a une vraie logique) et ne coûte pas comme un container (pas de Docker). Pour les ports métier (UserRepository, Clock, IdGenerator), un fake partagé entre tests unitaires et un contract test qui vérifie fake ≡ implémentation réelle est le pattern le plus robuste — c'est ce que recommande la London school revisitée.
// Le fake et le vrai repo passent le MÊME contract test
function repositoryContract(makeRepo: () => UserRepository) {
it('lit ce qu’il a écrit', async () => {
const repo = makeRepo()
await repo.save({ id: '1', name: 'Alice' })
expect(await repo.findById('1')).toMatchObject({ name: 'Alice' })
})
it('retourne null pour un id absent', async () => {
expect(await makeRepo().findById('nope')).toBeNull()
})
}
describe('InMemoryUserRepository', () => repositoryContract(() => new InMemoryUserRepository()))
describe('PgUserRepository (integration)', () => repositoryContract(() => new PgUserRepository(pool)))Le fake est désormais prouvé équivalent au vrai repo. Les tests unitaires l'utilisent à pleine vitesse, et le contract garantit qu'il ne dérive jamais.
4. Snapshot testing — avec parcimonie
Les snapshots sont parfaits pour capturer un output complexe (HTML, AST, log structuré). Ils deviennent un cauchemar quand on les utilise pour tout.
import { test } from 'node:test' // snapshots stables Node 22+, via t.assert.snapshot
test('serialize user', (t) => {
const out = serialize({ id: '1', email: '[email protected]' })
t.assert.snapshot(out) // crée/compare *.snapshot ; exécute avec --test-update-snapshots pour régénérer
})Règles : inline snapshots quand le payload est petit, fichier séparé quand il est gros. Et review humaine systématique des diffs de snapshot, sinon ils se mettent à mentir.
5. Test fixtures isolées par worker
En parallèle, deux tests qui écrivent dans la même DB se marchent dessus. Solution : un schema par worker.
const schema = `test_${process.env.VITEST_POOL_ID ?? '0'}_${Date.now()}`
await pool.query(`CREATE SCHEMA ${schema}`)
await pool.query(`SET search_path TO ${schema}`)6. Helpers de retry pour tests flaky
Un test flaky n'est jamais "juste flaky" — c'est un bug. Mais en attendant le fix :
const flaky = (impl: () => Promise<void>, retries = 3) =>
async () => {
let err: unknown
for (let i = 0; i < retries; i++) {
try { await impl(); return } catch (e) { err = e }
}
throw err
}
test('eventually consistent', flaky(async () => { /* ... */ }))Le retry masque, il ne soigne pas. Un test flaky est un signal de production gratuit : la même non-déterminisme qui le fait clignoter en CI causera un incident à 3h du matin. Avant de retry, classe la cause :
| Symptôme | Cause racine probable | Fix |
|---|---|---|
| Passe seul, casse en parallèle | État partagé (DB, process.env, fichier, port fixe) | Isoler par worker (schema/db/tmpdir/port:0) |
| Casse 1 fois sur 50 sans ordre | Course async non attendue (fire-and-forget, event loop) | await la promesse, ou attendre une condition (waitFor) pas un setTimeout |
| Casse selon l'heure / le jour | Dépendance au temps réel, fuseau, Date.now() | Fake timers + injection d'une Clock |
| Casse selon l'ordre des tests | Fuite entre tests (mock non restauré, timer non clear) | afterEach qui restaure tout, --detectOpenHandles |
| Casse en CI seulement | Hypothèse de perf (timeout serré sur une machine lente) | Attendre une condition observable, pas une durée |
| Casse au 1er run après build | Warm-up (JIT, cache, container pas prêt) | Wait.forLogMessage / healthcheck, pas un sleep |
Règle senior : un setTimeout(…, 50) dans un test est presque toujours un bug. Remplace l'attente d'une durée par l'attente d'une condition (vi.waitFor(() => expect(...).toPass()), polling borné). Le temps n'est pas une API.
🔄 Versions — Node 18 / 20 / 22 / 24
| Version | Test runner |
|---|---|
| 18 | node:test expérimental, pas de coverage, API instable |
| 20 | node:test stable, --watch, sous-tests fiables |
| 22 | Coverage stable, snapshots intégrés (t.assert.snapshot), reporters configurables, --experimental-strip-types pour tester du TS sans transpile |
| 23.6 | Strip-types unflaggé : node --test src/**/*.test.ts marche sans --experimental-* (mais TS-syntax-only — pas d'enum, pas de namespaces, pas de décorateurs émis) |
| 24 | Type-stripping on par défaut, node --run pour les scripts npm sans overhead, perf++. --test-isolation=none pour tout exécuter dans le même process (plus rapide, mais fuites partagées) |
Vitest et Jest tournent partout, mais Vitest 3.x exige Node 18.18+ / 20.x / 22.x (Node 18 entre en maintenance, EOL avril 2025 — sur du neuf, viser 22 LTS). Jest 30 (sortie 2025) a finalement un support ESM décent et un démarrage plus rapide, mais reste derrière Vitest sur le watch-mode et l'ESM pur.
Piège strip-types :
node:test+ strip-types ne fait pas de type-checking. Un test qui passe le runtime peut avoir des types faux. Garde untsc --noEmitséparé en CI. Strip-types ≠ compilateur.
⚠️ Pitfalls
- Tester l'implémentation au lieu du comportement :
expect(myMethodCalled).toBe(true)couplé à l'interne. Si tu refactor, le test casse alors que rien n'est cassé. Teste ce qui est observable de l'extérieur. - Mocks qui mentent : tu mocks
fetchpour retourner{ status: 200 }mais en prod l'API retourne{ statusCode: 200 }. Le test passe, la prod plante. Solution : tests d'intégration avec testcontainers ou mocks vérifiés par contract tests. - Snapshots géants non lus : un fichier
.snapde 2000 lignes que personne ne review. Au moindre changement on tape "u" pour update. Utilité = 0. Garde les snapshots petits et significatifs. beforeEachqui fait trop : il devient le "constructeur géant" du test. Le test ne parle plus de lui-même. Préfère des factory functions explicites par test.- Pas de timeout sur les tests async : un
awaitqui ne résout jamais bloque la suite. MetstestTimeout: 5000global et override quand nécessaire. - Coverage = 100% mais bugs en prod : la couverture mesure que la ligne est exécutée, pas que le comportement est vérifié. Mutation testing (Stryker) révèle ces faux positifs.
process.envpartagé entre tests : un test setNODE_ENV=test, l'autreNODE_ENV=prod. Race en parallèle. Isole avecvi.stubEnv()ou des conteneurs séparés.- Tests trop lents → ignorés : si la suite prend 10 minutes, personne ne l'attend en local. Mets un budget temps (< 30s pour les unitaires, < 5min pour l'intégration en CI).
--detectOpenHandlestoujours rouge : connexions DB non fermées, timers non clear, listeners non removed. Active-le en CI, fixe les fuites.- Mock de
Date.now()qui contamine :vi.useFakeTimers()dans un test, oubli devi.useRealTimers()après. Les tests suivants voient un temps figé. Toujours restore dansafterEach.
🧪 Testing — checklist pragmatique
// scripts/test-quality.ts
//
// Critères qu'on évalue par PR :
// 1. La suite tourne en < 60s en local ?
// 2. Le ratio unit/integration/e2e est ~ 80/15/5 ?
// 3. Mutation score (Stryker) > 70% sur les modules critiques ?
// 4. Pas de test skip non documenté ?
// 5. Pas de `setTimeout` arbitraire dans les tests ?
// 6. Pas d'assertion sur des messages d'erreur en string libre (fragile) ?
// 7. Les fixtures sont isolées entre workers ?
// 8. La couverture est mesurée par v8 (rapide) et non istanbul (lent) ?🎬 Cas d'usage concrets
Scénario 1 — Tests d'intégration "Pennylane" avec Vitest + testcontainers
Pennylane est un éditeur fintech français (compta + facturation pour TPE/PME, 100k+ clients en 2026). L'équipe backend (35 devs) gère une stack Node/TS avec Postgres, Redis, RabbitMQ, et S3 (MinIO en dev). Pendant longtemps les tests d'intégration tournaient sur une DB partagée nommée pennylane_test, ce qui causait des flakys insupportables : un test qui supprimait des factures cassait un autre test qui les listait, les workers Vitest se marchaient dessus sur les mêmes rows.
La migration vers testcontainers a tout changé. Chaque worker Vitest démarre son propre Postgres + Redis dans un container Docker isolé via GenericContainer. Les globalSetup Vitest démarrent le pool, les beforeEach truncate les tables, et le globalTeardown arrête les containers. Latence des tests : passée de 3 min flaky à 90 secondes stable sur 1200 tests d'intégration. L'équipe a aussi adopté app.inject() Fastify partout (au lieu de supertest) pour aller plus vite encore — 0.4 ms par test d'intégration vs 12 ms avec un vrai port HTTP ouvert. Bénéfice : la CI Pennylane est passée de 18 minutes à 7 minutes, ce qui a permis de mettre la branch protection GitHub avec "tous les checks doivent passer" sans frustrer les devs.
Scénario 2 — Contract testing e-commerce "MarketHub" avec Pact
MarketHub (l'agrégateur e-commerce) a 24 microservices Node (catalog, pricing, inventory, orders, shipping, payments, etc.) qui communiquent via HTTP REST et RabbitMQ. Le problème : quand l'équipe inventory change un champ dans la réponse de GET /stock/:sku, les équipes orders et shipping ne le savent pas, et le bug remonte en intégration trois jours plus tard. Solution : Pact (Consumer-Driven Contract Testing).
Chaque consumer (orders, shipping) écrit des tests Pact qui décrivent ce qu'il attend du provider (inventory). Ces contracts sont publiés sur un Pact Broker hébergé. Le provider (inventory) doit faire passer ces contracts avant de merger sur main. Si un dev inventory change un champ qui casse orders, son CI fail avec un message clair : "le consumer orders attend stock_count: number, tu as renommé en available: number, négocie avec eux avant de pusher". Adoption progressive : commencer par les services critiques (orders → inventory, orders → pricing), étendre ensuite. ROI mesuré : 70 % de réduction des incidents inter-services en six mois, et les équipes parlent plus entre elles (le contract devient le langage commun).
Scénario 3 — Cabinet juridique "LexFidens" fixtures réutilisables
LexFidens (4 équipes produit, ~80 devs) maintient une lib interne @lexfidens/fixtures qui contient toutes les factories de données métier (dossiers, clients, actes, signatures, paiements). Chaque test importe createDossierFixture({ status: 'active', clientType: 'particulier' }) au lieu d'écrire 30 lignes de setup à la main. Les fixtures sont basées sur @faker-js/faker + override partiel via { ...defaults, ...overrides }.
Bénéfice : refactor sans peur. Quand l'équipe ajoute un champ obligatoire gdprConsentDate au modèle Client, ils l'ajoutent dans createClientFixture une seule fois — tous les tests qui créent des clients sont automatiquement à jour. Sans fixtures partagées, ils auraient eu à modifier ~200 fichiers de tests. La règle senior chez LexFidens : "aucune donnée hardcodée dans un test, tout passe par une fixture", et les fixtures sont versionnées comme du code applicatif (review, tests, sémantique versionnée). L'équipe utilise aussi @snaplet/seed pour générer des fixtures depuis le schéma Prisma (encore moins de boilerplate).
🛠️ Exemple end-to-end
Cas d'usage : "tests d'intégration Pennylane pour un endpoint POST /invoices — testcontainers Postgres + Redis, fixtures partagées, fast-check pour le calcul de TVA, contract Pact pour le service downstream".
// test/setup.ts
import { GenericContainer, Wait, type StartedTestContainer } from 'testcontainers'
import { beforeAll, afterAll } from 'vitest'
let pg: StartedTestContainer
let redis: StartedTestContainer
let dbUrl: string
beforeAll(async () => {
pg = await new GenericContainer('postgres:16-alpine')
.withEnvironment({ POSTGRES_PASSWORD: 'test', POSTGRES_DB: 'pennylane_test' })
.withExposedPorts(5432)
.withWaitStrategy(Wait.forLogMessage('database system is ready'))
.start()
redis = await new GenericContainer('redis:7-alpine').withExposedPorts(6379).start()
dbUrl = `postgres://postgres:test@localhost:${pg.getMappedPort(5432)}/pennylane_test`
process.env.DATABASE_URL = dbUrl
process.env.REDIS_URL = `redis://localhost:${redis.getMappedPort(6379)}`
await runMigrations(dbUrl)
}, 60000)
afterAll(async () => {
await pg?.stop()
await redis?.stop()
})
// test/fixtures/invoice.ts
import { faker } from '@faker-js/faker'
export const createCompanyFixture = (overrides: Partial<Company> = {}) => ({
id: faker.string.uuid(),
name: faker.company.name(),
siret: faker.string.numeric(14),
vatNumber: `FR${faker.string.numeric(11)}`,
...overrides,
})
export const createInvoiceFixture = (overrides: Partial<Invoice> = {}) => ({
number: `INV-${faker.string.numeric(6)}`,
issueDate: faker.date.recent({ days: 30 }).toISOString().slice(0, 10),
dueDate: faker.date.soon({ days: 30 }).toISOString().slice(0, 10),
lines: [
{ description: faker.commerce.productName(), quantity: 1, unitPriceHt: 100, vatRate: 0.20 },
],
customerId: faker.string.uuid(),
...overrides,
})
// test/invoices.spec.ts
import { describe, it, expect, beforeEach } from 'vitest'
import fc from 'fast-check' // default export, PAS `import { fc }` (erreur fréquente)
import { buildApp } from '../src/app'
import { createCompanyFixture, createInvoiceFixture } from './fixtures/invoice'
import { truncate } from './helpers/db'
describe('POST /api/invoices', () => {
let app: ReturnType<typeof buildApp>
beforeEach(async () => {
await truncate(['invoices', 'companies', 'invoice_lines'])
app = buildApp()
})
it('creates an invoice with computed VAT', async () => {
const company = createCompanyFixture()
await app.inject({ method: 'POST', url: '/api/companies', payload: company })
const invoice = createInvoiceFixture({
customerId: company.id,
lines: [{ description: 'Audit', quantity: 2, unitPriceHt: 500, vatRate: 0.20 }],
})
const res = await app.inject({
method: 'POST',
url: '/api/invoices',
headers: { authorization: 'Bearer test-token' },
payload: invoice,
})
expect(res.statusCode).toBe(201)
const body = res.json()
expect(body.totalHt).toBe(1000)
expect(body.totalVat).toBe(200)
expect(body.totalTtc).toBe(1200)
})
it('property: VAT is always totalHt * rate (no rounding errors >1 centime)', () => {
fc.assert(
fc.property(
fc.float({ min: 0.01, max: 10000, noNaN: true }),
fc.constantFrom(0, 0.055, 0.10, 0.20),
(ht, rate) => {
const ttc = computeTtc({ ht, rate })
const computedVat = ttc - ht
const expected = ht * rate
expect(Math.abs(computedVat - expected)).toBeLessThan(0.01)
}
),
{ numRuns: 200 }
)
})
it('rejects invalid SIRET', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/invoices',
payload: createInvoiceFixture({ customerId: 'not-a-uuid' }),
})
expect(res.statusCode).toBe(400)
expect(res.json().error.code).toBe('VALIDATION')
})
})Cet exemple combine cinq pratiques seniors : (1) testcontainers pour un Postgres + Redis isolé par run (zéro flaky cross-test), (2) fixtures factory createInvoiceFixture avec override partiel (DRY, refactor-friendly), (3) app.inject() Fastify pour la vitesse (pas de port HTTP), (4) fast-check pour une property-based test sur le calcul de TVA (couvre les edge cases que les tests à exemples ratent — montants à 9999.99 €, taux à 5.5 %, arrondis), (5) test négatif sur la validation (l'envelope d'erreur est testé, pas juste le happy path). La suite tourne en 90 secondes sur 1200 tests Pennylane, vs 3 minutes flaky avant la migration.
🔁 Quand utiliser / éviter
| Choisir... | Quand | Éviter quand |
|---|---|---|
node:test | Lib publiée, microservice mince, zero-dep | App full-stack avec besoin de transformations |
| Vitest | App moderne ESM/TS, monorepo, DX prioritaire | Codebase Jest legacy gigantesque (migration coûteuse) |
| Jest | Maintenance d'une codebase existante | Nouveau projet en 2026 |
| supertest | Tests d'API Express | Fastify (utiliser app.inject) |
| testcontainers | Repos, queues, brokers, S3-like | Pure logic métier sans I/O |
| fast-check | Algos, parsers, transformations | Workflows métier non purs |
| Pact | Microservices avec contrats stables | Monolithe ou prototype |
🛠️ Mocking avancé — modules et timers
Mock de modules
Avec Vitest, le mock de module remplace toute une dépendance. Utile mais dangereux (couplage fort à l'interne).
import { vi, test, expect } from 'vitest'
// Mock complet
vi.mock('node:fs/promises', () => ({
readFile: vi.fn().mockResolvedValue('mocked content'),
writeFile: vi.fn().mockResolvedValue(undefined),
}))
// Mock partiel (garde le reste réel)
vi.mock('./config.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('./config.ts')>()
return { ...actual, isProduction: () => true }
})
test('uses mocked fs', async () => {
const { loadFile } = await import('./loader.ts')
await expect(loadFile('foo')).resolves.toBe('mocked content')
})Avec node:test, le mock est plus brut mais natif :
import { test, mock } from 'node:test'
import fs from 'node:fs/promises'
test('mock readFile', async (t) => {
t.mock.method(fs, 'readFile', async () => 'mocked')
// ... tests ...
// restore automatique à la fin du test
})Faux timers
Tester des comportements liés au temps (retry, debounce, expiration) sans attendre vraiment :
import { vi, test, expect, beforeEach, afterEach } from 'vitest'
import { debounce } from './debounce.ts'
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
test('debounce ne se déclenche qu\'une fois', () => {
const fn = vi.fn()
const debounced = debounce(fn, 100)
debounced('a'); debounced('b'); debounced('c')
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(100)
expect(fn).toHaveBeenCalledExactlyOnceWith('c')
})🛠️ Coverage — au-delà des pourcentages
Le coverage seul ment. Trois outils complémentaires donnent une image honnête :
# 1. Coverage classique (v8 ou istanbul)
vitest run --coverage
# 2. Mutation testing : modifie le code et vérifie que les tests cassent
pnpm add -D @stryker-mutator/core @stryker-mutator/vitest-runner
pnpm stryker run
# 3. Couverture des branches non triviales : if/switch, try/catch, async/await
# v8 coverage les capture nativementMutation testing trouve les tests qui passent mais ne vérifient rien. Exemple : tu testes if (x > 0) return x mais ton test ne checke jamais le cas x === 0. Stryker remplace > par >=, le test passe quand même → mutant survivant → bug en germe.
🛠️ Stratégie monorepo
Dans un monorepo, plusieurs principes :
// vitest.workspace.ts à la racine
import { defineWorkspace } from 'vitest/config'
export default defineWorkspace([
'packages/*', // chaque package a son vitest.config.ts
{ test: { name: 'integration', include: ['tests/integration/**'] } },
{ test: { name: 'unit', include: ['packages/**/src/**/*.test.ts'] } },
])vitest --project unit # tests unitaires
vitest --project integration # intégration
vitest # toutLe --project permet de paralléliser intelligemment en CI : les tests unitaires (rapides) en premier, intégration (lent) en parallèle ensuite. Si les unitaires échouent, on saute l'intégration.
🏭 Production & scale — la suite comme système
À 50 tests, n'importe quelle stratégie marche. À 5 000 tests sur 40 devs, la suite devient un système distribué avec ses propres pannes. Ce qui compte change d'échelle :
- Parallélisme & isolation. Vitest
pool: 'forks'donne une vraie isolation process (unprocess.exitou une fuite mémoire dans un test ne contamine pas les autres) au prix d'un fork plus lent ;'threads'(worker_threads) démarre plus vite mais partage l'espace mémoire natif — dangereux si une lib fait du C++ stateful. Règle :threadspour de l'unitaire pur rapide,forksdès qu'il y a de l'I/O ou des modules natifs.node:testparallélise par fichier par défaut ; un fichier = une unité d'isolation, donc split les gros fichiers pour mieux paralléliser. - Sharding en CI. Au-delà de ~3 min, shard :
vitest run --shard=1/4sur 4 runners, ounode --test --test-shard=1/4. Combine avec un test splitting par timing (timings du run précédent) pour équilibrer les shards, sinon un runner finit en 1 min et un autre en 4. - Observabilité de la suite. Une suite est un produit qu'on opère. Émets un reporter JUnit/JSON, ingère-le, et trace : durée p95 par test, top-10 des plus lents, taux de flaky par test sur 30 jours, tests jamais rouges depuis 6 mois (candidats à la suppression). Un test qui n'a jamais échoué ne protège de rien — soit il teste l'évident, soit il est mal écrit.
- Quarantaine, pas suppression. Un test flaky qui bloque tout le monde : on le marque
test.skipavec un ticket et un owner, on le sort du chemin critique, on le fixe sous SLA. Le supprimer silencieusement perd la couverture ; le laisser rouge entraîne l'aveuglement (« ah c'est juste le flaky habituel ») — le pire des deux mondes. - Coût. Les containers, c'est de la minute CI facturée. Mutualise : un
globalSetupqui démarre un Postgres pour toute la suite + un schema/db par worker, plutôt qu'un container par fichier.testcontainersréutilise les images via Ryuk ; en local,TESTCONTAINERS_REUSE_ENABLE=truegarde le container chaud entre runs. - Sécurité des tests. Les fixtures fuient : un dump de prod « anonymisé » dans un
.jsonde test finit sur GitHub. N'utilise jamais de vraie PII ; génère avecfaker(seedé pour le déterminisme :faker.seed(42)). Et ne commit jamais de vrais secrets dans unsetup.ts— même pour un container jetable, utilise des creds bidon explicites.
Le test pyramid est une heuristique, pas une loi. L'alternative moderne (le « testing trophy » de Kent C. Dodds) déplace le poids vers l'intégration : avec app.inject() + un fake repo ou un container chaud, un test d'intégration coûte aujourd'hui ~1 ms et donne 10× plus de confiance qu'un test unitaire couplé à l'interne. Le staff ne récite pas 80/15/5 ; il optimise le ratio confiance par seconde de CI. Pour une API CRUD, ça penche intégration ; pour un moteur de calcul (TVA, pricing, parser), ça penche unitaire + property-based.
🏋️ Exercices
Progressifs : chaque exercice suppose le précédent résolu. Vise du code qui tournerait en revue chez un éditeur SaaS.
1. Le test runner natif, sans filet (implement)
Objectif — Écrire une suite node:test (zéro dépendance) pour un RateLimiter token-bucket, avec faux timers natifs et coverage > 90 %.
L'API : new RateLimiter({ capacity, refillPerSec }), méthode tryAcquire(n = 1): boolean. Teste : burst jusqu'à capacity, refus au-delà, recharge après écoulement du temps, fractions de token.
Indice / Solution
Utilise mock.timers natif de node:test (t.mock.timers.enable({ apis: ['Date'] })) pour contrôler le temps sans Vitest. Le bucket calcule les tokens disponibles depuis lastRefill à la demande (lazy refill) plutôt qu'avec un vrai setInterval — c'est testable et sans timer qui fuit. Avance le temps avec t.mock.timers.tick(1000) et assert le refill. Lance avec node --test --experimental-test-coverage.
2. Fake prouvé équivalent (production-grade)
Objectif — Construire un InMemoryOutboxRepository (pattern outbox transactionnel) et le faire passer le même contract test qu'un PgOutboxRepository sur testcontainers.
Le repo expose enqueue(msg), pullPending(limit), markSent(id). Le contract doit prouver : FIFO par clé d'agrégat, idempotence de markSent, qu'un message pullPending non markSent re-sort au pull suivant (at-least-once).
Indice / Solution
Factorise outboxContract(makeRepo) réutilisé par deux describe. Le fake utilise une Map<string, Msg[]> + un Set<id> des sent ; le vrai repo une table avec SELECT ... FOR UPDATE SKIP LOCKED. Le piège at-least-once : un test doit pull, ne pas markSent, re-pull, et exiger que le message ressorte — ça force le vrai SQL à ne marquer sent_at que sur markSent, pas sur pullPending. Démarre un container en globalSetup, un schema par worker.
3. Property-based qui trouve un vrai bug (implement → break)
Objectif — Écrire computeTtc({ ht, rate }) (TVA française) puis une property fast-check qui échoue à cause d'une erreur d'arrondi flottant, observer le shrinking, puis fixer en passant aux centimes entiers.
Indice / Solution
L'implémentation naïve Math.round(ht * (1 + rate) * 100) / 100 casse sur des montants comme 0.1 + 0.2. La property : totalTtc - totalHt === round2(totalHt * rate) pour rate ∈ {0, 0.055, 0.10, 0.20}. fast-check shrink jusqu'à un input minimal (souvent un montant à x.xx5). Le fix : travaille en centimes (bigint ou entiers), arrondis une seule fois à la frontière, et utilise banker's rounding si la spec compta l'exige. La leçon : ne jamais faire de TVA en number flottant.
4. Casser puis réparer une suite flaky (break-then-fix)
Objectif — On te donne une suite d'intégration qui passe en local et casse 1 fois sur 5 en CI parallèle. Diagnostiquer et rendre déterministe sans retry.
Le code de départ : 3 fichiers de tests qui partagent une DB unique app_test, utilisent un port HTTP fixe 3000, et lisent process.env.NOW. Reproduis le flaky avec vitest --pool=threads --no-isolate, identifie les trois fuites, corrige-les.
Indice / Solution
Trois bugs cumulés : (1) DB partagée → un schema/db par VITEST_WORKER_ID ou un container par worker ; (2) port 3000 fixe → écoute sur :0 et lis le port assigné, ou n'ouvre pas de port (app.inject) ; (3) process.env.NOW muté globalement → vi.stubEnv + vi.unstubAllEnvs() en afterEach, ou injecter une Clock. Le test « passe-t-il en --no-isolate ? » est le révélateur : si oui, ton isolation ne dépendait que de la chance du fork.
5. Mutation testing sur un module critique (production-grade)
Objectif — Lancer Stryker sur ton computeTtc et ton RateLimiter, atteindre un mutation score > 85 %, et expliquer chaque mutant survivant restant.
Indice / Solution
Configure @stryker-mutator/vitest-runner, restreins mutate aux deux modules. Tu vas voir survivre des mutants de bornes (> → >= sur la capacité du bucket) que ta suite ne distingue pas → ajoute un test exactement à capacity et à capacity + 1. Les mutants d'arrondi sur la TVA exposent les inputs limites que tes exemples ratent. Un mutant survivant légitime : du code défensif unreachable — documente-le ou supprime le code mort. Le score n'est pas l'objectif ; la lecture des survivants l'est.
6. Contract test consumer-driven (architect)
Objectif — Écrire un test Pact côté consumer orders contre le provider inventory, le publier sur un broker local, puis vérifier le provider — et faire échouer la vérif en cassant volontairement un champ.
Indice / Solution
Consumer : PactV3 décrit GET /stock/:sku avec like({ sku, available: integer() }). Lance un Pact Broker en Docker, pact-broker publish. Côté provider, @pact-foundation/pact Verifier rejoue le contrat contre une vraie instance d'inventory (avec ses stateHandlers pour le given('sku ABC in stock')). Renomme available → qty dans le provider : la vérif échoue avec un diff précis avant le merge. Bonus : can-i-deploy dans le pipeline pour bloquer un déploiement qui casserait un consumer déjà en prod.
🎤 En entretien
« Coverage à 100 %, et pourtant un bug en prod. Comment c'est possible, et que mesures-tu à la place ? » La couverture prouve qu'une ligne est exécutée, pas qu'un comportement est vérifié : un test sans assertion, ou qui assert le mauvais invariant, couvre la ligne sans la protéger. Je regarde le mutation score (Stryker) sur les modules critiques — il mesure si les tests détectent un changement de comportement — et je traque les branches d'erreur non couvertes plutôt que le pourcentage global.
« Stub, mock, spy, fake : quand utilises-tu lequel ? » Stub = réponse canned pour isoler (90 % des cas) ; spy = observer une fonction réelle sans la remplacer ; mock = stub + assertions d'interaction, à réserver aux commandes sans état observable (envoyer un email) ; fake = implémentation légère mais réelle (repo en Map), prouvée équivalente à la vraie via un contract test partagé. Je préfère la state verification à la interaction verification parce qu'elle survit aux refactors.
« Un test passe seul mais clignote en parallèle. Ta démarche ? » C'est de l'état partagé jusqu'à preuve du contraire : DB unique, port fixe, process.env, fichier temp, ou course async non attendue. Je reproduis avec --no-isolate, j'isole par worker (schema/db/port:0/tmpdir), je remplace tout setTimeout d'attente par l'attente d'une condition observable, et je restaure tous les mocks/timers en afterEach. Le retry ne soigne pas : un flaky est un incident de prod qui s'annonce.
« Pyramide des tests ou testing trophy ? Comment décides-tu du ratio ? » Ni dogme. Avec app.inject() + un fake ou un container chaud, un test d'intégration coûte ~1 ms et donne bien plus de confiance qu'un test unitaire couplé à l'interne — donc pour une API CRUD je penche intégration. Pour un moteur de calcul pur (TVA, pricing, parser), je penche unitaire + property-based. Je n'optimise pas un ratio mais la confiance par seconde de CI, et je supprime les tests qui ne sont jamais rouges.
🔗 Liens
- Node test runner : https://nodejs.org/api/test.html
- Vitest : https://vitest.dev/
- supertest : https://github.com/ladjs/supertest
- testcontainers-node : https://node.testcontainers.org/
- fast-check : https://fast-check.dev/
- Pact : https://docs.pact.io/
- Stryker mutator : https://stryker-mutator.io/
- Test data builders : https://www.natpryce.com/articles/000714.html
🗓️ Récap final
Le testing en Node 2026 a deux gagnants : node:test pour le minimalisme et Vitest pour la DX. Empile dessus supertest/inject pour HTTP, testcontainers pour l'intégration honnête, fast-check pour les invariants et Pact pour les contrats inter-services. Ce qui compte vraiment, ce n'est pas l'outil — c'est la discipline : un test = une raison d'échouer, AAA visible, fixtures isolées, coverage utilisé comme signal et non comme objectif. Le reste suit.