Ir al contenido
wannacode

Tests con node:test

En resumen: Un test unitario es código que comprueba un trozo pequeño, normalmente una función, con cada cambio; en Node.js se escribe con node:test y assert.equal.

Problema

El descuento que se rompió en silencio

La regla de la tienda es: el descuento va en porcentaje, y en una compra de 100 € o más se suma otro 5%. Escribes la función y la pruebas a mano:

main.js
// Descuento en porcentaje. Desde 100 € se suma otro 5%.
function applyDiscount(price, percent) {
if (price > 100) {
percent = percent + 5;
}
return price * (100 - percent) / 100;
}
console.log(applyDiscount(50, 10));
console.log(applyDiscount(200, 10));
Salida
45
170

50 € con un 10% son 45 €, y 200 € con un 15% son 170 €. Parece correcto y lo publicas. Una semana después, un cliente con una compra de exactamente 100 € paga 90 € en lugar de 85 €. La condición es price > 100, y debería ser price >= 100.

Nadie probó exactamente 100 €. Y con cada cambio en la función tienes que probar otra vez todos los casos a mano.

La idea

Código que comprueba código

En lugar de mirar la salida con tus propios ojos, escribes funciones pequeñas que llaman a tu código y comparan el resultado con el esperado. Eso es un test. Ejecutas todos los tests con un solo clic, después de cada cambio, y te dicen qué se ha roto.

Tipo Qué comprueba Ejemplo
Test unitario un trozo pequeño, normalmente una función, separado del resto applyDiscount(100, 10) devuelve 85
De integración cómo funcionan las piezas juntas: con una base de datos, un archivo, otro servicio el pedido se guarda en la base de datos y se vuelve a leer
De extremo a extremo (end-to-end) toda la aplicación, tal como la usa una persona el cliente añade al carrito, paga y recibe un correo
En esta lección escribimos tests unitarios. Node.js tiene para ellos el módulo integrado node:test, y las comprobaciones vienen de node:assert: no hay nada que instalar.

Por qué vale la pena:

  • el error se ve enseguida, mientras aún recuerdas qué cambiaste, y no cuando el cliente se queja;
  • el test muestra cómo se usa el código: qué caso devuelve qué;
  • puedes ordenar el código sin miedo. Si los tests siguen en verde después del cambio, el comportamiento no ha cambiado.
Más precisamente, para curiosos

Hay otros tipos de tests: funcionales (si el programa cumple los requisitos), de rendimiento (cuántas peticiones aguanta), de seguridad (si tiene agujeros) y de interfaz de usuario (los botones, los formularios). Todos se apoyan en los tests unitarios: son los más numerosos, los más rápidos y los más baratos de mantener.

Además de node:test, verás Jest y Vitest: bibliotecas aparte, sobre todo para código del navegador. Las ideas son las mismas: test, expect, beforeEach.

Míralo

El primer test

applyDiscount está en el archivo discount.js, con export delante de function, para que otro archivo pueda importarla. Sus tests están en un archivo aparte, discount.test.js:
discount.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { applyDiscount } from "./discount.js";
test("10% de 50 €", () => {
assert.equal(applyDiscount(50, 10), 45);
});
test("otro 5% desde 100 €", () => {
assert.equal(applyDiscount(100, 10), 85);
});
  • test(nombre, función) registra un test. El nombre es un texto que dice qué se comprueba.
  • assert.equal(real, esperado) compara los dos valores con ===. Si son distintos, el test falla. Lo importamos de node:assert/strict: ahí todas las comprobaciones son estrictas.

Ejecutamos los dos tests contra el código de la primera tarjeta:

Resultado
FALLA otro 5% desde 100 €: 90 !== 85
OK 10% de 50 €
Tests: 2, pasan: 1, fallan: 1

El primer test pasa. El segundo falla y dice exactamente qué ha pasado: esperaba 85, y la función ha devuelto 90. Corriges la condición a price >= 100, los ejecutas otra vez y los dos están en verde:

Resultado
OK 10% de 50 €
OK otro 5% desde 100 €
Tests: 2, pasan: 2, fallan: 0

Cada test hace tres cosas: prepara los valores, llama al código y comprueba el resultado.

test("10% de 50 €", () => {
assert.equal(applyDiscount(50, 10), 45);
});
test("otro 5% desde 100 €", () => {
assert.equal(applyDiscount(100, 10), 85);
});
Paso a paso, en palabras
  1. El archivo tiene dos tests. node:test ejecuta cada uno por separado y enciende una luz a su lado.
  2. Test 1, preparar: un precio de 50 € y un 10% de descuento.
  3. Actuar: applyDiscount(50, 10) devuelve 45.
  4. Comprobar: assert.equal compara el 45 esperado con el 45 devuelto. Son iguales, así que el test pasa.
  5. Test 2: exactamente 100 € y un 10%. Esperamos 85, porque desde 100 € hay otro 5%.
  6. La condición en la función es price > 100, y 100 no es mayor que 100. La función devuelve 90.
  7. Esperado 85, devuelto 90: el test falla y dice exactamente eso.
  8. Corriges la condición a price >= 100 y ejecutas los tests otra vez.
  9. Ahora 100 cumple la condición y la función devuelve 85.
  10. Esperado y devuelto coinciden. Los dos tests están en verde.
Más precisamente, para curiosos
  • En el proyecto, el código y los tests son archivos uno junto a otro. Los archivos de tests terminan en .test.js, para que node --test los encuentre:
Estructura
discount.js el código (con export)
discount.test.js sus tests
  • Se ejecutan desde la terminal. En la terminal, node --test imprime cada test con ✔ o ✖, cuánto ha tardado y los detalles de los que fallaron:
Comandos
node --test busca *.test.js y los ejecuta
node --test discount.test.js solo este archivo
node --test --watch los ejecuta de nuevo con cada cambio
  • Aquí, en el sitio, los archivos se escriben en una carpeta temporal y se ejecutan con run() de node:test. La función de abajo escucha los eventos test:pass y test:fail e imprime los resultados ordenados por nombre, para que la salida sea igual en cada ejecución:
El ejecutor
import { run } from "node:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
async function runTests(files) {
const dir = mkdtempSync(join(tmpdir(), "tests-"));
for (const [name, code] of Object.entries(files)) {
writeFileSync(join(dir, name), code);
}
const tests = Object.keys(files).filter((name) => name.endsWith(".test.js")).map((name) => join(dir, name));
const lines = [];
let failed = 0;
for await (const { type, data } of run({ files: tests })) {
if (data.details?.type !== "test") {
continue;
}
if (type === "test:pass") {
lines.push(`OK ${data.name}`);
} else if (type === "test:fail") {
failed++;
const error = data.details.error.cause ?? data.details.error;
const message = error.operator === "strictEqual" ? `${error.actual} !== ${error.expected}` : error.message;
lines.push(`FALLA ${data.name}: ${message}`);
}
}
lines.sort();
for (const line of lines) {
console.log(line);
}
console.log(`Tests: ${lines.length}, pasan: ${lines.length - failed}, fallan: ${failed}`);
}

Pruébalo

IVA con un número decimal

El precio con IVA es el precio por 1,2. Dos tests:

¿Qué mostrarán los tests?

export function withVat(price) {
return price * 1.2;
}
import { test } from "node:test";
import assert from "node:assert/strict";
import { withVat } from "./vat.js";
test("café", () => {
assert.equal(withVat(10), 12);
});
test("té", () => {
assert.equal(withVat(3), 3.6);
});
Respuesta

La idea

Sí o no, y una excepción esperada

Además de la igualdad, a menudo compruebas una condición, o que el código se niega. Aquí está Account del módulo «Errores y excepciones», con un método nuevo, canPay:

account.js
export class InsufficientFundsError extends Error {
constructor(shortfall) {
super(`Faltan ${shortfall} €`);
this.name = "InsufficientFundsError";
}
}
export class Account {
#balance;
constructor(balance) {
this.#balance = balance;
}
get balance() {
return this.#balance;
}
canPay(amount) {
return amount <= this.#balance;
}
withdraw(amount) {
if (amount <= 0) {
throw new RangeError("El importe debe ser positivo");
}
if (amount > this.#balance) {
throw new InsufficientFundsError(amount - this.#balance);
}
this.#balance -= amount;
}
}
account.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { Account } from "./account.js";
test("puede pagar lo que tiene", () => {
const account = new Account(100);
assert.ok(account.canPay(100));
assert.equal(account.canPay(101), false);
});
test("retirar más de lo que hay", () => {
const account = new Account(100);
assert.throws(() => account.withdraw(130), {
name: "InsufficientFundsError",
message: "Faltan 30 €",
});
assert.equal(account.balance, 100);
});
Salida
OK puede pagar lo que tiene
OK retirar más de lo que hay
Tests: 2, pasan: 2, fallan: 0
  • assert.ok(condición) falla si la condición no es verdadera. Para «no es verdadera» comparamos con false.
  • assert.throws(función, qué) falla si la función no lanza. El código se pasa como función flecha: assert.throws la ejecuta y captura el error. El segundo argumento describe el error esperado: aquí, su nombre y su mensaje.

El segundo test comprueba también algo que se olvida fácilmente: después de la negativa, el saldo es el mismo.

La idea

Cada test empieza de cero

Aquí los dos tests comparten una cuenta con 100 €:

account.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { Account } from "./account.js";
const account = new Account(100);
test("retirar 40 € deja 60 €", () => {
account.withdraw(40);
assert.equal(account.balance, 60);
});
test("retirar 70 € deja 30 €", () => {
account.withdraw(70);
assert.equal(account.balance, 30);
});
Salida
FALLA retirar 70 € deja 30 €: Faltan 10 €
OK retirar 40 € deja 60 €
Tests: 2, pasan: 1, fallan: 1

account se crea una sola vez para todo el archivo. node:test ejecuta los tests en el orden del archivo, así que el test de 40 € va primero. Al segundo le quedan 60 €, y retira 70 €. Basta con que alguien cambie de orden los tests o ejecute solo el segundo, y el resultado es otro. Un test que depende de otro es una trampa. Por eso la cuenta se crea de nuevo antes de cada test, en beforeEach:

account.test.js
import { test, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { Account } from "./account.js";
let account;
beforeEach(() => {
account = new Account(100);
});
test("retirar 40 € deja 60 €", () => {
account.withdraw(40);
assert.equal(account.balance, 60);
});
test("retirar 70 € deja 30 €", () => {
account.withdraw(70);
assert.equal(account.balance, 30);
});
Salida
OK retirar 40 € deja 60 €
OK retirar 70 € deja 30 €
Tests: 2, pasan: 2, fallan: 0
beforeEach de node:test se llama antes de cada test del archivo. El nombre del test es un texto, así que puede ser una frase entera.
Más precisamente, para curiosos
Más cosas de node:test que te vas a encontrar:
node:test
beforeEach / afterEach antes / después de cada test (limpieza)
before / after una vez antes / después de todos los tests
describe("...", () => { ... }) un grupo de tests relacionados
test.skip("...", () => { ... }) el test no se ejecuta por ahora
test.todo("...") un test que aún no está escrito
test("...", { timeout: 5000 }, () => { ... })
falla si tarda más de 5 segundos
node --test --test-name-pattern="retirar"
solo los tests con «retirar» en el nombre

La idea

Una comprobación, muchos casos

Cuatro casos para el descuento son cuatro tests casi iguales. Como test es una función normal, la llamas en un bucle: un test por cada caso «precio, porcentaje, esperado»:

discount.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { applyDiscount } from "./discount.js";
const cases = [
[50, 10, 45],
[99, 0, 99],
[100, 10, 85],
[200, 20, 150],
];
for (const [price, percent, expected] of cases) {
test(`${price} € con ${percent}% = ${expected} €`, () => {
assert.equal(applyDiscount(price, percent), expected);
});
}
Salida
OK 100 € con 10% = 85 €
OK 200 € con 20% = 150 €
OK 50 € con 10% = 45 €
OK 99 € con 0% = 99 €
Tests: 4, pasan: 4, fallan: 0

Cada caso es un test aparte con su propio resultado, y su nombre se arma con los valores mediante una plantilla de texto (template string). Un caso nuevo es una fila nueva en el array, no un test nuevo. Los casos importantes están en los bordes: exactamente 100 €, 99 €, 0%.

Pruébalo

Encuentra el error con el test

El envío cuesta 4 €, y desde 50 € es gratis. El test:

delivery.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { deliveryPrice } from "./delivery.js";
for (const [total, expected] of [[20, 4], [50, 0], [80, 0]]) {
test(`pedido de ${total} €: envío ${expected} €`, () => {
assert.equal(deliveryPrice(total), expected);
});
}
Salida
FALLA pedido de 50 €: envío 0 €: 4 !== 0
OK pedido de 20 €: envío 4 €
OK pedido de 80 €: envío 0 €
Tests: 3, pasan: 2, fallan: 1

¿En qué línea del código está el error?

Elige la línea con el error.

La idea

Primero el test

La caja tiene que aceptar el código promocional OTOÑO10 para un 10%, da igual si está en minúsculas o en mayúsculas. Esta vez escribes primero los tests:

promo.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { promoPercent } from "./promo.js";
test("código conocido", () => {
assert.equal(promoPercent("OTOÑO10"), 10);
});
test("el código no distingue mayúsculas y minúsculas", () => {
assert.equal(promoPercent("otoño10"), 10);
});
test("código desconocido", () => {
assert.equal(promoPercent("NINGUNO"), 0);
});
  1. Rojo. Por ahora la función devuelve 0. Los tests fallan, y eso está bien: significa que de verdad comprueban algo.
export function promoPercent(code) {
return 0;
}
Salida
FALLA código conocido: 0 !== 10
FALLA el código no distingue mayúsculas y minúsculas: 0 !== 10
OK código desconocido
Tests: 3, pasan: 1, fallan: 2
  1. Verde. Escribes el código más simple con el que pasan:
export function promoPercent(code) {
if (code.toUpperCase() === "OTOÑO10") {
return 10;
}
return 0;
}
Salida
OK código conocido
OK código desconocido
OK el código no distingue mayúsculas y minúsculas
Tests: 3, pasan: 3, fallan: 0
  1. Ordenar. Ahora puedes mover los códigos a un Map, añadir otros nuevos y ordenar la función. Los tests te avisarán si rompes algo.

Esto es TDD (test-driven development, desarrollo guiado por tests). Escribir primero el test te obliga a decidir qué tiene que hacer exactamente la función, antes de pensar en cómo.

Más precisamente

Suites y objetos falsos

En node:test, un grupo de tests relacionados es describe: los tests de dentro se muestran juntos, bajo el nombre del grupo. Los tests del envío, reunidos en un grupo:

delivery.test.js
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { deliveryPrice } from "./delivery.js";
describe("envío", () => {
for (const [total, expected] of [[20, 4], [50, 0], [80, 0]]) {
test(`pedido de ${total} €: envío ${expected} €`, () => {
assert.equal(deliveryPrice(total), expected);
});
}
});

Una suite de varios archivos es solo un comando que los ejecuta juntos. Aquí están los tres archivos de la caja:

node --test discount.test.js account.test.js delivery.test.js
Salida
OK 10% de 50 €
OK otro 5% desde 100 €
OK pedido de 20 €: envío 4 €
OK pedido de 50 €: envío 0 €
OK pedido de 80 €: envío 0 €
OK retirar 40 € deja 60 €
OK retirar 70 € deja 30 €
Tests: 7, pasan: 7, fallan: 0
Más precisamente, para curiosos
  • A veces el código depende de algo que no quieres llamar en un test: el tipo de cambio de un banco, un pago, un correo. Entonces le pasas un objeto falso (mock) que devuelve lo que tú le digas. node:test tiene mock.fn(): una función que devuelve lo que le digas y recuerda cómo se la llamó:
converter.test.js
import { test, mock } from "node:test";
import assert from "node:assert/strict";
class Converter {
constructor(rates) {
this.rates = rates;
}
toUsd(eur) {
return eur * this.rates.eurTo("USD");
}
}
test("convierte al cambio de hoy", () => {
const rates = { eurTo: mock.fn(() => 1.1) };
const converter = new Converter(rates);
assert.ok(Math.abs(converter.toUsd(10) - 11) < 0.001);
assert.equal(rates.eurTo.mock.callCount(), 1);
assert.deepEqual(rates.eurTo.mock.calls[0].arguments, ["USD"]);
});
Salida
OK convierte al cambio de hoy
Tests: 1, pasan: 1, fallan: 0

El objeto falso es un objeto normal con un método eurTo. mock.callCount() dice cuántas veces se llamó, y mock.calls[0].arguments, con qué.

  • Más comprobaciones además de assert.equal, assert.ok y assert.throws:
Comprobaciones
assert.notEqual(a, b) si son distintos
assert.deepEqual(a, b) arrays y objetos con el mismo contenido
assert.notDeepEqual(a, b)
assert.ok(x) la condición es verdadera
assert.match(text, /patrón/) el texto coincide con una expresión regular
assert.throws(() => ...) el código lanza una excepción
assert.rejects(async () => ...) la promesa termina con un error
assert.fail("mensaje") el test falla al instante

Ejercicio

Escribe los tests

Resuelve los ejercicios en tu editor, con node:test (viene con el propio Node).

1. La calculadora de descuentos. Escribe discount.test.js para applyDiscount de la primera tarjeta. Cubre estos casos: 80 € con un 10% son 72 €, 100 € con un 0% son 95 €, 150 € con un 20% son 112,50 €, 0 € con un 30% son 0 € y exactamente 100 € con un 10% son 85 €. Ejecútalos también contra la versión rota con price > 100. Tienen que fallar exactamente los dos casos con 100 €:

Resultado contra la versión rota
FALLA 100 € con 0% = 95 €: 100 !== 95
FALLA exactamente 100 € recibe el 5% extra: 90 !== 85
OK 0 € con 30% = 0 €
OK 150 € con 20% = 112.5 €
OK 80 € con 10% = 72 €
Tests: 5, pasan: 3, fallan: 2

2. La cuenta. Escribe account.test.js para Account de la tarjeta «Sí o no, y una excepción esperada». Crea la cuenta con 100 € en beforeEach. Comprueba que:

  • retirar 30 € deja 70 €;
  • se puede retirar todo, y queda 0;
  • retirar 150 € lanza InsufficientFundsError con el mensaje «Faltan 50 €», y el saldo sigue en 100 €;
  • 0 y -20 € lanzan RangeError (tests en un bucle sobre los dos importes).

3. El error en la nota media. La función parece estar bien:

grades.js
export function average(grades) {
let total = 0;
for (const grade of grades) {
total += grade;
}
return Math.trunc(total / grades.length);
}
Escribe dos tests: la media de 4 y 6 es 5, y la media de 5 y 6 es 5,5. Aquí basta con assert.equal: 5,5 se guarda en binario de forma exacta. Uno de ellos falla:
Resultado
FALLA media con decimales: 5 !== 5.5
OK media entera
Tests: 2, pasan: 1, fallan: 1

Encuentra la causa y corrige la función, para que pasen los dos.

Test

Ponte a prueba

1. ¿Qué comprueba un test unitario?
2. assert.equal(applyDiscount(100, 10), 85) falla con «90 !== 85». ¿Qué devolvió la función?
3. ¿Por qué la cuenta se crea en beforeEach y no una sola vez arriba del todo en el archivo?
4. ¿Cómo compruebas que withdraw(130) lanza InsufficientFundsError?
5. ¿Cuál es el orden en TDD?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·