Quis custodiet ipsos custodes? Esta es una locución latina del poeta romano Juvenal, que significa ¿Quién vigilará a los vigilantes? Es una buena pregunta. ¿De verdad la ley es igual para todo el mundo? ¿Los vigilantes aplican la ley de manera correcta?

En el desarrollo del software, los vigilantes son los test. Sí, los tests deben asegurarse de que nuestro código cumple con los requisitos de negocio, es decir, que nuestro código hace lo que debe hacer. Los tests también son los guardianes que deben vigilar que no “rompamos” nada cuando hacemos alguna modificación, deben avisarnos.

Pero, ¿cómo podemos asegurarnos de que tenemos suficientes tests? Sí, podemos medir la cobertura de código, conocido más por su nombre en inglés Code Coverage:
En ingeniería de software, la cobertura de código, también llamada cobertura de tests, es una medida porcentual que indica el grado en que el código fuente de un programa se ejecuta al ejecutar un conjunto de pruebas determinado.

Como se puede ver de la definición, la cobertura de código solo mide el porcentaje de las líneas que se han ejecutado, no la calidad de los tests. He visto muchos proyectos con una cobertura de código casi de 100%, pero la mayoría de los tests eran irrelevantes o poco útiles. La cantidad y la calidad son diferentes cosas. Pero ¿cómo podemos detectar la calidad de nuestros tests? ¿Y quién controla si nuestros tests (los vigilantes) hacen bien su cometido? Los tests de mutación (Mutation Testing) nos ayudan con esta tarea.

¿Qué es mutation testing?

La idea de mutation testing es modificar el código cubierto por tests de forma sencilla, comprobando si el conjunto de tests existente para este código detectará y rechazará las modificaciones. Se utiliza para diseñar nuevos tests y evaluar la calidad de los tests existentes. ¿Cuáles son los tests con mala calidad?

Suposiciones subyacentes

Mutation testing se basa en dos ideas:

La primera es la hipótesis del programador/a competente: hacen su trabajo de la mejor manera posible, pero podemos cometer pequeños errores que no están en la estructura del programa.

La segunda es el efecto de acoplamiento: los pequeños errores tienden a propagarse y generar fallos más complejos en el código, por lo que al detectar estos pequeños errores con pruebas de mutación, también se pueden descubrir defectos más graves.

Conceptos básicos

Operadores de mutación/mutadores

Un mutador es la operación que se aplica al código original. Los ejemplos básicos incluyen el cambio de un operador '>' por un '<', la sustitución de los operadores '&&' por '||', y la sustitución de otros operadores matemáticos.

Mutantes

Un mutante es el resultado de aplicar el mutador a una entidad. Un mutante es una modificación del código en el tiempo de ejecución que se utilizará durante la ejecución del conjunto de pruebas.

Mutaciones matadas/supervivientes

Cuando se ejecuta el conjunto de los tests contra el código mutado, hay dos resultados posibles para cada mutante: el mutante ha muerto o ha sobrevivido. Un mutante eliminado significa que al menos una prueba ha fallado como resultado de la mutación. Un mutante que ha sobrevivido significa que nuestro conjunto de pruebas no ha detectado la mutación y, por tanto, debe mejorarse.

¿Cómo funciona mutation testing?

Como el mutation testing valida la calidad de nuestros tests, antes de lanzar las pruebas de mutación se ejecutan los tests y deben pasar. Sino, no se puede seguir.

Si todos los tests pasan correctamente, la librería de mutation testing empieza a crear mutaciones. Para cada mutación se ejecutan todos los tests.

Vamos a ver un ejemplo de 10 tests a los cuales se pueden crear 5 mutantes.

  1. Se ejecutan todos los tests. Si todos pasan, se sigue al paso 2.
  2. Se crea el primer mutante y se ejecutan de nuevo todos los tests. Como vemos en la siguiente imagen, el tercer test falla. Esto significa que el mutante fue detectado y eliminado.
tabla con las pruebas y resultados. vemos que a la tercera prueba, el test falla

👍 Si nuestros tests fallan después de la mutación, entonces podemos decir que la mutación fue detectada y eliminada.

  1. Se crea el segundo mutante y se ejecutan de nuevo todos los test. Esta vez todos los tests pasan sin fallar y el mutante no fue detectado. Por lo tanto, el mutante sobrevivió.
se lanza el segundo mutante y vemos que pasa todos los tests. el resultado sale como que "ha sobrevivido"

La calidad de los tests se mide en función del porcentaje de mutación eliminada. Las pruebas de mutación prueban si los tests son efectivos.

Este es el listado de las herramientas automatizadas para mutation Testing:

Ejemplo de mutation testing con Fizz Buzz Kata

Vamos a verlo con un ejemplo de una kata llamada Fizz Buzz. Los requisitos de la kata son simples:

Aquí tenemos nuestro código de FizzBuzz.java:

public class FizzBuzz {


   public String convert(int number) {
       if (isDivisibleBy(3, number)) {
           return "Fizz";
       }


       if (isDivisibleBy(5, number)) {
           return "Buzz";
       }


       if (isDivisibleBy(15, number)) {
           return "Fizz";
       }


       return String.valueOf(number);
   }


   private boolean isDivisibleBy(int divisor, int number) {
       return number % divisor == 0;
   }
}

Y este es nuestro FizzBuzzTest.java:

class FizzBuzzTest {


   private FizzBuzz fizzBuzz;


   @BeforeEach
   void setUp() {
       this.fizzBuzz = new FizzBuzz();
   }


   @ParameterizedTest
   @CsvSource({"1,1", "2,2", "4,4"})
   void convert_regular_number_to_string(int input, String expected) {
       String actual = fizzBuzz.convert(input);


       assertThat(actual).isEqualTo(expected);
   }


   @ParameterizedTest
   @ValueSource(ints = {3, 6, 9})
   void convert_numbers_divisible_by_3_and_not_divisible_by_5_to_Fizz(int input) {
       String actual = fizzBuzz.convert(input);


       assertThat(actual).isEqualTo("Fizz");
   }


   @ParameterizedTest
   @ValueSource(ints = {5, 10, 20})
   void convert_numbers_divisible_by_5_and_not_divisible_by_3_to_Buzz(int input) {
       String actual = fizzBuzz.convert(input);


       assertThat(actual).isEqualTo("Buzz");
   }


   @ParameterizedTest
   @ValueSource(ints = {15, 30, 45})
   void convert_numbers_divisible_by_15_to_Fizz(int input) {
       String actual = fizzBuzz.convert(input);


       assertThat(actual).isEqualTo("Fizz");
   }
}

Revisamos el código y vemos que los tests pasan. El código no es perfecto pero nos parece correcto para lo que necesitamos ahora.

Añadir el plugin de Pitest al proyecto

Los requisitos no funcionales son:

Vamos a añadir el plugin de Pitest a nuestro pom.xml y configurar el umbral de mutación a 95%.

<build>
 <plugins>
   <plugin>
     <groupId>org.pitest</groupId>
     <artifactId>pitest-maven</artifactId>
     <version>1.16.2</version>
     <dependencies>
       <dependency>
         <groupId>org.pitest</groupId>
         <artifactId>pitest-junit5-plugin</artifactId>
         <version>1.2.1</version>
       </dependency>
     </dependencies>
     <configuration>
       <mutationThreshold>95</mutationThreshold>
     </configuration>
   </plugin>
 </plugins>
</build>

Yo estoy utilizando Maven en este ejemplo, pero si quieres usarlo con Gradle, puedes seguir este tutorial: Gradle quick start.

Revisar los resultados

Probamos si funciona ejecutando los siguientes comandos:

mvn clean test 
mvn pitest:mutationCoverage

Es necesario que se ejecuten los dos comandos en orden, porque Pitest trabaja con el código compilado, así que si no se compila el código y los tests, no veremos el resultado de los últimos cambios que hemos hecho. Si no hemos hecho ningún cambio, es suficiente ejecutar el segundo comando. Un comando más rápido puede ser:

mvn clean compile test-compile 
mvn pitest:mutationCoverage

Pero a mí, personalmente, me gusta el feedback rápido, así que prefiero ejecutar primero los tests y después mutationCoverage. A continuación vemos que nos sale el siguente error:

nos salta un error de que está por debajo del 95%

La puntuación de mutación es de 90% y debe ser de al menos un 95%. Vamos a revisar los resultados del reporte de Pitest. Abrimos el index.html que se puede encontrar en la carpeta target -> pit-reports.

Pantallazo del index donde se encuentra el challenge pit reports

Lo abrimos en nuestro navegador favorito y revisamos los resultados:

pit test coverage report: resultados

Abrimos el informe hasta llegar a FizzBuzz.java y revisamos el reporte:

Resultados del fizzbuzz.java. Vemos que en la línea 15 hay un mutante que ha sobrevivido

Vemos que en la línea 15 hay un mutante que ha sobrevivido. Abajo podemos ver las mutaciones:

Listado de mutaciones. En la línea 15 hay un mutante que ha sobrevivido

Vemos que la sexta mutación consiste en cambiar el valor de retorno con un string vacío “” y el mutante no ha sido detectado por los tests.

Mejorando nuestro código usando los resultados del reporte de Pitest

Si revisamos el código y nos fijamos en la línea 15, vemos que la condición nunca se va a alcanzar porque, si el número es divisible por 15, es también divisible por 3, así que se cumpliría en la condición de la línea 7, cuya condición es que sea divisible por 3 y devuelva “Fizz”.

Movemos la comprobación si el números es divisible por 15 como primera instrucción del nuestro método convert(int number):

public class FizzBuzz {

   public String convert(int number) {
       if (isDivisibleBy(15, number)) {
           return "Fizz";
       }


       if (isDivisibleBy(3, number)) {
           return "Fizz";
       }

       if (isDivisibleBy(5, number)) {
           return "Buzz";
       }

       return String.valueOf(number);
   }

   private boolean isDivisibleBy(int divisor, int number) {
       return number % divisor == 0;
   }
}

Ejecutamos de nuevo los tests de mutación, pasan correctamente y el build termina. Revisamos el reporte y vemos que la puntuación de mutación es de 100%.

Resultados del reporte pit test. el reporte devuelve un éxito del 100%

Revisando los cambios detectamos que, con las prisas, habíamos cometido un error. Para los valores divisibles por tres y cinco devolvemos Fizz en vez de FizzBuzz. Vamos a arreglarlo en el código y en los tests y vemos que todo funciona correctamente.

Gracias a los tests de mutación podemos detectar pequeños errores que están hechos sin querer.

¿Qué hacemos con los mutantes supervivientes?

El análisis de mutantes supervivientes es fundamental para mejorar la calidad del código. Mientras que algunos revelan problemas significativos en el código o en los test, otros representan mutaciones equivalentes o simplemente ruido. Sin embargo, todos proporcionan información valiosa sobre la efectividad de las pruebas.

Tipos de mutantes supervivientes

Podemos dividir los mutantes supervivientes en tres categorías:

  1. Mutantes ruidosos

En esta categoría podemos poner los:

Este código, en mayoría de los casos, es generado automáticamente con el IDE o usando Lombok, así que no aporta mucho valor si le hacemos pruebas de mutación. Se debe excluir de la cobertura de mutación.

  1. Mutantes que no se pueden matar

Este grupo de mutantes nos da información valiosa para hacer un refactor. Estos mutantes nos pueden mostrar:

  1. Mutantes con información valiosa

Son mutantes que revelan datos reales y/o problemas significativos en nuestro código o tests. Debemos hacerles caso y solucionar los problemas que nos muestran.

Operadores de mutación

Existen infinitos cambios posibles dependiendo del tamaño de nuestro código. Estos son algunos los mutadores:

Mutador de métodos void

Si tenemos un método que no devuelve nada, significa que es un método con “side effect”, es decir, que cambia un estado global o algo en la infraestructura. Por esto, Pitest elimina todo el código del método para ver si los tests fallan:

elimina el contenido en "public void method"

Mutador de retornos null

cambia "return new" a "return null" en el código

Mutador de constante

quita el if field y lo sustituye por "return 3"

Mutador de optional

devuelve optional.empty()

La implementación efectiva de mutation testing

Para que se pueda hacer mutation testing, nuestros tests deben cumplir los siguientes requisitos:

Los unit tests cumplen estos requisitos, así que debemos excluir las siguientes pruebas del mutation testing:

Conclusiones

Las pruebas de mutación son una técnica avanzada de validación que ayuda a garantizar la calidad del código al evaluar la efectividad de los tests existentes. Su implementación aporta varios beneficios clave:

En resumen, la prueba de mutación es una técnica valiosa que eleva la calidad del software al fortalecer su sistema de pruebas, fomentando un código más eficiente y asegurando que los errores sean detectados antes de llegar a producción. Podéis ver todo el código con los commits por pasos en el repositorio GitHub fizzbuzz-mutation-testing.

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete