Abandonner Jest au profit du test runner intégré de Node
Node test runner face à Jest : fonctions stables, watch mode, snapshots, fake timers, coverage, support TypeScript et limites de migration.
Le test runner intégré de Node peut remplacer Jest pour la plupart des suites côté serveur. Le runner lui-même est stable depuis Node 20.0.0, et le mode watch, les tests par snapshot et les faux timers existent tous aujourd’hui, même si les guides de migration plus anciens les mentionnent encore comme manquants.
La frustration qui motive cette migration est bien connue. Votre service est en ESM pur, et pourtant votre commande de test entraîne avec elle un pipeline de transformation, un fichier de configuration et un arbre de dépendances qui casse à chaque montée de version majeure, tout cela pour exécuter des fonctions et vérifier des résultats. La vraie question n’est pas de savoir si node:test existe, mais quelles parties sont suffisamment stables pour être intégrées à une CI. Ce qui suit parcourt les fonctionnalités dans l’ordre, indique le niveau de stabilité de chaque élément d’après la documentation du test runner de Node, et expose ce que vous perdez par rapport à Jest et Vitest.
Points clés à retenir
- Le test runner de Node est stable depuis la v20.0.0, mais la couverture de code nécessite toujours le flag expérimental
--experimental-test-coverageet le mode watch est lui aussi marqué comme expérimental. - Les tests par snapshot sont arrivés en v22.3.0 et sont devenus stables en v23.4.0 ; les faux timers via
mock.timerssont stables depuis la v23.1.0 et peuvent simulerDate. - Les exports des modules ES sont figés (frozen), donc
mock.methodne peut pas remplacer un export nommé ; exportez plutôt un objet, ou utilisez la fonction expérimentalemock.module()derrière--experimental-test-module-mocks. - Les fichiers de test
.tss’exécutent sans loader car le type stripping est activé par défaut, et stable depuis la v24.12.0. - Ce que vous perdez en quittant Jest, ce ne sont pas des fonctionnalités mais de l’ergonomie : le vocabulaire des matchers, l’environnement jsdom, et les helpers de stub en une ligne comme
mockResolvedValue.
Qu’obtient-on sans aucune dépendance ?
La base zéro dépendance, c’est node:test pour la structure et node:assert pour les assertions, le tout exécuté avec node --test. Vous disposez de describe/it (alias de suite/test), des hooks before/after/beforeEach/afterEach, des sous-tests, de skip et todo, ainsi que d’un code de sortie non nul en cas d’échec.
// math.test.js
import { describe, it } from 'node:test';
import assert from 'node:assert';
describe('add', () => {
it('sums two numbers', () => {
assert.strictEqual(1 + 2, 3);
});
});
node --test
La stabilité s’évalue fonctionnalité par fonctionnalité, et non module par module, et cette distinction est déterminante avant d’engager un pipeline. Voici où en est chaque élément :
| Fonctionnalité | Flag / API | Statut | Version |
|---|---|---|---|
| Cœur du runner | node --test | Stable | Stable depuis la v20.0.0 |
| Mode watch | --watch | Expérimental | Ajouté en v19.2.0 |
| Snapshots | t.assert.snapshot() | Stable | Ajouté en v22.3.0, stable en v23.4.0 |
| Faux timers | mock.timers | Stable | Stable depuis la v23.1.0 |
| Couverture de code | --experimental-test-coverage | Expérimental | - |
| Mocking de modules | mock.module() | Développement初期 (early development) | Ajouté en v22.3.0 / v20.18.0 |
| Tags de tests | --experimental-test-tag-filter | Début de développement | Ajouté en v26.2.0, rétroporté en v24.19.0 |
| Type stripping TypeScript | activé par défaut | Stable | Stable depuis la v24.12.0 |
Exécuter et filtrer avec le test runner de Node
Sans argument, node --test découvre les fichiers correspondant à **/*.test.{cjs,mjs,js}, **/*-test.{cjs,mjs,js}, **/*_test.{cjs,mjs,js}, **/test-*.{cjs,mjs,js}, **/test.{cjs,mjs,js} et **/test/**/*.{cjs,mjs,js}, ainsi que les six mêmes motifs avec {cts,mts,ts}, sauf si vous désactivez le type stripping avec --no-strip-types. Vous pouvez également passer des globs explicites en arguments.
Le filtrage se transpose directement depuis les habitudes prises avec Jest :
node --test --test-name-pattern="parses headers" # like jest -t
node --test --test-skip-pattern="integration" # inverse filter
node --test --test-only # honor { only: true }
--test-only est l’élément qui manque en premier aux utilisateurs de Jest : marquer un test avec { only: true } n’a aucun effet si le flag n’est pas passé. Les tags de tests sont arrivés avec --experimental-test-tag-filter en v26.2.0 et ont été rétroportés sur la ligne LTS en v24.19.0, dans les deux cas au stade « début de développement ». La syntaxe du filtre n’est pas identique sur les deux lignes : la v26 accepte les expressions booléennes et les jokers, tandis que la 24.x ne reconnaît que les noms de tags littéraux. Quoi qu’il en soit, un stade aussi précoce reste trop instable pour y conditionner un pipeline.
Le mode watch
Le mode watch existe et s’invoque avec node --test --watch. Il surveille vos fichiers de test ainsi que les modules qu’ils importent, puis relance ce qui est affecté par un changement. La documentation classe toujours le mode watch en Stabilité 1, Expérimental, ajouté en v19.2.0. Concrètement, cela signifie qu’il convient parfaitement à une boucle de développement local, mais qu’il doit rester à l’écart des scripts de CI, qui n’en ont de toute façon pas besoin.
{
"scripts": {
"test": "node --test",
"test:watch": "node --test --watch"
}
}
La couverture de code reste derrière un flag
La couverture de code nécessite toujours --experimental-test-coverage : un pipeline conditionné par la couverture s’appuie donc sur une surface instable. Délimitez le périmètre mesuré avec les globs --test-coverage-include et --test-coverage-exclude, et produisez une sortie exploitable par la machine pour la CI grâce au reporter lcov :
node --test --experimental-test-coverage \
--test-coverage-include='src/**' \
--test-reporter=lcov --test-reporter-destination=lcov.info
Les seuils sont eux aussi applicables, via --test-coverage-lines, --test-coverage-branches et --test-coverage-functions, ou via les options équivalentes lineCoverage, branchCoverage et functionCoverage de l’API programmatique run(). Les autres reporters intégrés sont spec (celui par défaut), tap, dot et junit.
Mocking : espions, timers et le mur des exports figés
L’objet mock de node:test couvre les espions (mock.fn), les stubs de méthodes (mock.method) et les faux timers (mock.timers). Il n’existe pas de mockResolvedValue ; vous simulez des résultats asynchrones avec une mockImplementation async. Les assertions se lisent depuis mock.callCount() et mock.calls[n].arguments plutôt que via des matchers :
import { test } from 'node:test';
import assert from 'node:assert';
test('spy records calls', (t) => {
const fn = t.mock.fn();
fn('a');
assert.strictEqual(fn.mock.callCount(), 1);
assert.deepStrictEqual(fn.mock.calls[0].arguments, ['a']);
});
Les faux timers sont stables depuis la v23.1.0 et simulent setTimeout, setInterval, setImmediate et Date, que l’on fait avancer avec tick() ou runAll(). Une limite mérite d’être connue : si vous extrayez un timer d’un module par déstructuration, comme dans import { setTimeout } from 'node:timers', le mock ne s’y appliquera pas.
test('advances mocked time and Date together', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100 });
const fn = t.mock.fn();
setTimeout(fn, 200);
t.mock.timers.tick(200);
assert.strictEqual(fn.mock.callCount(), 1);
assert.strictEqual(Date.now(), 300);
});
La véritable contrainte, c’est le mocking de modules. Les exports des modules ES sont figés, donc mock.method ne peut pas remplacer un export nommé ; le contournement durable consiste à exporter un objet et à mocker la méthode sur celui-ci :
// before: cannot be stubbed
export function fetchUser(id) { /* ... */ }
// after: stubbable with mock.method(api, 'fetchUser')
export const api = {
fetchUser(id) { /* ... */ },
};
Node propose bien une alternative officielle, mock.module(), qui permet de mocker les modules ESM, CJS, JSON et natifs, mais elle se cache derrière --experimental-test-module-mocks au stade « début de développement ». À utiliser pour expérimenter, pas pour asseoir une suite de CI.
TypeScript sans loader
Node exécute directement les fichiers de test .ts, .mts et .cts grâce au type stripping, activé par défaut (depuis les v23.6.0 et v22.18.0) et stable depuis la v24.12.0, donc stable sur la ligne LTS 24.x. Le test runner reconnaît automatiquement les motifs de fichiers TypeScript, sauf si vous passez --no-strip-types. L’ancienne recette consistant à brancher un loader tel que tsx, décrite dans le billet de migration de Mehul Kar à l’époque de Node 20, appartient désormais au passé pour l’exécution des tests — sachant toutefois que le stripping se contente d’effacer les types, si bien que enum et les autres syntaxes TS présentes à l’exécution nécessitent encore une transformation.
Que perd-on par rapport à Jest et Vitest ?
Le compromis réel porte sur l’ergonomie, pas sur les capacités. Trois pertes sont bien réelles. D’abord, l’écosystème de matchers : le expect de Jest vous donne toHaveBeenNthCalledWith et des centaines de matchers communautaires, alors que node:assert vous laisse composer vos assertions à partir de deepStrictEqual et de mock.calls. Le point d’extension est assert.register(), ajouté en v23.7.0 et v22.14.0, qui permet de définir des assertions personnalisées sur le contexte de test. Ensuite, les environnements de type navigateur : il n’existe aucun équivalent de jsdom ou happy-dom, donc les tests de composants qui touchent au DOM doivent rester sur Vitest ou Jest. Enfin, le confort des stubs : pas de mockResolvedValue, pas de test.each (une boucle for...of fait l’affaire), et le stubbing appel par appel passe par mockImplementationOnce plutôt que par des helpers chaînés. Le guide de migration d’Erick Wendel recense ces correspondances une à une, même si sa section sur les faux timers est antérieure à l’arrivée effective de l’API mock.timers et se lit comme un projet de proposition.
Où cela laisse-t-il une suite en cours de migration ?
Pour un service Node, une CLI ou une bibliothèque qui ne touche jamais au DOM, le runner intégré couvre le noyau stable de ce que faisait Jest, sans aucune dépendance ni couche de transformation ; les zones encore expérimentales sont la couverture de code, le mode watch, le mocking de modules et les tags. Une trajectoire à faible risque consiste à convertir un seul package, à conserver le contrôle de couverture sur votre outillage existant jusqu’à ce que le flag disparaisse, et à réécrire les assertions riches en matchers au fil de vos interventions. Lancez node --test sur un unique fichier converti et voyez quelle part de votre répertoire de configuration vous pouvez supprimer.
FAQ
node --test exécute-t-il les fichiers de test en parallèle ?
Oui. L'isolation par processus est le comportement par défaut : chaque fichier de test obtient son propre processus enfant, et --test-concurrency définit combien d'entre eux peuvent s'exécuter simultanément. À l'intérieur d'un même fichier, les tests s'exécutent toujours l'un après l'autre, sauf si vous définissez une option de concurrence sur test ou describe. Si vos suites partagent une base de données, un port ou un état global, --test-concurrency=1 limite l'exécution à un fichier à la fois.
Puis-je faire coexister Jest et node:test pendant une migration ?
Oui. Les runners sont indépendants, vous pouvez donc conserver des scripts npm distincts et migrer fichier par fichier. Le piège tient au recouvrement de la découverte : les deux reconnaissent par défaut des fichiers comme *.test.js, il faut donc délimiter chaque runner avec des globs explicites, des répertoires séparés ou le paramètre testMatch de Jest, afin d'éviter que les fichiers convertis soient exécutés deux fois ou que les fichiers non convertis échouent sous node --test.
node:test fonctionne-t-il avec les projets CommonJS ?
Oui. Le runner est agnostique vis-à-vis du système de modules : require('node:test') et require('node:assert') fonctionnent dans les fichiers CommonJS, et les motifs de découverte par défaut incluent explicitement .cjs aux côtés de .mjs et .js. La seule exigence est le préfixe node:, si bien que require('test') ou import test from 'test' échoue. Une base de code mixte peut exécuter des fichiers de test ESM et CJS dans un même appel à node --test.
Quelle version de Node viser pour adopter node:test en CI ?
Node 24 LTS couvre le noyau stable : le runner (stable depuis la v20.0.0), les tests par snapshot, les faux timers mock.timers et le type stripping TypeScript par défaut. La couverture de code et le mode watch restent expérimentaux sur toutes les lignes de publication. Deux fonctionnalités plus récentes du test runner sont arrivées en 24.x par rétroportage plutôt que de rester sur la ligne courante — les tags de tests en v24.19.0 et la randomisation de l'ordre d'exécution en v24.16.0 — mais toutes deux en sont au début de leur développement : n'y adossez pas encore de contrôles bloquants en CI.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k