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:
// 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));4517050 € 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 |
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: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 denode:assert/strict: ahí todas las comprobaciones son estrictas.
Ejecutamos los dos tests contra el código de la primera tarjeta:
FALLA otro 5% desde 100 €: 90 !== 85OK 10% de 50 €Tests: 2, pasan: 1, fallan: 1El 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:
OK 10% de 50 €OK otro 5% desde 100 €Tests: 2, pasan: 2, fallan: 0Cada 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
- El archivo tiene dos tests. node:test ejecuta cada uno por separado y enciende una luz a su lado.
- Test 1, preparar: un precio de 50 € y un 10% de descuento.
- Actuar: applyDiscount(50, 10) devuelve 45.
- Comprobar: assert.equal compara el 45 esperado con el 45 devuelto. Son iguales, así que el test pasa.
- Test 2: exactamente 100 € y un 10%. Esperamos 85, porque desde 100 € hay otro 5%.
- La condición en la función es price > 100, y 100 no es mayor que 100. La función devuelve 90.
- Esperado 85, devuelto 90: el test falla y dice exactamente eso.
- Corriges la condición a price >= 100 y ejecutas los tests otra vez.
- Ahora 100 cumple la condición y la función devuelve 85.
- 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 quenode --testlos encuentre:
discount.js el código (con export)discount.test.js sus tests- Se ejecutan desde la terminal. En la terminal,
node --testimprime cada test con ✔ o ✖, cuánto ha tardado y los detalles de los que fallaron:
node --test busca *.test.js y los ejecutanode --test discount.test.js solo este archivonode --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()denode:test. La función de abajo escucha los eventostest:passytest:faile imprime los resultados ordenados por nombre, para que la salida sea igual en cada ejecución:
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);});3 por 1,2 en un number no es exactamente 3,6. Los números decimales se guardan en binario, y en las cuentas se pierde una parte diminuta. assert.equal compara de forma exacta, así que el test falla:
FALLA té: 3.5999999999999996 !== 3.6OK caféTests: 2, pasan: 1, fallan: 1node:assert no tiene una comprobación con diferencia permitida, así que la escribes tú: assert.ok(Math.abs(withVat(3) - 3.6) < 0.001) pasa, porque la diferencia es menor que 0,001. assert.ok falla si la condición no es verdadera. Y 12 !== 12.0 no fallaría: en JavaScript 12 y 12.0 son el mismo número.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:
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; }}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);});OK puede pagar lo que tieneOK retirar más de lo que hayTests: 2, pasan: 2, fallan: 0assert.ok(condición)falla si la condición no es verdadera. Para «no es verdadera» comparamos confalse.assert.throws(función, qué)falla si la función no lanza. El código se pasa como función flecha:assert.throwsla 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 €:
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);});FALLA retirar 70 € deja 30 €: Faltan 10 €OK retirar 40 € deja 60 €Tests: 2, pasan: 1, fallan: 1account 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:
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);});OK retirar 40 € deja 60 €OK retirar 70 € deja 30 €Tests: 2, pasan: 2, fallan: 0beforeEach 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
beforeEach / afterEach antes / después de cada test (limpieza)before / after una vez antes / después de todos los testsdescribe("...", () => { ... }) un grupo de tests relacionadostest.skip("...", () => { ... }) el test no se ejecuta por ahoratest.todo("...") un test que aún no está escritotest("...", { timeout: 5000 }, () => { ... }) falla si tarda más de 5 segundosnode --test --test-name-pattern="retirar" solo los tests con «retirar» en el nombreLa 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»:
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); });}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: 0Cada 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:
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); });}FALLA pedido de 50 €: envío 0 €: 4 !== 0OK 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.
El test que falla dice: en un pedido de exactamente 50 €, el envío es de 4 €, y debería ser 0. Es decir, 50 no cumple la condición. total > 50 deja fuera el propio 50. Debería ser total >= 50. El test señala el caso, y el código muestra la causa.
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:
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);});- 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;}FALLA código conocido: 0 !== 10FALLA el código no distingue mayúsculas y minúsculas: 0 !== 10OK código desconocidoTests: 3, pasan: 1, fallan: 2- 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;}OK código conocidoOK código desconocidoOK el código no distingue mayúsculas y minúsculasTests: 3, pasan: 3, fallan: 0- 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:
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.jsOK 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: 0Má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ó:
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"]);});OK convierte al cambio de hoyTests: 1, pasan: 1, fallan: 0El 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:
assert.notEqual(a, b) si son distintosassert.deepEqual(a, b) arrays y objetos con el mismo contenidoassert.notDeepEqual(a, b)assert.ok(x) la condición es verdaderaassert.match(text, /patrón/) el texto coincide con una expresión regularassert.throws(() => ...) el código lanza una excepciónassert.rejects(async () => ...) la promesa termina con un errorassert.fail("mensaje") el test falla al instanteEjercicio
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 €:
FALLA 100 € con 0% = 95 €: 100 !== 95FALLA exactamente 100 € recibe el 5% extra: 90 !== 85OK 0 € con 30% = 0 €OK 150 € con 20% = 112.5 €OK 80 € con 10% = 72 €Tests: 5, pasan: 3, fallan: 22. 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
InsufficientFundsErrorcon 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:
export function average(grades) { let total = 0; for (const grade of grades) { total += grade; } return Math.trunc(total / grades.length);}assert.equal: 5,5 se guarda en binario de forma exacta. Uno de ellos falla:FALLA media con decimales: 5 !== 5.5OK media enteraTests: 2, pasan: 1, fallan: 1Encuentra la causa y corrige la función, para que pasen los dos.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Map y Set · Clases anidadas y datos · Funciones flecha · Métodos de arrays
- Continúa en: Async, await e hilos · Reflexión · Las pruebas como red de seguridad · Refactorización en pasos pequeños
- Ejercicios sobre el tema: Programas en el mundo real