Skip to content

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            expectations

Analogie : 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).

ts
// 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
}
ts
// 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 :

bash
# 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.

ts
// 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'],
  },
})
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 :

ts
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 :

ts
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.

ts
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.

ts
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.

ts
// 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.

ts
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.

ts
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 :

ConceptRôleExemple
StubRemplace une fonction par une réponse cannedvi.fn().mockReturnValue(42)
SpyObserve une fonction réelle sans la remplacervi.spyOn(obj, 'method')
MockStub + 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érifieL'é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'interneFaible — survit aux refactorsFort — casse quand on réorganise les collaborateurs
Double typiqueStub / fakeMock
Quand l'utiliser90 % du tempsQuand 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.

ts
// 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.

ts
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.

ts
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 :

ts
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ômeCause racine probableFix
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 ordreCourse async non attendue (fire-and-forget, event loop)await la promesse, ou attendre une condition (waitFor) pas un setTimeout
Casse selon l'heure / le jourDépendance au temps réel, fuseau, Date.now()Fake timers + injection d'une Clock
Casse selon l'ordre des testsFuite entre tests (mock non restauré, timer non clear)afterEach qui restaure tout, --detectOpenHandles
Casse en CI seulementHypothèse de perf (timeout serré sur une machine lente)Attendre une condition observable, pas une durée
Casse au 1er run après buildWarm-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

VersionTest runner
18node:test expérimental, pas de coverage, API instable
20node:test stable, --watch, sous-tests fiables
22Coverage stable, snapshots intégrés (t.assert.snapshot), reporters configurables, --experimental-strip-types pour tester du TS sans transpile
23.6Strip-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)
24Type-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 un tsc --noEmit sé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 fetch pour 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 .snap de 2000 lignes que personne ne review. Au moindre changement on tape "u" pour update. Utilité = 0. Garde les snapshots petits et significatifs.
  • beforeEach qui 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 await qui ne résout jamais bloque la suite. Mets testTimeout: 5000 global 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.env partagé entre tests : un test set NODE_ENV=test, l'autre NODE_ENV=prod. Race en parallèle. Isole avec vi.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).
  • --detectOpenHandles toujours 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 de vi.useRealTimers() après. Les tests suivants voient un temps figé. Toujours restore dans afterEach.

🧪 Testing — checklist pragmatique

ts
// 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".

ts
// 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:testLib publiée, microservice mince, zero-depApp full-stack avec besoin de transformations
VitestApp moderne ESM/TS, monorepo, DX prioritaireCodebase Jest legacy gigantesque (migration coûteuse)
JestMaintenance d'une codebase existanteNouveau projet en 2026
supertestTests d'API ExpressFastify (utiliser app.inject)
testcontainersRepos, queues, brokers, S3-likePure logic métier sans I/O
fast-checkAlgos, parsers, transformationsWorkflows métier non purs
PactMicroservices avec contrats stablesMonolithe 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).

ts
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 :

ts
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 :

ts
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 :

bash
# 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 nativement

Mutation 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 :

ts
// 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'] } },
])
bash
vitest --project unit          # tests unitaires
vitest --project integration   # intégration
vitest                          # tout

Le --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 (un process.exit ou 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 : threads pour de l'unitaire pur rapide, forks dès qu'il y a de l'I/O ou des modules natifs. node:test parallé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/4 sur 4 runners, ou node --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.skip avec 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 globalSetup qui démarre un Postgres pour toute la suite + un schema/db par worker, plutôt qu'un container par fichier. testcontainers réutilise les images via Ryuk ; en local, TESTCONTAINERS_REUSE_ENABLE=true garde le container chaud entre runs.
  • Sécurité des tests. Les fixtures fuient : un dump de prod « anonymisé » dans un .json de test finit sur GitHub. N'utilise jamais de vraie PII ; génère avec faker (seedé pour le déterminisme : faker.seed(42)). Et ne commit jamais de vrais secrets dans un setup.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 availableqty 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

🗓️ 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.

Bibliothèque tech perso — Achref