Dominá node:test: el test runner nativo de Node.js sin librerías

DUGLAS MORENODUGLAS MORENO 👁 18

Dominá node:test: el test runner nativo de Node.js sin librerías

Introducción

Node.js nació originalmente como una herramienta para ejecutar JavaScript del lado del servidor, pero a lo largo de los años creció hasta convertirse en un ecosistema enorme. Con el tiempo, la comunidad desarrolló un montón de frameworks de testing (Jest, Mocha, Ava, etc.) para cubrir la necesidad de validar código de forma confiable. Sin embargo, a partir de Node.js 18, el equipo principal decidió integrar un test runner nativo—llamado node:test—que funciona sin necesidad de dependencias externas.

Querés aprender a escribir tests claros, async y compatibles con ESM sin instalar una librería adicional. Te gustaría poder integrar tests con la misma herramienta que ya usás para ejecutar tu aplicación, y que los tests funcionen igual de bien con CI, coverage y debugging. En esta guía práctica, vas a aprender todo lo que necesitás saber para empezar a usar node:test hoy mismo, con ejemplos ejecutables que podés copiar y pegar en tu terminal.

Por qué usar node:test

  • Sin dependencias: El test runner ya viene con Node.js. No hay npm install ni versioning conflictivo.
  • Diseñado para ESM: Funciona a la perfección con módulos modernos (import, export), el estándar actual.
  • API simple y consistente: Súper parecido a los frameworks más populares, pero sin capas adicionales que aprender.
  • Compatibilidad con async/await: Soporta funciones asíncronas, promesas y generadores nativamente.
  • Integración con herramientas existentes: Podés combinarlo con c8 para coverage, o con jest si tenés código legado—sin cambios en la configuración de CI.

Configurando un proyecto mínimo

Empezá creando un pequeño proyecto temporal. No necesitás un package.json elaborado; solo un directorio con un archivo de test y un archivo de módulo a testear.

mkdir prueba-node-test && cd prueba-node-test
cat > math.js <<'EOF'
export function add(a, b) {
  return a + b;
}

export function divide(a, b) {
  if (b === 0) throw new Error('División por cero');
  return a / b;
}
EOF

Ahora, creá un archivo de test. El test runner espera archivos con la extensión .test.js o .spec.js. El nombre puede ser cualquier cosa, pero lo convencional es math.test.js.

// math.test.js
import { test } from 'node:test';
import assert from 'node:assert';
import { add, divide } from './math.js';

test('add should sum two numbers', () => {
  const result = add(2, 3);
  assert.strictEqual(result, 5);
});

test('divide should return quotient', () => {
  const result = divide(10, 2);
  assert.strictEqual(result, 5);
});

test('divide with zero throws', () => {
  assert.throws(() => divide(5, 0), Error);
});

Resultado esperado:

·············· 3 passing (0.009s)

El test runner imprimió tres puntos (uno por test) y un mensaje de resumen. Si algún test falla, verás F y un stack trace.

Comprendiendo el ciclo de vida del test runner

El test runner expone tres conceptos principales:

  1. test() – Define un caso individual.
  2. suite() – Define un grupo de tests que se pueden agrupar (opcional, pero útil para organizar).
  3. beforeEach() / afterEach() – Hooks que corren antes o después de cada test.

Todos reciben un callback como argumento, que puede ser sync, async o un generador.

Test básico con test()

El ejemplo anterior ya mostró lo básico. Aquí tenés un ejemplo más compacto usando assert de Node de forma directa:

import { test } from 'node:test';

test('2 + 2 = 4', () => {
  const a = 2;
  const b = 2;
  console.log(`${a} + ${b} = ${a + b}`);
});

Resultado esperado:

·············· 1 passing (0.009s)

El callback recibe un nombre legible para los humanos, muy útil en reportes.

Hooks: beforeEach y afterEach

Imaginá que querés inicializar un recurso antes de cada test, como un cliente de base de datos mockeado. beforeEach y afterEach son perfectos.

import { test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert';

let counter = 0;

beforeEach(() => {
  counter = Math.floor(Math.random() * 100);
});

afterEach(() => {
  console.log(`Test finalizado con counter = ${counter}`);
});

test('counter debe ser un número', () => {
  assert(typeof counter === 'number');
  assert(counter >= 0 && counter < 100);
});

test('counter debe ser diferente entre tests', () => {
  const before = counter;
  // dispara beforeEach de nuevo gracias al siguiente test
});

Resultado esperado (nota los logs extra):

Test finalizado con counter = 42
·············· 2 passing (0.015s)
Test finalizado con counter = 87

El hook afterEach se ejecuta después de cada test, mostrando el valor aleatorio.

Suites: agrupando tests

Si tenés muchas pruebas relacionadas, podés envolverlas en una suite. Una suite es un callback que recibe test y suite (y hooks) como parámetros, permitiendo una anidación limpia.

import { suite } from 'node:test';

suite('Array utils', (context) => {
  const { test } = context;

  test('push adds item', () => {
    const arr = [];
    arr.push(1);
    console.assert(arr[0] === 1);
  });

  test('map transforms', () => {
    const arr = [1, 2];
    const doubled = arr.map(x => x * 2);
    console.assert(doubled[0] === 2);
    console.assert(doubled[1] === 4);
  });
});

Resultado esperado:

·············· 2 passing (0.008s)

Las suites ayudan a mantener el código organizado y a evitar colisiones de nombres (por ejemplo, beforeEach dentro de una suite afecta solo a los tests dentro de esa suite).

Escribiendo tests asíncronos con async/await

El test runner soporta funciones async directamente. No es necesario envolver el callback en Promise.resolve().

import { test } from 'node:test';
import { promisify } from 'node:util';
import { readFile } from 'node:fs';

const readFileAsync = promisify(readFile);

test('lee un archivo asíncronamente', async () => {
  const data = await readFileAsync(new URL('./math.js', import.meta.url), 'utf8');
  assert.match(data, /export function add/);
});

Resultado esperado:

·············· 1 passing (0.009s)

También podés usar await dentro de una función test que sea un generador (usando function*). Esto es menos común, pero útil si ya tenés código legacy con generadores.

Mockeando dependencias con Mock

Una característica clave de node:test es el módulo Mock. Permite reemplazar exportaciones, espóviar funciones y comprobar llamadas.

import { test, Mock } from 'node:test';
import { add } from './math.js';

// Espórea la función add para que siempre retorne 99
const mockAdd = Mock.method(add, () => 99);

test('uso de add mockeado', () => {
  const result = add(1, 2); // ignora los args reales, retorna 99
  console.assert(result === 99);
});

mockAdd.restore(); // limpieza explícita (no estricta)

Resultado esperado:

·············· 1 passing (0.009s)

También podés mockear módulos enteros, útil cuando necesitás aislar tu código de dependencias externas (red, sistema de archivos).

import { test, Mock } from 'node:test';
import { something } from './external.js'; // módulo que querés mockear

Mock.module('./external.js', {
  namedExports: {
    something: () => 'mocked'
  }
});

test('external.mocked', async () => {
  const mod = await import('./external.js');
  console.assert(mod.something() === 'mocked');
});

Mock.restoreAll(); // limpia todos los mocks

Resultado esperado:

·············· 1 passing (0.009s)

Nota: Los mocks son especialmente útiles para escribir pruebas de integración que no querés ejecutar contra servicios reales (ej: APIs pagas) en tu flujo de desarrollo local.

Uso de node:test con coverage

Node ofrece una herramienta integrada de coverage llamada c8. Puedes ejecutar tu test runner con coverage para ver qué líneas de código están siendo ejercitadas.

npx c8 --reporter=html node --test math.test.js

Tras correr, podés abrir coverage/index.html para ver el reporte. Si ya tenés un script de package.json, agregá algo como:

{
  "scripts": {
    "test": "c8 --reporter=html node --test ./test/**/*.test.js"
  }
}

Esto alinea node:test con los pipelines CI típicos.

Integración con CI/CD

La API de node:test es tan simple que la mayoría de los servicios de CI (GitHub Actions, GitLab CI, CircleCI) tienen soporte nativo para ejecutar node --test. Ejemplo en GitHub Actions:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm install  # solo si tenés otras dependencias
      - run: npx c8 --reporter=text node --test

El flag --test indica a Node que busque automáticamente cualquier .test.js o .spec.js dentro del árbol de trabajo y los ejecute. También podés especificar paths:

node --test src/**/*.test.js

Patrones comunes y errores típicos

Situación Mejor práctica Por qué es importante
Test async sin await Envolvé el callback en async () => { … } o usá await dentro del callback. Evita falsos positivos: los tests asíncronos podrían finalizar antes de que la promesa se resuelva.
Mockear un módulo que luego importás en otro test Usá Mock.restoreAll() después de cada test, o usá un afterEach global. Evita contaminación entre tests.
Usar console.assert en lugar de assert de Node Prefirió assert.strictEqual, assert.throws, etc. console.assert se silencía en entornos sin console, y da menos detalle en fallos.
Escribir tests que leen archivos grandes Leé archivos una vez en un beforeAll (si existe) o en un setup compartido. Evita E/S repetida; mejora rendimiento.
Olvidar limpiar mocks Agregá un afterEach que llame a Mock.restoreAll() si estás usando mocks a nivel de módulo. Mantiene aislamiento entre tests.

Resumiendo lo aprendido

Ahora tenés un pipeline de testing completo usando únicamente Node.js:

  1. Escribí tests simples con test().
  2. Agregá hooks (beforeEach, afterEach) para setup/limpieza.
  3. Usá suites (suite()) para agrupar casos relacionados.
  4. Escribí tests asíncronos usando async/await directamente.
  5. Mockeá módulos y funciones con Mock para aislar dependencias.
  6. Corrí tests con node --test y generá coverage con c8.
  7. Integrá el pipeline en CI sin necesidad de frameworks externos.

Todos los ejemplos anteriores son ejecutables tal cual; copialos a un directorio, ejecútalos con node --test, y verás los resultados esperados.

Pasos siguientes

Si querés profundizar, explorá estos temas:

  • Reporterios personalizados: El test runner permite implementar reporterios propios, útiles para integrar con dashboards de monitoreo.
  • Test serial e paralelo: Usá parallel() para tests independientes que puedan correr simultáneamente y acelerar suites grandes.
  • Test con import.meta.url: Usá este meta dato para resolver rutas relativas de forma confiable dentro de tests.
  • Integración con ESLint/Prettier: Muchos equipos usan eslint-plugin-node/test para lintear sus archivos .test.js.
  • Migración desde Jest/Mocha: Si tenés un código legado que usa un framework, podés migrar gradualmente importando node:test en un nuevo repositorio; la transición suele ser incremental.

Con esta base, vas a poder crear un ciclo de testing sólido sin carga de dependencias, y mantener tu stack de desarrollo limpio y rápido.

Conclusión final

node:test es una herramienta subestimada pero poderosa, integrada en Node desde la versión 18. Al usarlo, podés eliminar la necesidad de instalar y mantener frameworks de testing externos, simplificar tus pipelines de CI, y escribir pruebas clearer y más mantenibles.

Experimentá hoy mismo con los ejemplos de este artículo, y verás lo rápido que podés empezar a escribir tests confiables. Después de dominar los conceptos básicos, podés añadir mocks, coverage y reporterios avanzados para llevar tu proceso de desarrollo al siguiente nivel.

¡Feliz testing! 🚀

Comentarios (0)

Sé el primero en comentar.

Dejá tu comentario