Changer le thème

Tests unitaires Vitest : TDD et configuration de la couverture

Easton editorial illustration: failed red test card, passing green test card, central refactor wrench, coverage gate

Vous fixez la ligne Test Suites: 1 failed, 47 passed. Une modification de code, 28 secondes d’attente. Encore une ligne, encore 28 secondes.

C’est à peu près l’expérience typique en migrant de Jest vers Vitest.

À l’époque, le projet comptait près de 500 tests ; chaque npm test laissait le temps de lire deux pages de Hacker News. Après le passage à Vitest, la même suite tournait en un peu plus de 3 secondes.

Aujourd’hui, on parle de deux choses : comment obtenir cette vitesse avec Vitest, et comment rendre le TDD (test-driven development) moins pénible. On prend une fonction complète de formatage de prix comme fil rouge, on parcourt le cycle Red-Green-Refactor, puis la couverture, les mocks et l’interface Vitest UI.

Pourquoi Vitest + TDD

Quelques chiffres d’abord.

50 000 tests
Vitest 3 s vs Jest 28-34 s

SitePoint a publié en 2026 un comparatif : 50 000 cas de test, Vitest en 3 secondes, Jest entre 28 et 34. Ce n’est pas un petit écart, c’est un ordre de grandeur.

La vitesse n’est qu’une raison. Si vous avez déjà galéré avec ESM sous Jest — Babel, transformers, et prières dans jest.config.js — Vitest change la donne : ESM natif, pas de couche de transpilation. Le code s’exécute en test comme en prod, tout simplement.

Pour les équipes Vite, un autre gain : Vitest réutilise la config Vite. Alias, variables d’environnement, plugins définis dans vite.config.ts passent automatiquement en environnement de test. Pas de second moduleNameMapper. La première fois que j’ai compris ça, j’ai mis quelques secondes à réaliser que la config de tests pouvait être aussi simple.

Côté méthode, le TDD beaucoup de gens l’ont entendu, peu le tiennent sur la durée. Le cœur, c’est Red-Green-Refactor : test qui échoue (rouge), code minimal qui passe (vert), puis refactor. Ça paraît contre-intuitif — écrire le test avant le code ?

Mais chaque ligne de code sert à faire passer un test. Pas de logique « au cas où », pas de sur-ingénierie. Et comme le test précède l’implémentation, vous clarifiez d’abord ce que la fonction doit faire, retourner, et où sont les limites. Cette contrainte rend le design plus net.

Le mode watch de Vitest rend la boucle fluide. Sauvegarde → tests en une seconde → résultat dans le terminal. Pas de changement de fenêtre, pas de commande manuelle : un copilote qui signale « là, c’est cassé » ou « tout est vert ». Ce retour immédiat pousse naturellement vers le flow.

TDD en pratique : développer une fonction de zéro

On passe à l’action. En TDD, on construit formatPrice() pour afficher un montant en devise, par exemple 1234.5¥1,234.50.

Phase Red : un test qui échoue

Créez formatPrice.test.ts :

// formatPrice.test.ts
import { describe, it, expect } from 'vitest'
import { formatPrice } from './formatPrice'

describe('formatPrice', () => {
  it('doit formater un nombre en devise CNY', () => {
    expect(formatPrice(1234.5)).toBe('¥1,234.50')
  })
})

Lancez npx vitest : erreur rouge Cannot find module './formatPrice'. Normal, la fonction n’existe pas encore.

C’est la phase Red — l’échec formalise un besoin pas encore implémenté. Beaucoup trouvent bizarre d’écrire le test d’abord ; si vous codez avant, comment être sûr que le test vérifie vraiment ce que vous vouliez ?

Phase Green : le minimum pour passer

Créez formatPrice.ts :

// formatPrice.ts
export function formatPrice(value: number): string {
  return '¥1,234.50'  // valeur en dur pour commencer
}

Relancez les tests : vert.

« Ce n’est pas de la triche ? » Pas vraiment. Le TDD demande juste assez de code pour passer, ni plus. Hardcodé ou logique minimale, vous avez une base vérifiable. Ensuite, nouveaux tests, évolution du code, pas à pas.

Ajoutez un cas :

it('doit gérer d\'autres valeurs', () => {
  expect(formatPrice(0)).toBe('¥0.00')
  expect(formatPrice(99.99)).toBe('¥99.99')
})

Rouge à nouveau. Plus de hardcode possible :

export function formatPrice(value: number): string {
  return `¥${value.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`
}

Tests verts. La regex est moche, mais ça tourne.

Phase Refactor : clarifier le code

Les tests passent ; on peut refactoriser sans peur — une régression remet tout en rouge.

// version refactorisée
export function formatPrice(value: number): string {
  // Intl.NumberFormat est plus robuste
  return new Intl.NumberFormat('zh-CN', {
    style: 'currency',
    currency: 'CNY',
    minimumFractionDigits: 2,
  }).format(value)
}

Relancez : toujours vert. Un tour Red-Green-Refactor complet.

En projet, je reste en watch : sauvegarde → tests → résultat → code → sauvegarde. Quelques secondes, sans quitter l’éditeur. Savoir tout de suite si c’est bon change vraiment l’expérience.

Couverture et intégration CI

Des tests, oui — mais combien du code est réellement exercé ? Les rapports de couverture répondent à ça.

Configuration de base

Dans vitest.config.ts :

// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',      // ou 'istanbul', v8 est plus rapide par défaut
      reporter: ['text', 'html', 'json-summary'],
      reportsDirectory: './coverage',
      include: ['src/**/*.ts'],
      exclude: ['src/**/*.test.ts', 'src/types/**'],
      thresholds: {
        statements: 80,
        branches: 75,
        functions: 80,
        lines: 80,
      },
    },
  },
})

Deux providers : v8 (API native V8, rapide) et istanbul (classique, large compatibilité). Projet Vite/Node pur : v8 suffit.

Les reporters : text en terminal, html pour une vue navigateur, json-summary pour la CI.

Seuils

thresholds couvre quatre dimensions :

  • statements : part des instructions exécutées
  • branches : chaque branche if/else testée ou non
  • functions : fonctions invoquées au moins une fois
  • lines : lignes touchées (proche de statements, calcul légèrement différent)

Je vise en général 75 % à 85 %. En dessous, peu utile ; au-dessus, l’équipe court après des pourcentages — certaines branches (garde-fous, erreurs rares) sont difficiles à couvrir à 100 %.

GitHub Actions

La couverture brille en CI pour bloquer les PR sous le seuil. Exemple .github/workflows/test.yml :

- name: Run tests with coverage
  run: npm run test -- --coverage

- name: Check coverage threshold
  run: |
    COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
    if (( $(echo "$COVERAGE < 80" | bc -l) )); then
      echo "Coverage $COVERAGE% is below threshold 80%"
      exit 1
    fi

Sous 80 %, la PR ne merge pas ; l’équipe renforce les tests avant merge.

Lire le rapport

npx vitest --coverage affiche par exemple :

 % Stmts   % Branch   % Funcs   % Lines   Uncovered Line
----------|----------|----------|----------|----------------
  82.45    |   76.32   |   85.71   |   82.45   | 23-25, 67

Uncovered Line liste les lignes non couvertes. coverage/index.html colore le détail : vert couvert, rouge manquant.

Au début, j’avais la manie du 100 %. Inutile en pratique : 80 % couvre souvent cœur et branches principales ; le reste, ce sont des cas limites où forcer des tests coûte plus qu’il ne rapporte.

Les trois mocks : vi.fn, vi.spy, vi.mock

Le plus délicat en test, ce sont les dépendances externes — API, timers, libs tierces. Vitest propose trois outils, chacun pour un usage.

vi.fn() : fausse fonction

Quand il vous faut une fonction factice et que seuls les appels comptent :

test('le callback doit être appelé une fois', () => {
  const callback = vi.fn()

  callMeMaybe(callback)

  expect(callback).toHaveBeenCalledTimes(1)
  expect(callback).toHaveBeenCalledWith('hello')
})

callback enregistre appels, arguments, retours. mockReturnValue et mockImplementation pilotent le comportement.

vi.spy() : espionner une vraie fonction

Parfois vous gardez la fonction réelle et vous vérifiez seulement qu’elle a été appelée :

test('doit appeler console.log', () => {
  const logSpy = vi.spyOn(console, 'log')

  greet('World')

  expect(logSpy).toHaveBeenCalledWith('Hello, World!')
  logSpy.mockRestore()  // ne pas oublier de restaurer
})

Le spy conserve le comportement d’origine tout en journalisant. mockRestore() évite de polluer les tests suivants.

vi.mock() : remplacer un module

Pour simuler une API ou une lib entière :

// mock axios
vi.mock('axios', () => ({
  default: {
    get: vi.fn(() => Promise.resolve({ data: { name: 'test' } }))
  }
}))

test('getUser doit renvoyer les données', async () => {
  const user = await getUser(1)

  expect(user.name).toBe('test')
  expect(axios.get).toHaveBeenCalledWith('/users/1')
})

Attention : vi.mock est hissé en tête de fichier ; le corps du mock ne peut pas dépendre de variables définies plus bas.

Lequel choisir ?

En bref :

  • Fausse fonction isolée → vi.fn()
  • Observer sans remplacer → vi.spy()
  • Module entier → vi.mock()

J’avais du mal à les distinguer ; le mnémonique aide : fn fabrique, spy observe, mock remplace tout.

Nettoyer entre les tests

L’isolation entre tests est la base de la fiabilité :

afterEach(() => {
  vi.restoreAllMocks()
})

Ou globalement :

test: {
  restoreMocks: true
}

Vitest UI et astuces de debug

La ligne de commande suffit souvent ; pour une vue plus riche, essayez Vitest UI.

Interface visuelle

npx vitest --ui

Le navigateur ouvre une liste de tests à gauche, le détail à droite. Clic sur un test : sortie, stack, durée. Bouton couverture → rapport HTML.

Idéal pour déboguer : échec visible sans fouiller le terminal, code à côté, rafraîchissement à la sauvegarde.

Mode watch : tests impactés seulement

En dev, npx vitest en watch ne relance que ce qui touche le fichier modifié — pas toute la suite.

Sur un projet à 800+ tests, full ~4 s, incrémental souvent quelques centaines de ms.

Astuces

Test qui bloque ?

Un seul test : .only sur it

it.only('ce test seul', () => {
  // ...
})

Ignorer : .skip

it.skip('à traiter plus tard', () => {
  // ...
})

Snapshots : après changement de composant

npx vitest -u  # --update

console.log : vieille méthode, Vitest affiche la sortie dans le rapport.

Erreurs fréquentes

ErreurCauseSolution
Cannot find moduleAlias mal configuréVérifier alias dans vitest.config
vi.mock is not a functionMauvais importimport { vi } from 'vitest'
Fuseau horaireUTC par défautprocess.env.TZ = 'Asia/Shanghai' dans setup

Je les ai toutes eues ; le fuseau en CI m’a fait perdre une demi-journée — tests verts en local, rouges sur le runner.

Conclusion

En résumé : Vitest + TDD rend les tests moins pénibles.

Passer de dizaines de secondes à quelques secondes, ce n’est pas qu’un chiffre : plus d’aller-retour entre éditeur et attente. ESM natif, config Vite partagée — du concret au quotidien.

Red-Green-Refactor paraît bizarre au départ ; après quelques cycles, chaque pas est petit, validé, léger pour la tête. Pas besoin du design parfait d’un coup : les tests guident l’itération.

Couverture et mocks sont des outils ; l’essentiel reste l’habitude — pas pour le pourcentage, pour la confiance dans le code.

Projet déjà sur Vite ? Migrer de Jest coûte peu : npm add -D vitest, syntaxe quasi identique, c’est parti. Hésitant ? Testez sur un petit module et le watch immédiat.

Lancez votre premier test Vitest, essayez la boucle TDD, et ce sentiment « je sais tout de suite si c’est bon ». Vous pourriez, comme moi, ne plus vouloir s’en passer.

Workflow TDD avec Vitest

Développer une fonction en TDD de zéro et configurer les rapports de couverture

⏱️ Estimated time: 30 min

  1. 1

    Step 1: Installer Vitest

    Dans un projet Vite, ajoutez Vitest :

    npm add -D vitest

    Aucune config supplémentaire : Vitest réutilise la config Vite
  2. 2

    Step 2: Phase Red : écrire un test qui échoue

    Créez le fichier de test avec un cas voué à échouer :

    import { describe, it, expect } from 'vitest'
    import { formatPrice } from './formatPrice'

    describe('formatPrice', () => {
    it('doit formater la devise', () => {
    expect(formatPrice(1234.5)).toBe('¥1,234.50')
    })
    })

    Lancez npx vitest et confirmez l'échec (rouge)
  3. 3

    Step 3: Phase Green : code minimal

    Créez l'implémentation avec le strict minimum pour passer :

    export function formatPrice(value: number): string {
    return '¥1,234.50' // hardcodé d'abord
    }

    Relancez les tests : tout doit passer (vert)
  4. 4

    Step 4: Phase Refactor : optimiser

    Refactorisez avec Intl.NumberFormat :

    export function formatPrice(value: number): string {
    return new Intl.NumberFormat('zh-CN', {
    style: 'currency',
    currency: 'CNY',
    }).format(value)
    }

    Vérifiez que les tests passent toujours
  5. 5

    Step 5: Configurer la couverture

    Dans vitest.config.ts :

    test: {
    coverage: {
    provider: 'v8',
    thresholds: { statements: 80, branches: 75 }
    }
    }

    Lancez npx vitest --coverage pour le rapport

FAQ

Quelle différence principale entre Vitest et Jest ?
Vitest est conçu pour Vite, supporte ESM nativement et est 5 à 10× plus rapide que Jest. Il réutilise la config Vite (alias, variables d'environnement) sans moduleNameMapper comme avec Jest.
Comment fonctionne le cycle Red-Green-Refactor du TDD ?
Trois étapes en boucle :

• Red : écrire d'abord un test qui échoue pour définir le besoin
• Green : écrire le code minimal pour passer, même en dur
• Refactor : améliorer la structure sous la protection des tests

Chaque pas est petit, la charge cognitive reste faible, les tests couvrent tout le chemin.
Quel seuil de couverture choisir ?
75 % à 85 % est un bon compromis. Trop bas, peu d'intérêt ; trop haut, l'équipe s'épuise. 80 % couvre en général la logique centrale ; les 20 % restants sont souvent des cas limites extrêmes, coûteux à tester.
Quand utiliser vi.fn, vi.spy ou vi.mock ?
Selon le scénario :

• vi.fn() : créer une fausse fonction et enregistrer appels et arguments
• vi.spy() : espionner une fonction existante en gardant le comportement ; pensez à mockRestore()
• vi.mock() : remplacer tout un module (API, lib tierce)

Mnémonique : fn fabrique, spy observe, mock remplace tout.
Quels avantages du mode watch Vitest ?
Les tests incrémentaux ne relancent que les fichiers impactés : 800 tests en ~4 s en full, quelques centaines de ms en incrémental. À la sauvegarde, résultat immédiat sans changer de fenêtre — idéal pour rester en flow.
Comment gérer les problèmes de fuseau horaire en test ?
Vitest utilise UTC par défaut. Dans vitest.config.ts, setupFiles avec process.env.TZ = 'Asia/Shanghai', ou définissez le fuseau en tête du fichier de test.

8 min de lecture · Publié le: 29 avr. 2026 · Mis à jour le: 27 juil. 2026

Commentaires

Connectez-vous avec GitHub pour laisser un commentaire

Easton BlogEaston Blog