Abandonar Jest en favor del test runner integrado de Node
Node test runner frente a Jest: funciones estables, watch mode, snapshots, fake timers, coverage, soporte TypeScript y límites al migrar.
El test runner integrado de Node puede reemplazar a Jest en la mayoría de las suites del lado del servidor. El runner en sí es estable desde Node 20.0.0, y el modo watch, las pruebas de snapshot y los fake timers ya existen hoy, aunque las guías de migración más antiguas los sigan listando como ausentes.
La frustración que impulsa esta migración resulta familiar. Tu servicio es ESM puro y, sin embargo, tu comando de test arrastra un pipeline de transformación, un archivo de configuración y un árbol de dependencias que se rompe en las actualizaciones mayores, todo para ejecutar funciones y hacer aserciones sobre los resultados. La pregunta abierta no es si node:test existe, sino qué partes de él son lo bastante estables como para integrarlas en CI. Lo que sigue recorre la superficie de funcionalidades en orden, indica el nivel de estabilidad de cada pieza según la documentación del test runner de Node y expone qué pierdes frente a Jest y Vitest.
Puntos clave
- El test runner de Node es estable desde la v20.0.0, pero la cobertura todavía requiere el flag experimental
--experimental-test-coveragey el modo watch también está marcado como experimental. - Las pruebas de snapshot llegaron en la v22.3.0 y se volvieron estables en la v23.4.0; los fake timers mediante
mock.timersson estables desde la v23.1.0 y pueden simularDate. - Las exportaciones de los módulos ES están congeladas, por lo que
mock.methodno puede reemplazar una exportación con nombre; exporta un objeto en su lugar, o usa el experimentalmock.module()detrás de--experimental-test-module-mocks. - Los archivos de prueba
.tsse ejecutan sin loader porque el type stripping está activado por defecto, estable desde la v24.12.0. - Lo que cedes al dejar Jest no son funcionalidades sino ergonomía: el vocabulario de matchers, el entorno jsdom y los helpers de stub de una línea como
mockResolvedValue.
¿Qué obtienes sin dependencias?
La base sin dependencias es node:test para la estructura y node:assert para las aserciones, ejecutados con node --test. Obtienes describe/it (alias de suite/test), los hooks before/after/beforeEach/afterEach, subtests, skip y todo, y un código de salida distinto de cero cuando falla.
// 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 estabilidad se define por funcionalidad, no por módulo, y esa distinción es lo que importa antes de comprometer un pipeline. Así está cada pieza:
| Funcionalidad | Flag / API | Estado | Versión |
|---|---|---|---|
| Núcleo del runner | node --test | Estable | Estable desde v20.0.0 |
| Modo watch | --watch | Experimental | Añadido en v19.2.0 |
| Snapshots | t.assert.snapshot() | Estable | Añadido en v22.3.0, estable en v23.4.0 |
| Fake timers | mock.timers | Estable | Estable desde v23.1.0 |
| Cobertura | --experimental-test-coverage | Experimental | - |
| Mocking de módulos | mock.module() | Desarrollo temprano | Añadido en v22.3.0 / v20.18.0 |
| Etiquetas de test | --experimental-test-tag-filter | Desarrollo temprano | Añadido en v26.2.0, retroportado a v24.19.0 |
| Type stripping de TypeScript | activado por defecto | Estable | Estable desde v24.12.0 |
Ejecutar y filtrar con el test runner de Node
Sin argumentos, node --test descubre archivos que coincidan con **/*.test.{cjs,mjs,js}, **/*-test.{cjs,mjs,js}, **/*_test.{cjs,mjs,js}, **/test-*.{cjs,mjs,js}, **/test.{cjs,mjs,js} y **/test/**/*.{cjs,mjs,js}, además de los mismos seis patrones con {cts,mts,ts} salvo que desactives el type stripping con --no-strip-types. También puedes pasar globs explícitos como argumentos.
El filtrado se corresponde directamente con los hábitos de 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 es la pieza que los usuarios de Jest echan de menos primero: marcar un test con { only: true } no hace nada a menos que se pase el flag. Las etiquetas de test llegaron con --experimental-test-tag-filter en la v26.2.0 y fueron retroportadas a la línea LTS en la v24.19.0, ambas con estabilidad de desarrollo temprano. La sintaxis del filtro no es idéntica en las dos líneas: la v26 acepta expresiones booleanas y comodines, mientras que la 24.x hace coincidencia con nombres literales de etiquetas. En cualquier caso, el desarrollo temprano es demasiado verde como para condicionar un pipeline a ello.
Modo watch
El modo watch existe y se invoca con node --test --watch. Vigila tus archivos de prueba y los módulos que estos importan, y luego vuelve a ejecutar aquello a lo que afecte un cambio. La documentación sigue marcando el modo watch como Estabilidad 1, Experimental, añadido en la v19.2.0. En la práctica eso significa que está bien como bucle de desarrollo local y que debería quedarse fuera de los scripts de CI, que de todos modos no lo necesitan.
{
"scripts": {
"test": "node --test",
"test:watch": "node --test --watch"
}
}
La cobertura sigue detrás de un flag
La cobertura de código todavía requiere --experimental-test-coverage, así que un pipeline condicionado por cobertura está optando por una superficie inestable. Delimita qué se mide con los globs --test-coverage-include y --test-coverage-exclude, y emite salida legible por máquinas para CI con el reporter lcov:
node --test --experimental-test-coverage \
--test-coverage-include='src/**' \
--test-reporter=lcov --test-reporter-destination=lcov.info
Los umbrales también son aplicables, mediante --test-coverage-lines, --test-coverage-branches y --test-coverage-functions, o a través de las opciones equivalentes lineCoverage, branchCoverage y functionCoverage de la API programática run(). Los otros reporters integrados son spec (el predeterminado), tap, dot y junit.
Mocking: spies, timers y el muro de las exportaciones congeladas
El objeto mock de node:test cubre spies (mock.fn), stubs de métodos (mock.method) y fake timers (mock.timers). No existe mockResolvedValue; los resultados asíncronos se stubean con una mockImplementation async. Las aserciones se leen desde mock.callCount() y mock.calls[n].arguments en lugar de 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']);
});
Los fake timers son estables desde la v23.1.0 y simulan setTimeout, setInterval, setImmediate y Date, avanzando con tick() o runAll(). Hay una carencia que conviene conocer: si extraes un timer de un módulo mediante destructuring, como en import { setTimeout } from 'node:timers', el mock no se le aplicará.
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 verdadera restricción es el mocking de módulos. Las exportaciones de los módulos ES están congeladas, por lo que mock.method no puede reemplazar una exportación con nombre; la solución duradera es exportar un objeto y mockear el método sobre él:
// before: cannot be stubbed
export function fetchUser(id) { /* ... */ }
// after: stubbable with mock.method(api, 'fetchUser')
export const api = {
fetchUser(id) { /* ... */ },
};
Node sí incluye una alternativa oficial, mock.module(), que mockea módulos ESM, CJS, JSON e integrados, pero se encuentra detrás de --experimental-test-module-mocks con estabilidad de desarrollo temprano. Úsalo para experimentar, no para sostener una suite de CI.
TypeScript sin loader
Node ejecuta archivos de prueba .ts, .mts y .cts directamente mediante type stripping, que está habilitado por defecto (desde la v23.6.0 y la v22.18.0) y es estable desde la v24.12.0, es decir, estable en la línea LTS 24.x. El test runner reconoce automáticamente los patrones de archivos TypeScript salvo que pases --no-strip-types. La receta más antigua de conectar un loader como tsx, descrita en el artículo de migración de la era Node 20 de Mehul Kar, ya es historia para la ejecución de pruebas, aunque el stripping solo borra tipos, así que enum y otra sintaxis de TS con presencia en tiempo de ejecución siguen necesitando una transformación.
¿A qué renuncias frente a Jest y Vitest?
El intercambio honesto es de ergonomía, no de capacidad. Tres pérdidas son reales. Primera, el ecosistema de matchers: el expect de Jest te da toHaveBeenNthCalledWith y cientos de matchers de la comunidad, mientras que node:assert te deja componiendo aserciones a partir de deepStrictEqual y mock.calls. El punto de extensión es assert.register(), añadido en la v23.7.0 y la v22.14.0, que permite definir aserciones personalizadas sobre el contexto de prueba. Segunda, los entornos tipo navegador: no hay equivalente a jsdom o happy-dom, así que las pruebas de componentes que tocan el DOM deberían quedarse en Vitest o Jest. Tercera, la comodidad al hacer stubs: no hay mockResolvedValue, no hay test.each (un bucle for...of hace el trabajo) y el stubbing por llamada pasa por mockImplementationOnce en lugar de helpers encadenados. La guía de migración de Erick Wendel mapea estas traducciones par por par, aunque su sección de fake timers es anterior a la llegada de la API mock.timers y se lee como una propuesta preliminar.
¿En qué situación deja esto a una suite en migración?
Para un servicio de Node, una CLI o una librería que nunca toca el DOM, el runner integrado cubre el núcleo estable de lo que hacía Jest, sin dependencias y sin capa de transformación; los bordes experimentales que quedan son la cobertura, el modo watch, el mocking de módulos y las etiquetas. Un camino de bajo riesgo es convertir un paquete, mantener el control de cobertura en tu herramienta actual hasta que el flag desaparezca y reescribir las aserciones cargadas de matchers a medida que las vayas tocando. Ejecuta node --test sobre un único archivo convertido y comprueba cuánto de tu directorio de configuración puedes eliminar.
Preguntas frecuentes
¿node --test ejecuta los archivos de prueba en paralelo?
Sí. El aislamiento por procesos es el comportamiento por defecto, de modo que cada archivo de prueba obtiene su propio proceso hijo, y --test-concurrency define cuántos de ellos pueden ejecutarse al mismo tiempo. Dentro de un mismo archivo, las pruebas siguen ejecutándose una tras otra salvo que establezcas una opción de concurrencia en test o describe. Si tus suites comparten una base de datos, un puerto o estado global, --test-concurrency=1 lo limita a un archivo a la vez.
¿Puedo ejecutar Jest y node:test en paralelo durante una migración?
Sí. Los runners son independientes, así que puedes mantener scripts de npm separados y migrar archivo por archivo. El inconveniente es el solapamiento en el descubrimiento: ambos coinciden por defecto con archivos como *.test.js, así que delimita cada runner con globs explícitos, directorios separados o la opción testMatch de Jest para evitar que los archivos convertidos se ejecuten dos veces o que los no convertidos fallen bajo node --test.
¿Funciona node:test con proyectos CommonJS?
Sí. El runner es agnóstico respecto al sistema de módulos: require('node:test') y require('node:assert') funcionan en archivos CommonJS, y los patrones de descubrimiento por defecto incluyen explícitamente .cjs junto a .mjs y .js. El único requisito es el esquema node:, de modo que require('test') o import test from 'test' falla. Un código base mixto puede ejecutar archivos de prueba ESM y CJS en la misma invocación de node --test.
¿Qué versión de Node debería fijar como objetivo para adoptar node:test en CI?
Node 24 LTS cubre el núcleo estable: el runner (estable desde la v20.0.0), las pruebas de snapshot, los fake timers de mock.timers y el type stripping de TypeScript por defecto. La cobertura y el modo watch siguen siendo experimentales en todas las líneas de versión. Dos funcionalidades más recientes del test runner llegaron a la 24.x por retroportado en lugar de quedarse en la línea actual, las etiquetas de test en la v24.19.0 y la aleatorización del orden de ejecución en la v24.16.0, pero ambas están en desarrollo temprano, así que todavía no construyas controles de CI sobre ellas.
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