Caza mutantes: pruebas que detectan cada error
Escribe las pruebas de una tarifa de cine y mide su calidad con pruebas de mutación, como PIT: se ejecutan contra siete copias del código con un error pequeño cada una, y un mutante solo muere si alguna prueba lo distingue. Valores límite, clases de equivalencia y aserciones.
- Pruebas de mutación
- Valores límite
- Clases de equivalencia
- Aserciones (como en JUnit)
- Probar excepciones
- Calidad de una batería de pruebas
Enunciado
¿Cómo se sabe si unas pruebas son buenas? La cobertura (qué líneas se ejecutan) no basta: una prueba puede pasar por una línea sin comprobar nada útil. Las pruebas de mutación responden de otra forma: se crean copias del código con un error pequeño (un mutante: < cambiado por <=, una condición borrada…) y se ejecutan las pruebas contra cada una. Si alguna prueba falla, el mutante muere; si todas pasan, el mutante sobrevive, y eso señala un caso que nadie está comprobando.
En este ejercicio el código a probar es la tarifa de un cine (está en el código de partida): bebés de menos de 4 años gratis, niños de menos de 12 a 4 €, mayores de 65 a 5 €, estudiantes a 6 €, el resto a 8 €, los miércoles 2 € menos (salvo si es gratis) y error si la edad no está entre 0 y 120. Tu trabajo es solo el método pruebas, con las mini aserciones igual y lanza (como assertEquals y assertThrows de JUnit).
Qué tiene que hacer el programa
- Completa
pruebas(Tarifa t)con llamadas aigual(caso, esperado, t.precio(...))ylanza(caso, () -> t.precio(...)). No cambies nada más. - Todas tus pruebas deben pasar con el código correcto (
Original: todas las pruebas pasan); si una falla, el programa lo dice y se detiene. - El programa ejecuta tus pruebas contra los siete mutantes y escribe, para cada uno,
muertooVIVO. El objetivo es la salida que termina enMutantes muertos: 7 de 7. La entrada puede traer los números de los mutantes que se quieren probar, separados por espacios (en ese orden, sin repetir; lo que no sea del 1 al 7 se ignora); si no trae ninguno, se prueban todos. - No hace falta probar todas las edades: con los valores límite de cada regla (justo antes y justo después de cada frontera) y un caso por cada condición sobra.
Ejemplos de ejecución
Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.
Contra los siete mutantes
Salida por consola
Original: todas las pruebas pasan Mutante 1 (edad < 4 cambiado por edad <= 4): muerto Mutante 2 (edad < 12 cambiado por edad <= 12): muerto Mutante 3 (edad >= 65 cambiado por edad > 65): muerto Mutante 4 (se ha perdido el descuento de estudiante): muerto Mutante 5 (el día del espectador también resta a las entradas gratis): muerto Mutante 6 («miércoles» se compara distinguiendo mayúsculas): muerto Mutante 7 (edad > 120 cambiado por edad >= 120): muerto Mutantes muertos: 7 de 7
Solo los mutantes de frontera
Entrada
2 1 3 2
Salida por consola
Original: todas las pruebas pasan Mutante 2 (edad < 12 cambiado por edad <= 12): muerto Mutante 1 (edad < 4 cambiado por edad <= 4): muerto Mutante 3 (edad >= 65 cambiado por edad > 65): muerto Mutantes muertos: 3 de 3
Guía paso a paso
Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.
1. Lee los mutantes
Cada descripción dice qué se ha cambiado: edad < 4 cambiado por edad <= 4 solo se distingue del original con una edad de exactamente 4. Busca el caso concreto en el que cada mutante y el original dan resultados distintos.
2. Valores límite
Para cada frontera (4, 12, 65, 120) prueba el valor justo en la frontera y el anterior: 3 y 4, 11 y 12, 64 y 65, 120 y 121.
igual("4 años ya paga", 4, t.precio(4, false, "lunes"));
igual("3 años, gratis", 0, t.precio(3, false, "lunes"));3. Las condiciones
Una prueba para el descuento de estudiante, otra para el miércoles en mayúsculas y otra para que un bebé siga sin pagar el miércoles.
4. Las excepciones
lanza comprueba que se lanza IllegalArgumentException. Prueba una edad imposible y, para el último mutante, que 120 sí es válida con igual.
Resuélvelo aquí
El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 2 casos ocultos que buscan los errores típicos.
Ejemplo de lo que debe mostrar
Original: todas las pruebas pasan Mutante 1 (edad < 4 cambiado por edad <= 4): muerto Mutante 2 (edad < 12 cambiado por edad <= 12): muerto Mutante 3 (edad >= 65 cambiado por edad > 65): muerto Mutante 4 (se ha perdido el descuento de estudiante): muerto Mutante 5 (el día del espectador también resta a las entradas gratis): muerto Mutante 6 («miércoles» se compara distinguiendo mayúsculas): muerto Mutante 7 (edad > 120 cambiado por edad >= 120): muerto Mutantes muertos: 7 de 7
Solución explicada
Ver la solución completa
1import java.util.ArrayList;
2import java.util.Scanner;
3import java.util.List;
4
5/** Precio de una entrada de cine (el código que hay que probar). */
6class Tarifa {
7 double precio(int edad, boolean estudiante, String dia) {
8 if (edad < 0 || edad > 120) throw new IllegalArgumentException("edad no válida: " + edad);
9 double p;
10 if (edad < 4) p = 0;
11 else if (edad < 12) p = 4;
12 else if (edad >= 65) p = 5;
13 else if (estudiante) p = 6;
14 else p = 8;
15 if (dia.equalsIgnoreCase("miércoles") && p > 0) p -= 2; // día del espectador
16 return p;
17 }
18}
19
20// Los mutantes: copias de Tarifa con un error pequeño cada una
21class M1 extends Tarifa { double precio(int e, boolean s, String d) { if (e < 0 || e > 120) throw new IllegalArgumentException(); double p; if (e <= 4) p = 0; else if (e < 12) p = 4; else if (e >= 65) p = 5; else if (s) p = 6; else p = 8; if (d.equalsIgnoreCase("miércoles") && p > 0) p -= 2; return p; } }
22class M2 extends Tarifa { double precio(int e, boolean s, String d) { if (e < 0 || e > 120) throw new IllegalArgumentException(); double p; if (e < 4) p = 0; else if (e <= 12) p = 4; else if (e >= 65) p = 5; else if (s) p = 6; else p = 8; if (d.equalsIgnoreCase("miércoles") && p > 0) p -= 2; return p; } }
23class M3 extends Tarifa { double precio(int e, boolean s, String d) { if (e < 0 || e > 120) throw new IllegalArgumentException(); double p; if (e < 4) p = 0; else if (e < 12) p = 4; else if (e > 65) p = 5; else if (s) p = 6; else p = 8; if (d.equalsIgnoreCase("miércoles") && p > 0) p -= 2; return p; } }
24class M4 extends Tarifa { double precio(int e, boolean s, String d) { if (e < 0 || e > 120) throw new IllegalArgumentException(); double p; if (e < 4) p = 0; else if (e < 12) p = 4; else if (e >= 65) p = 5; else p = 8; if (d.equalsIgnoreCase("miércoles") && p > 0) p -= 2; return p; } }
25class M5 extends Tarifa { double precio(int e, boolean s, String d) { if (e < 0 || e > 120) throw new IllegalArgumentException(); double p; if (e < 4) p = 0; else if (e < 12) p = 4; else if (e >= 65) p = 5; else if (s) p = 6; else p = 8; if (d.equalsIgnoreCase("miércoles")) p -= 2; return p; } }
26class M6 extends Tarifa { double precio(int e, boolean s, String d) { if (e < 0 || e > 120) throw new IllegalArgumentException(); double p; if (e < 4) p = 0; else if (e < 12) p = 4; else if (e >= 65) p = 5; else if (s) p = 6; else p = 8; if (d.equals("miércoles") && p > 0) p -= 2; return p; } }
27class M7 extends Tarifa { double precio(int e, boolean s, String d) { if (e < 0 || e >= 120) throw new IllegalArgumentException(); double p; if (e < 4) p = 0; else if (e < 12) p = 4; else if (e >= 65) p = 5; else if (s) p = 6; else p = 8; if (d.equalsIgnoreCase("miércoles") && p > 0) p -= 2; return p; } }
28
29public class Main {
30 // ── Mini biblioteca de pruebas (como las aserciones de JUnit) ──
31 static List<String> fallos = new ArrayList<>();
32
33 static void igual(String caso, double esperado, double obtenido) {
34 if (Math.abs(esperado - obtenido) > 1e-9) fallos.add(caso + " (esperado " + esperado + ", obtenido " + obtenido + ")");
35 }
36
37 static void lanza(String caso, Runnable codigo) {
38 try {
39 codigo.run();
40 fallos.add(caso + " (esperaba IllegalArgumentException y no se lanzó)");
41 } catch (IllegalArgumentException e) {
42 // bien: es lo que se esperaba
43 }
44 }
45
46 /** Las pruebas: cada una, un caso con su resultado esperado. Los valores límite son los que cazan mutantes. */
47 static void pruebas(Tarifa t) {
48 igual("bebé de 3 años", 0, t.precio(3, false, "lunes"));
49 igual("4 años ya paga", 4, t.precio(4, false, "lunes"));
50 igual("11 años, infantil", 4, t.precio(11, false, "lunes"));
51 igual("12 años, adulto", 8, t.precio(12, false, "lunes"));
52 igual("64 años, adulto", 8, t.precio(64, false, "lunes"));
53 igual("65 años, jubilado", 5, t.precio(65, false, "lunes"));
54 igual("estudiante de 20", 6, t.precio(20, true, "lunes"));
55 igual("estudiante jubilado: el mejor precio", 5, t.precio(70, true, "lunes"));
56 igual("miércoles, adulto", 6, t.precio(30, false, "miércoles"));
57 igual("miércoles en mayúsculas", 6, t.precio(30, false, "MIÉRCOLES"));
58 igual("miércoles, bebé: sigue gratis", 0, t.precio(2, false, "miércoles"));
59 igual("120 años es una edad válida", 5, t.precio(120, false, "lunes"));
60 lanza("edad negativa", () -> t.precio(-1, false, "lunes"));
61 lanza("edad de 121", () -> t.precio(121, false, "lunes"));
62 }
63
64 public static void main(String[] args) {
65 pruebas(new Tarifa());
66 if (!fallos.isEmpty()) {
67 System.out.println("Original: falla " + fallos.get(0));
68 System.out.println("Una prueba que falla con el código correcto está mal escrita: corrígela antes de cazar mutantes.");
69 return;
70 }
71 System.out.println("Original: todas las pruebas pasan");
72 String[] descripciones = {
73 "edad < 4 cambiado por edad <= 4",
74 "edad < 12 cambiado por edad <= 12",
75 "edad >= 65 cambiado por edad > 65",
76 "se ha perdido el descuento de estudiante",
77 "el día del espectador también resta a las entradas gratis",
78 "«miércoles» se compara distinguiendo mayúsculas",
79 "edad > 120 cambiado por edad >= 120",
80 };
81 Tarifa[] mutantes = {new M1(), new M2(), new M3(), new M4(), new M5(), new M6(), new M7()};
82 // la entrada puede elegir qué mutantes probar (por defecto, todos)
83 List<Integer> elegidos = new ArrayList<>();
84 Scanner sc = new Scanner(System.in);
85 while (sc.hasNext()) {
86 String t = sc.next();
87 if (t.matches("[1-7]") && !elegidos.contains(Integer.parseInt(t))) elegidos.add(Integer.parseInt(t));
88 }
89 if (elegidos.isEmpty()) for (int k = 1; k <= mutantes.length; k++) elegidos.add(k);
90 int muertos = 0;
91 for (int k : elegidos) {
92 int i = k - 1;
93 fallos.clear();
94 boolean muerto;
95 try {
96 pruebas(mutantes[i]);
97 muerto = !fallos.isEmpty();
98 } catch (RuntimeException e) {
99 muerto = true; // una excepción inesperada también delata al mutante
100 }
101 if (muerto) muertos++;
102 System.out.println("Mutante " + (i + 1) + " (" + descripciones[i] + "): " + (muerto ? "muerto" : "VIVO, falta una prueba que lo distinga"));
103 }
104 System.out.println("Mutantes muertos: " + muertos + " de " + elegidos.size());
105 }
106}Los mutantes de frontera (< por <=) son los más comunes en el código real, y solo se cazan con valores límite: por eso el análisis de valores límite es la técnica de caja negra más rentable.
Un mutante que sobrevive no siempre es un fallo de las pruebas: a veces es equivalente (se comporta igual que el original en todos los casos). Aquí ninguno lo es, así que todos se pueden matar.
Las pruebas que solo comprueban «que no explota» matan muy pocos mutantes. Lo que mata mutantes es comprobar el resultado exacto en el caso justo.
En Java, PIT (pitest) genera los mutantes automáticamente sobre tu código y tus pruebas JUnit, y da un porcentaje de mutantes muertos que mide mejor que la cobertura la calidad de las pruebas.
Para ir más allá
- Escribe un octavo mutante que sea equivalente al original y explica por qué ninguna prueba puede matarlo.
- Reduce tus pruebas al mínimo número que sigue matando los siete mutantes.
- Pasa las pruebas a JUnit 5 con
@ParameterizedTesty@CsvSource.