Las pruebas como red de seguridad
En resumen: Las pruebas de caracterización fijan lo que hace hoy el código viejo y atrapan cualquier cambio accidental de comportamiento mientras lo limpias.
Problema
El cambio inofensivo
La tienda calcula el envío con la función deliveryFee. Se escribió hace tres años y no tiene pruebas. El envío cuesta 4,50 €, fuera de Sofía 2 € más, a Vidin otros 3 €, y por encima de 10 kg pagas 0,50 € por cada kilo de más. Desde 50 € es gratis.
export function deliveryFee(total, kg, city) { let fee = 4.5; if (city !== "Sofía") { fee = fee + 2; } if (city === "Vidin") { fee = fee + 3; } if (kg > 10) { fee = fee + (kg - 10) * 0.5; } if (total >= 50) { fee = 0; } return fee;}El mes pasado un compañero «puso orden»: si el pedido es gratis, ¿para qué calcular nada? Así que movió la comprobación al principio. Escribió total > 50 en lugar de total >= 50. Durante una semana entera, los pedidos de exactamente 50 € pagaron envío. Nadie lo notó hasta que llegaron las quejas.
Ahora tienes que añadir una ciudad nueva. ¿Tocas esta función? Si no, cada regla nueva se pega encima y el código da cada vez más miedo. Si lo haces, quizá rompas algo que funciona y nunca te enteres.
La idea
Una red bajo la cuerda
Un equilibrista camina tranquilo porque debajo hay una red. Si cae, la red lo atrapa antes de que pase nada malo.
En el código, la red son las pruebas. Antes de cambiar cómo está escrito el código, escribes pruebas que dicen qué hace hoy. Luego lo cambias y ejecutas las pruebas. Verde: el comportamiento es el mismo. Rojo: algo ha cambiado, y sabes exactamente qué.
Estas pruebas se llaman pruebas de caracterización: no comprueban si el código es correcto, describen cómo se comporta. Si hoy el código hace algo raro, la prueba también registra lo raro. Esta es la red de seguridad: no se toca ningún código hasta que haya pruebas debajo.
Míralo
Anota lo que hace hoy
No adivinas qué debería devolver la función. Se lo preguntas. Un programa corto la llama con unos cuantos pedidos e imprime las respuestas:
console.log(`20 €, 2 kg, Sofía → ${deliveryFee(20, 2, "Sofía")}`);console.log(`20 €, 2 kg, Plovdiv → ${deliveryFee(20, 2, "Plovdiv")}`);console.log(`20 €, 16 kg, Sofía → ${deliveryFee(20, 16, "Sofía")}`);console.log(`50 €, 2 kg, Plovdiv → ${deliveryFee(50, 2, "Plovdiv")}`);console.log(`60 €, 25 kg, Plovdiv → ${deliveryFee(60, 25, "Plovdiv")}`);20 €, 2 kg, Sofía → 4.520 €, 2 kg, Plovdiv → 6.520 €, 16 kg, Sofía → 7.550 €, 2 kg, Plovdiv → 060 €, 25 kg, Plovdiv → 0La última línea es una sorpresa. El paquete pesa 25 kg y el envío es gratis: el envío gratis también elimina el recargo por peso. ¿Es un error? Quizá. Pero así funciona hoy, los clientes cuentan con ello y decidir si se cambia no te toca a ti mientras limpias el código. Lo anotas tal como es.
Cada línea del sondeo se convierte en una prueba:
import { test } from "node:test";import assert from "node:assert/strict";import { deliveryFee } from "./delivery.js";
test("Sofía", () => { assert.equal(deliveryFee(20, 2, "Sofía"), 4.5);});
test("fuera de Sofía", () => { assert.equal(deliveryFee(20, 2, "Plovdiv"), 6.5);});
test("más de 10 kg", () => { assert.equal(deliveryFee(20, 16, "Sofía"), 7.5);});
test("gratis desde 50 €", () => { assert.equal(deliveryFee(50, 2, "Plovdiv"), 0);});
test("gratis incluso con 25 kg (así es hoy)", () => { // Así es hoy: con envío gratis también desaparece el recargo por peso. assert.equal(deliveryFee(60, 25, "Plovdiv"), 0);});OK SofíaOK fuera de SofíaOK gratis desde 50 €OK gratis incluso con 25 kg (así es hoy)OK más de 10 kgPruebas: 5, pasaron: 5, fallaron: 0Cinco pruebas, cinco en verde. La red está tendida.
Más precisamente, para curiosos
Las pruebas aquí se ejecutan igual que en la lección de pruebas del módulo «Programas en el mundo real». El programa comprobado escribe delivery.js y delivery.test.js en una carpeta temporal y los ejecuta con run() de node:test. Los resultados se ordenan por nombre. Así la salida es la misma en cada ejecución.
Los valores de la prueba son lo que imprimió JavaScript. JavaScript tiene un solo tipo de número (number), y los importes de aquí son múltiplos de 0,50 y se guardan con exactitud, por eso basta assert.equal.
Míralo
El cambio inofensivo bajo la red
Ahora el compañero hace el mismo cambio, pero bajo una red. La comprobación sube al principio:
export function deliveryFee(total, kg, city) { if (total > 50) { return 0; } let fee = 4.5; if (city !== "Sofía") { fee = fee + 2; } if (city === "Vidin") { fee = fee + 3; } if (kg > 10) { fee = fee + (kg - 10) * 0.5; } return fee;}FALLA gratis desde 50 €: 6.5 !== 0OK SofíaOK fuera de SofíaOK gratis incluso con 25 kg (así es hoy)OK más de 10 kgPruebas: 5, pasaron: 4, fallaron: 1Una prueba está en rojo y dice exactamente qué caso: un pedido de 50 € fuera de Sofía. Se esperaba 0 y es 6,50 €. El error se atrapa en el escritorio, no por un cliente.
import { test } from "node:test";import assert from "node:assert/strict";import { deliveryFee } from "./delivery.js";
test("Sofía", () => { assert.equal(deliveryFee(20, 2, "Sofía"), 4.5);});
test("fuera de Sofía", () => { assert.equal(deliveryFee(20, 2, "Plovdiv"), 6.5);});
test("más de 10 kg", () => { assert.equal(deliveryFee(20, 16, "Sofía"), 7.5);});
test("gratis desde 50 €", () => { assert.equal(deliveryFee(50, 2, "Plovdiv"), 0);});
test("gratis incluso con 25 kg (así es hoy)", () => { // Así es hoy: con envío gratis también desaparece el recargo por peso. assert.equal(deliveryFee(60, 25, "Plovdiv"), 0);});Paso a paso, en palabras
- Cinco pruebas, una por cada línea del sondeo. Cada una llama a deliveryFee y compara con la respuesta que da hoy.
- deliveryFee(20, 2, "Sofía") devuelve 4.5. La prueba lo fija.
- deliveryFee(20, 2, "Plovdiv") devuelve 6.5. La prueba lo fija.
- deliveryFee(20, 16, "Sofía") devuelve 7.5. La prueba lo fija.
- deliveryFee(50, 2, "Plovdiv") devuelve 0. La prueba lo fija.
- deliveryFee(60, 25, "Plovdiv") devuelve 0, aunque el paquete pesa 25 kg. Raro, pero hoy es así: la prueba lo conserva, no lo corrige.
- Un compañero mueve la comprobación del envío gratis al principio y escribe > en lugar de >=. Se ejecuta toda la red otra vez.
- «gratis desde 50 €» falla: se esperaba 0, pero es 6.5. Un pedido de exactamente 50 € ya paga el envío.
- Con >= las cinco pruebas están en verde. El cambio es seguro: la red lo ha comprobado.
Con >= todo está en verde:
export function deliveryFee(total, kg, city) { if (total >= 50) { return 0; } let fee = 4.5; if (city !== "Sofía") { fee = fee + 2; } if (city === "Vidin") { fee = fee + 3; } if (kg > 10) { fee = fee + (kg - 10) * 0.5; } return fee;}OK SofíaOK fuera de SofíaOK gratis desde 50 €OK gratis incluso con 25 kg (así es hoy)OK más de 10 kgPruebas: 5, pasaron: 5, fallaron: 0El cambio es el mismo que pasó una semana entera sin que nadie lo notara. La única diferencia es la red.
Pruébalo
Predice la salida
Alguien cambia la línea del recargo por peso y olvida restar los primeros 10 kg:
fee = fee + kg * 0.5;Las pruebas de la tarjeta «Anota lo que hace hoy» se ejecutan otra vez. ¿Qué pasa?
export function deliveryFee(total, kg, city) { let fee = 4.5; if (city !== "Sofía") { fee = fee + 2; } if (city === "Vidin") { fee = fee + 3; } if (kg > 10) { fee = fee + kg * 0.5; } if (total >= 50) { fee = 0; } return fee;}Pruébalo
Qué escribes como valor esperado
Para 60 €, 25 kg y Plovdiv el sondeo imprimió: 0. Estás seguro de que el envío correcto cuesta 14 €. ¿Qué escribes en la prueba antes del cambio?
test("gratis incluso con 25 kg (así es hoy)", () => {
assert.equal(deliveryFee(60, 25, "Plovdiv"), );
});Escribes lo que la función devuelve hoy. La prueba protege el comportamiento mientras cambias la forma. Si el envío de verdad debe costar 14 €, eso es un cambio aparte: preguntas a la tienda, cambias la regla y solo entonces cambias la prueba, a propósito y en un commit separado.
La idea
Pasos pequeños
La red ayuda más cuando caes desde poca altura. Si cambias veinte cosas a la vez y una prueba falla, no sabes cuál de las veinte tiene la culpa. Si cambias una cosa y la prueba falla, el culpable es evidente.
Así que el trabajo tiene un ritmo: un cambio pequeño, ejecutar las pruebas, verde, el siguiente. Rojo significa: deshacer el último cambio (Ctrl+Z, o git checkout sobre el archivo) y hacerlo en trozos aún más pequeños. De esa manera el código está casi siempre en un estado que funciona.
Pon los pasos en orden:
Verde: te lo quedas (commit). Rojo: deshaces solo el último cambioAñadir pruebas para el camino que vas a tocarVolver a ejecutar las pruebasEjecutar las pruebas: todas están en verdeHacer un cambio pequeño
Pruébalo
Encuentra el error
Alguien escribió así la prueba de 50 €. Luego el compañero hace el cambio con total > 50 de la tarjeta «El cambio inofensivo bajo la red»:
import { test } from "node:test";import assert from "node:assert/strict";import { deliveryFee } from "./delivery.js";
test("Sofía", () => { assert.equal(deliveryFee(20, 2, "Sofía"), 4.5);});
test("fuera de Sofía", () => { assert.equal(deliveryFee(20, 2, "Plovdiv"), 6.5);});
test("más de 10 kg", () => { assert.equal(deliveryFee(20, 16, "Sofía"), 7.5);});
test("gratis desde 50 €", () => { console.log(deliveryFee(50, 2, "Plovdiv"));});
test("gratis incluso con 25 kg (así es hoy)", () => { // Así es hoy: con envío gratis también desaparece el recargo por peso. assert.equal(deliveryFee(60, 25, "Plovdiv"), 0);});OK SofíaOK fuera de SofíaOK gratis desde 50 €OK gratis incluso con 25 kg (así es hoy)OK más de 10 kgPruebas: 5, pasaron: 5, fallaron: 0Se devuelve el importe equivocado y las pruebas están en verde. ¿En qué línea está el error?
Elige la línea con el error.
La prueba imprime el resultado, pero no lo compara con nada. Una prueba falla solo cuando no se cumple una comprobación que hay en ella, así que esta está en verde con cualquier resultado. En JavaScript el texto impreso ni siquiera aparece: node:test ejecuta el archivo en un proceso aparte y no reenvía su salida. Pon una comprobación donde está la impresión: assert.equal(..., 0).
La idea
Lo que la red no atrapa
La red atrapa solo donde está tendida. No hay ninguna prueba para Vidin. Alguien «ordena» las ciudades con else if:
export function deliveryFee(total, kg, city) { let fee = 4.5; if (city === "Vidin") { fee = fee + 3; } else if (city !== "Sofía") { fee = fee + 2; } if (kg > 10) { fee = fee + (kg - 10) * 0.5; } if (total >= 50) { fee = 0; } return fee;}OK SofíaOK fuera de SofíaOK gratis desde 50 €OK gratis incluso con 25 kg (así es hoy)OK más de 10 kgPruebas: 5, pasaron: 5, fallaron: 0Vidin antes: 9.5Vidin después: 7.5Las pruebas están en verde y el envío a Vidin se volvió 2 € más barato: antes Vidin pagaba los 2 € de «fuera de Sofía» y además los 3 €. Ninguna prueba pasa por este camino, así que ninguna falla.
Antes de tocar código, mira qué caminos tienen pruebas y añade algunas para los que vas a tocar. Eso es lo que muestra la cobertura: qué líneas se ejecutaron mientras corrían las pruebas.
Node la trae integrada:node --test --experimental-test-coverage imprime, para cada archivo, qué líneas no se ejecutaron.En JavaScript hay algo más: no existe un compilador que lea todo el archivo. Alguien renombra fee a price a mano y lo olvida en la rama de Vidin:
export function deliveryFee(total, kg, city) { let price = 4.5; if (city !== "Sofía") { price = price + 2; } if (city === "Vidin") { price = fee + 3; } if (kg > 10) { price = price + (kg - 10) * 0.5; } if (total >= 50) { price = 0; } return price;}Las cinco pruebas siguen en verde: ninguna llega a esta línea. El error aparece solo cuando alguien hace un pedido a Vidin:
try { deliveryFee(20, 2, "Vidin");} catch (error) { console.log(`Vidin: ${error.message}`);}Vidin: fee is not definedPor eso las pruebas protegen aquí aún más que en Java y C#.
Más precisamente, para curiosos
Un 100 % de cobertura no significa que cada caso esté comprobado. La línea con total >= 50 también queda «cubierta» por una prueba con 60 €, pero exactamente 50 € es un caso aparte, y solo la prueba de 50 € atrapa >. La cobertura te dice dónde la red tiene agujeros. Lo densa que sea donde existe depende de los casos que elijas: los límites (50 €, 10 kg), cada rama de un if y los valores raros del sondeo.
Los comandos de cobertura no se ejecutan aquí: las comprobaciones del sitio corren sin red y sin paquetes externos.
Ejercicio
Una red para Vidin
Resuelve la tarea en tu editor.
Una red para Vidin. Añade dos pruebas a las de la tarjeta «Anota lo que hace hoy»: un pedido de 20 €, 2 kg a Vidin y un pedido de 50 €, 2 kg a Vidin. Ejecuta primero el sondeo para ver qué devuelve hoy el código, y fíjalo.
OK SofíaOK VidinOK Vidin gratis desde 50 €OK fuera de SofíaOK gratis desde 50 €OK gratis incluso con 25 kg (así es hoy)OK más de 10 kgPruebas: 7, pasaron: 7, fallaron: 0Luego haz el cambio con else if de la tarjeta «Lo que la red no atrapa». Ahora tiene que fallar:
FALLA Vidin: 7.5 !== 9.5OK SofíaOK Vidin gratis desde 50 €OK fuera de SofíaOK gratis desde 50 €OK gratis incluso con 25 kg (así es hoy)OK más de 10 kgPruebas: 7, pasaron: 6, fallaron: 1Más tareas
- El límite de peso. Añade una prueba para exactamente 10 kg y otra para 11 kg. Luego cambia
kg > 10porkg >= 10. ¿Falla algo? ¿Por qué? - La ciudad nueva. Añade Varna con 1 € de recargo. Primero una prueba para Varna que falle, luego la regla y luego todas las pruebas otra vez.
- Un sondeo para todo. Escribe el sondeo como un bucle sobre una tabla de casos (importe, peso, ciudad), de modo que un caso nuevo sea una fila nueva en la tabla.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Tests con node:test · Nombres en el código · Funciones: una tarea, pocos parámetros, sin efectos secundarios ocultos · Code smells: método largo, clase grande, duplicación, primitivos en vez de tipos, cadenas largas de if
- Continúa en: Refactorización en pasos pequeños · Ejercicio: una clase desordenada, limpiada paso a paso · Dependencias: el código ajeno que ejecuta el tuyo
- Ejercicios sobre el tema: Código limpio y refactorización