Sur un repo “starter”, Vitest c’est presque insultant de simplicité. Sur une base existante, c’est une autre histoire. Le problème n’est pas Vitest en lui-même, c’est ce qu’il révèle d’un projet réel : un peu de CommonJS qui traîne, des alias TypeScript résolus différemment selon les outils, et des imports qui déclenchent des trucs en prod… mais qui deviennent un cauchemar en tests.
Si tu es en train de te dire « pourquoi ça marche dans le tuto mais pas chez moi », tu es exactement au bon endroit. Je te propose une méthode de stabilisation que j’utilise quand j’arrive sur un projet déjà vivant, avec ses dettes et ses habitudes.
Pourquoi Vitest “marche en 15 minutes” sur un repo neuf (et pas chez toi)
Vitest est construit autour de Vite, et Vite assume un monde moderne : ESM, bundling, résolution d’imports “à la Vite”, transformation de code à la volée. Sur un projet neuf (ou un frontend pur), tu es déjà dans cette logique. Sur une app Node/React existante, tu as souvent un mélange : du code compilé par tsc, du runtime Node qui exécute des fichiers “comme ils sont”, et des libs qui n’ont pas toutes le même rapport à ESM.
Résultat, tu te retrouves avec des symptômes qui ressemblent à du hasard : un test qui passe seul mais casse en suite, un import qui fonctionne dans l’app mais échoue dans Vitest, un module qui devient “undefined” après transformation, ou un simple require() conditionnel qui pète parce qu’il n’était jamais censé être analysé par une pipeline de bundling.
La bonne nouvelle, c’est que ce n’est pas une malédiction. Ça se cartographie. Et une fois que tu sais où est la frontière entre “runtime Node” et “runtime Vite/Vitest”, tu reprends la main.
Sponsorisé par Le Scribouillard
Besoin de contenu optimisé SEO ?
Utilisez la meilleure plateforme française de création de contenu assistée par IA ! Et générez des articles pour moins de 1€ !
Interop ESM/CJS : le point de friction n°1 (et comment l’identifier vite)
Le piège classique, c’est le projet qui a des bouts ESM et des bouts CommonJS, parfois sans le dire clairement. Un "type": "module" dans package.json, puis un vieux fichier qui fait encore module.exports, un import “default” qui marche avec Babel mais pas ailleurs, ou une lib CJS importée comme si c’était de l’ESM. En prod, ça passe parfois parce que ton bundler/ts-node/loader “arrange” les coins. En test, Vitest applique ses règles, et ça devient brutal.
Mon réflexe : trouver le premier stacktrace “utile”, celui qui parle d’un import. Pas les 200 lignes derrière. Est-ce que ça casse au moment où Vitest transforme du code, ou au moment où Node exécute ? Si tu vois des erreurs du type « Cannot use import statement outside a module », « require is not defined », « Named export not found », tu es dans l’interop. Et là, il faut arrêter de bricoler test par test et décider ce que ton projet veut être.
Deux décisions te simplifient la vie : soit tu assumes un runtime ESM partout (y compris côté tests), soit tu poses une frontière claire (par exemple, tests en ESM mais certaines dépendances restent CJS et tu les traites explicitement). Le flou, c’est ce qui te fait perdre 9 heures.
Alias TypeScript : pourquoi tsc, Vite et Vitest ne “voient” pas la même chose
Le grand classique sur une base TS : tout compile avec tsc, ton IDE est content, et Vitest te sort un « Cannot find module @/… ». Ce n’est pas parce que TypeScript “ment”, c’est parce que TypeScript ne résout pas forcément comme ton runtime. Les paths de tsconfig.json aident le compilateur et l’IDE, mais côté exécution il faut un résolveur. Dans une app, tu as peut-être déjà un alias géré par Webpack, par Vite côté front, ou par un loader Node. Vitest, lui, est dans le monde Vite, donc il faut aligner la résolution.
La solution la plus rentable, c’est d’arrêter d’écrire une deuxième vérité dans vitest.config et de réutiliser le tsconfig. En pratique, vite-tsconfig-paths fait très bien le boulot. Si tu ne le fais pas, tu vas finir avec des alias dupliqués, qui divergent, et tu vas “corriger” les tests en cassant discrètement l’app ou l’inverse.
Imports à effets de bord : le vrai tueur silencieux (DB, env, singletons, cron…)
Sur un projet existant, il y a souvent des modules qui font des choses au moment de l’import. Initialiser une connexion DB. Lire des variables d’environnement et throw si ça manque. Instancier un client Redis. Démarrer un scheduler. En prod, c’est confortable : tu importes et “ça tourne”. En test, c’est toxique : le simple fait d’importer une fonction à tester peut déclencher un accès réseau, une config manquante, ou un ordre d’initialisation aléatoire.
Ce genre de code donne des tests instables et, pire, te pousse à mocker n’importe comment. Tu te retrouves à écrire des vi.mock partout pour empêcher ton app de s’allumer. Tu crois que tu écris des tests, en fait tu écris une couche de mensonges.
Le fix n’est pas “un hack Vitest”. Le fix est architectural, mais souvent léger : transformer l’initialisation eager en initialisation lazy. Au lieu de créer un client DB au top-level du module, tu exposes une factory getDb() ou un createDbClient(config). Tu fais remonter les dépendances dans les fonctions (ou tu utilises une injection simple). Et tu réserves l’init globale à ton point d’entrée runtime, pas à un module partagé.
Ça a un effet immédiat : tes tests redeviennent des imports “qui ne font rien”, donc tu peux tester du code sans avoir besoin d’un environnement complet. Et en prime, tu as souvent amélioré ton démarrage en prod et ta lisibilité.
Où poser la frontière : unitaires rapides vs tests d’intégration assumés
Vitest donne envie de tout tester vite. Mais sur une base existante, la question n’est pas juste “comment faire passer les tests”. C’est “qu’est-ce qu’on est en train de tester exactement”. Si tu veux faire de l’unitaire, tu dois pouvoir importer un module sans démarrer la planète. Si ton code ne le permet pas encore, insiste moins sur l’unitaire pur, et assume une couche d’intégration plus proche du runtime, avec un env de test propre, une base éphémère, ou des doubles contrôlés.
Le mauvais arbitrage, c’est de tout transformer en unitaires via du mock massif. Tu vas obtenir des tests verts et une fausse confiance. Le bon arbitrage, c’est de choisir : certaines briques se prêtent très bien à l’unitaire (helpers, logique métier pure, mapping), d’autres gagnent à être testées en intégration (accès DB, adaptateurs, code d’API), parce que la valeur du test est justement dans l’assemblage.
Et oui, ça peut donner deux “vitesses” de tests. C’est normal. Ce qui ne l’est pas, c’est un test suite “rapide” qui dépend du réseau sans l’avouer, ou une suite “lente” qui ne te dit pas clairement qu’elle fait de l’intégration.
La méthode de debug que j’utilise : faire passer un test minimal, puis élargir
Quand Vitest part en vrille sur une base existante, le pire truc à faire c’est de corriger 20 erreurs en parallèle. Je repars d’un test minimal qui doit passer, puis j’élargis progressivement. Typiquement, je teste une fonction pure, sans import profond, juste pour valider que Vitest exécute du TS/JS comme je le pense, dans le bon environnement.
Ensuite seulement, j’ajoute un import réel du projet. Si ça casse, tu sais que ce n’est pas “Vitest est mal installé”, c’est un problème de résolution ou de side effects. Et tu remontes. Chaque fois que tu ajoutes un import et que ça explose, tu as trouvé une zone du codebase qui ne se comporte pas comme un module testable. C’est précieux : c’est un signal, pas une punition.
Dans cette phase, j’évite aussi le réflexe « je vais juste mettre testEnvironment: jsdom » pour calmer une erreur. Si ton bug est un alias ou un CJS, changer l’environnement masque parfois le symptôme et te fait perdre du temps. Choisis l’environnement (node ou jsdom) parce qu’il correspond au code, pas parce qu’il fait taire une stacktrace.
Une config Vitest “projet existant” qui évite les divergences d’alias
Une config stable, c’est d’abord une config qui ne duplique pas la réalité. Pour les alias TS, je préfère m’appuyer sur le tsconfig. Pour le reste, je mets les choix explicites : environnement, setup, et un minimum de réglages sur les dépendances quand j’ai une lib qui se comporte mal en transformation.
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: 'node',
globals: true,
setupFiles: ['./test/setup.ts'],
// Si tu as une dépendance CJS qui casse après transform,
// tu peux parfois la forcer en inline. À utiliser au cas par cas.
// deps: { inline: [/some-cjs-only-lib/] },
},
})
Et côté test/setup.ts, j’essaie de rester sobre. Un setup est utile pour fixer le timezone, charger un dotenv de test, ou reset des mocks. Un setup n’est pas un endroit pour démarrer une DB “parce que sinon ça plante”. Si tu en arrives là, c’est souvent que tes imports ont encore des effets de bord.
Les erreurs fréquentes (celles qui te font “corriger” au mauvais endroit)
La plus fréquente, c’est de croire que Vitest est “juste Jest mais plus rapide”. Non. Le modèle d’exécution est différent, surtout sur la partie transform/résolution. Traduction terrain : un projet qui a appris à survivre avec des patches Jest peut se casser autrement avec Vitest.
Autre classique : mélanger le diagnostic. Tu as une erreur d’alias, tu changes ton tsconfig. Tu as un problème ESM/CJS, tu rajoutes un alias. Tu as un side effect, tu ajoutes un mock. Ça “marche” localement, puis ça recasse sur une autre machine ou en CI. Je préfère une règle simple : si l’erreur est une résolution d’import, je corrige la résolution. Si l’erreur est une init au import, je corrige le code pour le rendre importable sans démarrage. Si l’erreur est une incompatibilité ESM/CJS, je clarifie la frontière.
Ce n’est pas sexy, mais c’est ce qui fait qu’une suite de tests reste stable dans six mois, quand quelqu’un ajoute un nouveau module et déclenche une init cachée.
Conclusion : stabiliser Vitest, c’est souvent stabiliser ton architecture
Quand Vitest te fait pleurer sur un projet existant, ce n’est pas “juste un outil capricieux”. C’est un crash-test. Il te montre où tu as des effets de bord, où ta résolution d’imports n’est pas alignée, et où tu mélanges runtime Node et bundling sans le dire.
Si tu prends le problème dans le bon ordre, tu peux obtenir une suite rapide et fiable. Et surtout, tu vas te retrouver avec un codebase plus modulaire, plus testable, et moins dépendant de “l’ordre magique” des imports. Le genre de gain qui dépasse largement le sujet Vitest.