Ir al contenido
wannacode

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"));
Salida
20 €, 2 kg, Sofía → 4.5
20 €, 2 kg, Plovdiv → 6.5
20 €, 16 kg, Sofía → 7.5
50 €, 2 kg, Plovdiv → 0.0
60 €, 25 kg, Plovdiv → 0.0

La ú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"));
}
}
Resultado
OK freeEvenWhenHeavy()
OK freeFrom50()
OK heavy()
OK outsideSofia()
OK sofia()
Pruebas: 5, pasaron: 5, fallaron: 0

Cinco 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;
}
}
Resultado
FALLA freeFrom50(): expected: <0.0> but was: <6.5>
OK freeEvenWhenHeavy()
OK heavy()
OK outsideSofia()
OK sofia()
Pruebas: 5, pasaron: 4, fallaron: 1

Una 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
  1. Cinco pruebas, una por cada línea del sondeo. Cada una llama a Delivery.fee y compara con la respuesta que da hoy.
  2. Delivery.fee(20, 2, "Sofía") devuelve 4.5. La prueba lo fija.
  3. Delivery.fee(20, 2, "Plovdiv") devuelve 6.5. La prueba lo fija.
  4. Delivery.fee(20, 16, "Sofía") devuelve 7.5. La prueba lo fija.
  5. Delivery.fee(50, 2, "Plovdiv") devuelve 0.0. La prueba lo fija.
  6. 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.
  7. 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.
  8. freeFrom50() falla: se esperaba 0.0, pero es 6.5. Un pedido de exactamente 50 € ya paga el envío.
  9. 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;
}
}
Resultado
OK freeEvenWhenHeavy()
OK freeFrom50()
OK heavy()
OK outsideSofia()
OK sofia()
Pruebas: 5, pasaron: 5, fallaron: 0

El 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;
}
}
Respuesta

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"));
}

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:

  1. Verde: te lo quedas (commit). Rojo: deshaces solo el último cambio
  2. Añadir pruebas para el camino que vas a tocar
  3. Volver a ejecutar las pruebas
  4. Ejecutar las pruebas: todas están en verde
  5. Hacer 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"));
}
}
Resultado
6.5
OK freeEvenWhenHeavy()
OK freeFrom50()
OK heavy()
OK outsideSofia()
OK sofia()
Pruebas: 5, pasaron: 5, fallaron: 0

Se 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 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;
}
}
Las pruebas
OK freeEvenWhenHeavy()
OK freeFrom50()
OK heavy()
OK outsideSofia()
OK sofia()
Pruebas: 5, pasaron: 5, fallaron: 0
Vidin antes y después
Vidin antes: 9.5
Vidin después: 7.5

Las 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 renombras fee 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.

Resultado
OK freeEvenWhenHeavy()
OK freeFrom50()
OK heavy()
OK outsideSofia()
OK sofia()
OK vidin()
OK vidinFreeFrom50()
Pruebas: 7, pasaron: 7, fallaron: 0

Luego haz el cambio con else if de la tarjeta «Lo que la red no atrapa». Ahora tiene que fallar:

Resultado
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: 1

Más tareas

  • El límite de peso. Añade una prueba para exactamente 10 kg y otra para 11 kg. Luego cambia kg > 10 por kg >= 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

1. El sondeo muestra que un pedido de 60 € con un paquete de 25 kg se entrega gratis. Crees que es un error. ¿Qué escribes en la prueba antes del cambio?
2. Cinco pruebas pasan. Reorganizas la función y una falla. ¿Qué haces?
3. ¿Por qué los pasos son pequeños, con las pruebas ejecutadas después de cada uno?
4. Todas las pruebas están en verde. ¿Significa que el cambio no cambió nada?
5. Una prueba llama a la función e imprime el resultado sin compararlo. ¿Qué protege?

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