Mutation Testing: Probando tus pruebas
28 de octubre de 2024
La base del testing es la confianza. Escribimos pruebas para asegurarnos de que nada se ha roto, pero ¿cómo podemos fiarnos de unas pruebas que nunca hemos visto fallar? ¿Me servirán de algo si meto la pata en mi código?
Aquí es donde el mutation testing puede ayudar enormemente, ya que se trata de una técnica que evalúa la calidad y eficacia de tus pruebas unitarias.
En esencia, el mutation testing introduce cambios pequeños y controlados (mutaciones) en el código fuente de tu aplicación y luego ejecuta tu suite de test contra estas versiones "mutadas" del código. Si tus pruebas son robustas, deberían detectar estos cambios y fallar. Si no lo hacen, esto podría indicar una debilidad en el conjunto de tests.
Esta técnica nos permite poner a prueba nuestras propias pruebas, proporcionando una capa adicional de seguridad y confianza en nuestro proceso de desarrollo de software.
¿Por qué es importante?
La calidad de las pruebas es un aspecto crucial en el desarrollo de software, pero a menudo es difícil de medir. Tradicionalmente, los desarrolladores han confiado en métricas como la cobertura de código para evaluar la exhaustividad de sus pruebas. Sin embargo, si tomamos esta métrica de manera única, no tendremos la foto completa.
Una alta cobertura de código solo nos dice que una parte del código se ha ejecutado durante las pruebas, pero no nos da información sobre la calidad o la efectividad de esas pruebas. Es posible tener una cobertura del 100% con tests que no verifican realmente el comportamiento correcto del código.
Aquí es donde el mutation testing aporta su valor. Al introducir cambios deliberados en el código y verificar si las pruebas los detectan, esta técnica proporciona una medida más precisa de lo buenas que son nuestras pruebas a la hora de verificar el comportamiento del código.
Por tanto, el mutation testing es un excelente complemento para la cobertura de código.
Profundizando
El proceso de mutation testing se puede desglosar en varios pasos:
1- Generación de mutantes: se crean versiones modificadas del código fuente original. Cada mutante contiene una pequeña modificación, como cambiar un operador aritmético o lógico, modificar una condición, etc.
2- Ejecución de pruebas: la suite de pruebas se ejecuta contra cada mutante.
3- Análisis de resultados: se evalúa si las pruebas detectaron la mutación (el mutante fue "matado") o no (el mutante "sobrevivió").
Los tipos de mutaciones pueden variar, pero algunos ejemplos comunes incluyen:
-
Cambiar operadores aritméticos (+ por -, * por /, etc.)
-
Modificar operadores lógicos (AND por OR, > por >=, etc.)
-
Alterar valores de retorno (return true por return false)
-
Eliminar líneas de código
El resultado del mutation testing se expresa a menudo como un "mutation score", que es el porcentaje de mutantes que fueron detectados (matados) por las pruebas.
Una de las principales ventajas es que puede revelar debilidades sutiles en las pruebas que otras técnicas podrían pasar por alto. Sin embargo, también presenta inconvenientes, como el tiempo de ejecución potencialmente largo y la necesidad de interpretar correctamente los resultados.
Ejemplo
Veamos ahora un ejemplo con Java y la librería PIT de mutation testing.
Imaginemos que tenemos un módulo que calcula el precio total del carrito de la compra, donde existen casos especiales en los que hay descuentos tanto por la suma total como por artículos concretos.
public class CarritoCompra {
public static class Item {
double precio;
int cantidad;
boolean descuentoAplicable;
public Item(double precio, int cantidad, boolean descuentoAplicable) {
this.precio \= precio;
this.cantidad \= cantidad;
this.descuentoAplicable \= descuentoAplicable;
}
}
public double calcularTotal(List<Item> carrito) {
double total \= 0;
for (Item item : carrito) {
double descuento \= item.descuentoAplicable ? item.precio \* 0.1 : 0;
total += (item.precio \- descuento) \* item.cantidad;
}
if (total \> 100) {
total \= total \* 0.9; // Aplicar 10% de descuento si el total es mayor a 100
}
return total;
}
}
En una primera versión, podríamos partir de los siguientes tests:
@Test
public void testCalculoTotalSinDescuentoGlobal() {
CarritoCompra carritoCompra \= new CarritoCompra();
List<CarritoCompra.Item> carrito \= Arrays.asList(
new CarritoCompra.Item(20, 2, true), // Precio: 20, Cantidad: 2, Descuento Aplicable
new CarritoCompra.Item(50, 1, false) // Precio: 50, Cantidad: 1, Sin descuento
);
double total \= carritoCompra.calcularTotal(carrito);
assertEquals(86, total);
}
@Test
public void testCalculoTotalConDescuentoGlobal() {
CarritoCompra carritoCompra \= new CarritoCompra();
List<CarritoCompra.Item> carrito \= Arrays.asList(
new CarritoCompra.Item(60, 2, false), // Precio: 60, Cantidad: 2, Sin descuento
new CarritoCompra.Item(50, 1, false) // Precio: 50, Cantidad: 1, Sin descuento
);
double total \= carritoCompra.calcularTotal(carrito);
assertEquals(153, total); // Descuento global del 10% sobre 170
}
Ambos test parecen razonables y además, nos dan una cobertura del 100%. No obstante, si ejecutamos la herramienta PIT y observamos el reporte, veremos que nuestros tests podrían mejorarse.
Lo que vemos a continuación es que, a pesar de tener un 100% de cobertura, no estamos teniendo en cuenta el caso límite de que la suma total de los artículos sea exactamente igual a 100 y, por tanto, el “mutante” sobrevive.
Para mejorar esto, deberíamos añadir un caso de prueba que tenga en cuenta esta casuística
@Test
public void testConDescuentoAplicable() {
CarritoCompra carritoCompra = new CarritoCompra();
List<CarritoCompra.Item> carrito = Arrays.asList(
new CarritoCompra.Item(100, 1, false)
);
double total = carritoCompra.calcularTotal(carrito);
assertEquals(100, total);
}
Casos de Uso y herramientas
El mutation testing se puede implementar en varios escenarios:
1- Desarrollo continuo: los desarrolladores pueden usar el mutation testing localmente para mejorar sus pruebas antes de hacer commit de su código.
2- Integración continua: el mutation testing se puede integrar en pipelines de CI/CD para garantizar que la calidad de las pruebas se mantenga a lo largo del tiempo.
3- Revisión de código: los resultados del mutation testing pueden ser parte de los criterios de aceptación en las revisiones de código.
4- Mantenimiento de código legacy: el mutation testing puede ayudar a identificar áreas donde las pruebas existentes son inadecuadas en bases de código más antiguas.
Existen herramientas de mutation testing para varios lenguajes de programación. Por ejemplo, PIT para Java, mutmut para Python, Stryker para JavaScript, y muchas más.
Conclusiones
El mutation testing es una herramienta sencilla pero muy útil para mejorar la calidad de nuestras pruebas unitarias. Nos ayuda a ganar confianza al revelar debilidades que otras métricas podrían pasar por alto.
Algunas recomendaciones para implementarlo en proyectos existentes incluyen:
1- Comenzar en entorno local o con plugins del IDE.
2- Comenzar con módulos pequeños y críticos.
3- Integrarlo en el proceso de CI/CD gradualmente.
4- Usar los resultados del mutation testing como una guía para mejorar las pruebas, no como una métrica punitiva.
5- Combinar esta técnica con otras prácticas de calidad de código, como revisiones de código y análisis estático.
Recuerda: no confíes en una prueba que nunca hayas visto fallar. El mutation testing te ayuda a asegurarte de que tus pruebas fallarán cuando deban hacerlo.
Nuestras últimas novedades
¿Te interesa saber cómo nos adaptamos constantemente a la nueva frontera digital?
19 de diciembre de 2024
Contract Testing con Pact - La guía definitiva
10 de diciembre de 2024
Tecnologías rompedoras hoy, que redibujarán el mapa de la innovación en 2025
26 de noviembre de 2024
El ecosistema digital con el que Vítaly reinventa el cuidado de la salud
21 de noviembre de 2024
Contract Testing as a Service: apoya a tus clientes