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 el método Delivery.fee. 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.
class Delivery { static double fee(double total, int kg, String city) { double fee = 4.50; if (!city.equals("Sofía")) { fee = fee + 2.00; } if (city.equals("Vidin")) { fee = fee + 3.00; } if (kg > 10) { fee = fee + (kg - 10) * 0.50; } 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 este método? 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 el método. Se lo preguntas. Un programa corto la llama con unos cuantos pedidos e imprime las respuestas:
System.out.println("20 €, 2 kg, Sofía → " + Delivery.fee(20, 2, "Sofía"));System.out.println("20 €, 2 kg, Plovdiv → " + Delivery.fee(20, 2, "Plovdiv"));System.out.println("20 €, 16 kg, Sofía → " + Delivery.fee(20, 16, "Sofía"));System.out.println("50 €, 2 kg, Plovdiv → " + Delivery.fee(50, 2, "Plovdiv"));System.out.println("60 €, 25 kg, Plovdiv → " + Delivery.fee(60, 25, "Plovdiv"));20 €, 2 kg, Sofía → 4.520 €, 2 kg, Plovdiv → 6.520 €, 16 kg, Sofía → 7.550 €, 2 kg, Plovdiv → 0.060 €, 25 kg, Plovdiv → 0.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 org.junit.jupiter.api.Test;import static org.junit.jupiter.api.Assertions.assertEquals;
class DeliveryTest { @Test void sofia() { assertEquals(4.5, Delivery.fee(20, 2, "Sofía")); }
@Test void outsideSofia() { assertEquals(6.5, Delivery.fee(20, 2, "Plovdiv")); }
@Test void heavy() { assertEquals(7.5, Delivery.fee(20, 16, "Sofía")); }
@Test void freeFrom50() { assertEquals(0.0, Delivery.fee(50, 2, "Plovdiv")); }
@Test void freeEvenWhenHeavy() { // Así es hoy: con envío gratis también desaparece el recargo por peso. assertEquals(0.0, Delivery.fee(60, 25, "Plovdiv")); }}OK freeEvenWhenHeavy()OK freeFrom50()OK heavy()OK outsideSofia()OK sofia()Pruebas: 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 pone la clase y la prueba en un solo archivo y ejecuta JUnit 5 mediante la API Launcher. 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ó Java. 4.5 y 0.0 son double, y los importes de aquí son múltiplos de 0,50 y se guardan con exactitud, por eso basta assertEquals sin tolerancia.
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:
class Delivery { static double fee(double total, int kg, String city) { if (total > 50) { return 0; } double fee = 4.50; if (!city.equals("Sofía")) { fee = fee + 2.00; } if (city.equals("Vidin")) { fee = fee + 3.00; } if (kg > 10) { fee = fee + (kg - 10) * 0.50; } return fee; }}FALLA freeFrom50(): expected: <0.0> but was: <6.5>OK freeEvenWhenHeavy()OK heavy()OK outsideSofia()OK sofia()Pruebas: 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.
class DeliveryTest { @Test void sofia() { assertEquals(4.5, Delivery.fee(20, 2, "Sofía")); }
@Test void outsideSofia() { assertEquals(6.5, Delivery.fee(20, 2, "Plovdiv")); }
@Test void heavy() { assertEquals(7.5, Delivery.fee(20, 16, "Sofía")); }
@Test void freeFrom50() { assertEquals(0.0, Delivery.fee(50, 2, "Plovdiv")); }
@Test void freeEvenWhenHeavy() { // Así es hoy: con envío gratis también desaparece el recargo por peso. assertEquals(0.0, Delivery.fee(60, 25, "Plovdiv")); }}Paso a paso, en palabras
- Cinco pruebas, una por cada línea del sondeo. Cada una llama a Delivery.fee y compara con la respuesta que da hoy.
- Delivery.fee(20, 2, "Sofía") devuelve 4.5. La prueba lo fija.
- Delivery.fee(20, 2, "Plovdiv") devuelve 6.5. La prueba lo fija.
- Delivery.fee(20, 16, "Sofía") devuelve 7.5. La prueba lo fija.
- Delivery.fee(50, 2, "Plovdiv") devuelve 0.0. La prueba lo fija.
- Delivery.fee(60, 25, "Plovdiv") devuelve 0.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.
- freeFrom50() falla: se esperaba 0.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:
class Delivery { static double fee(double total, int kg, String city) { if (total >= 50) { return 0; } double fee = 4.50; if (!city.equals("Sofía")) { fee = fee + 2.00; } if (city.equals("Vidin")) { fee = fee + 3.00; } if (kg > 10) { fee = fee + (kg - 10) * 0.50; } return fee; }}OK freeEvenWhenHeavy()OK freeFrom50()OK heavy()OK outsideSofia()OK sofia()Pruebas: 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.50;Las pruebas de la tarjeta «Anota lo que hace hoy» se ejecutan otra vez. ¿Qué pasa?
class Delivery { static double fee(double total, int kg, String city) { double fee = 4.50; if (!city.equals("Sofía")) { fee = fee + 2.00; } if (city.equals("Vidin")) { fee = fee + 3.00; } if (kg > 10) { fee = fee + kg * 0.50; } if (total >= 50) { fee = 0; } return fee; }}Pruébalo
Qué escribes como valor esperado
Para 60 €, 25 kg y Plovdiv el sondeo imprimió: 0.0. Estás seguro de que el envío correcto cuesta 14 €. ¿Qué escribes en la prueba antes del cambio?
@Test
void freeEvenWhenHeavy() {
assertEquals(, Delivery.fee(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»:
class DeliveryTest { @Test void sofia() { assertEquals(4.5, Delivery.fee(20, 2, "Sofía")); }
@Test void outsideSofia() { assertEquals(6.5, Delivery.fee(20, 2, "Plovdiv")); }
@Test void heavy() { assertEquals(7.5, Delivery.fee(20, 16, "Sofía")); }
@Test void freeFrom50() { System.out.println(Delivery.fee(50, 2, "Plovdiv")); }
@Test void freeEvenWhenHeavy() { // Así es hoy: con envío gratis también desaparece el recargo por peso. assertEquals(0.0, Delivery.fee(60, 25, "Plovdiv")); }}6.5OK freeEvenWhenHeavy()OK freeFrom50()OK heavy()OK outsideSofia()OK sofia()Pruebas: 5, pasaron: 5, fallaron: 0Se imprime 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. Pon una comprobación donde está la impresión: assertEquals(0.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:
class Delivery { static double fee(double total, int kg, String city) { double fee = 4.50; if (city.equals("Vidin")) { fee = fee + 3.00; } else if (!city.equals("Sofía")) { fee = fee + 2.00; } if (kg > 10) { fee = fee + (kg - 10) * 0.50; } if (total >= 50) { fee = 0; } return fee; }}OK freeEvenWhenHeavy()OK freeFrom50()OK heavy()OK outsideSofia()OK sofia()Pruebas: 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.
En IntelliJ IDEA ejecutas las pruebas con «Run with Coverage», y las líneas sin prueba se ponen en rojo. En un proyecto de Maven o Gradle, JaCoCo te da lo mismo.Hay algo, sin embargo, que el compilador protege incluso sin prueba. Si renombrasfee a mano y lo olvidas en la rama de Vidin, el programa no compila: fee ya no existe. El error se encuentra antes de que haya una prueba.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 freeEvenWhenHeavy()OK freeFrom50()OK heavy()OK outsideSofia()OK sofia()OK vidin()OK vidinFreeFrom50()Pruebas: 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(): expected: <9.5> but was: <7.5>OK freeEvenWhenHeavy()OK freeFrom50()OK heavy()OK outsideSofia()OK sofia()OK vidinFreeFrom50()Pruebas: 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 JUnit · 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