En el artículo Mejora la calidad de los tests con Mutation testing vimos qué es, para qué sirve y cómo funciona el Mutation testing con una simple kata Fizz Buzz.

También determinamos que los tests de mutación son los “vigilantes” que validan si nuestros tests hacen bien su cometido.

Muchas veces, el Mutation Testing es rechazado en proyectos reales por su lentitud. En este post vemos cómo implementarlos de manera eficiente en un proyecto con Spring Boot 3.

Antes de empezar, vamos a revisar las reglas para la implementación efectiva de Mutation testing. Para que Mutation Testing sea viable, nuestros tests deben cumplir los siguientes requisitos:

Básicamente, los unit tests cumplen estos requisitos, así que debemos excluir los siguientes pruebas del Mutation testing:

Teniendo esto claro, ¡manos a la obra! Ahora veremos cómo aplicarlo en un proyecto con Spring Boot 3 de una manera más efectiva.

Usaremos Pitest, que es un sistema de pruebas de mutación de vanguardia que ofrece una cobertura de pruebas de referencia para Java y JVM. Es rápido, escalable y se integra con herramientas modernas de prueba y compilación.

¿Por qué elegimos Pitest?

Existen otros sistemas de pruebas de mutaciones para Java, pero no se utilizan ampliamente. En su mayoría son lentos, difíciles de usar y están escritos para satisfacer las necesidades de la investigación académica en lugar de las de equipos de desarrollo reales.

Pitest es diferente por los siguientes motivos:

Añadimos Pitest a un proyecto que existe. El código se puede obtener en este GitHub: spring-boot-3-mutation-testing.

Añadir el plugin de Pitest al proyecto

Añadimos el plugin de Pites a nuestro pom.xml:

<plugin>
 <groupId>org.pitest</groupId>
 <artifactId>pitest-maven</artifactId>
 <version>1.7.6</version>
 <dependencies>
   <dependency>
     <groupId>org.pitest</groupId>
     <artifactId>pitest-junit5-plugin</artifactId>
     <version>0.16</version>
   </dependency>
 </dependencies>
</plugin>

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 habéis 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

Personalmente, me gusta el feedback rápido, así que prefiero ejecutar primero los tests y después mutationCoverage.

Después de esperar un rato, vemos el resultado:

Resultado mutation coverage

Es un fallo. Como hemos visto en el artículo anterior, Pitest ejecuta todos los tests antes de empezar con las mutaciones y el test que falla es uno de integración que usa Testcontainers y es lento.

Esto no cumple los requisitos. Vamos a excluirlos de las pruebas de mutación.

Excluir las pruebas de integración

Configuramos nuestro Pitest para que excluya los tests de integración, para esto modificamos nuestro pom.xml para que quede de la siguiente manera:

<plugin>
 <groupId>org.pitest</groupId>
 <artifactId>pitest-maven</artifactId>
 <version>1.7.6</version>
 <dependencies>
   <dependency>
     <groupId>org.pitest</groupId>
     <artifactId>pitest-junit5-plugin</artifactId>
     <version>0.16</version>
   </dependency>
 </dependencies>
 <configuration>
   <excludedTestClasses>
     <param>**.*IntegrationTest</param>
   </excludedTestClasses>
 </configuration>
</plugin>

Ejecutamos de nuevo los comandos de Maven y esta vez termina bien:

Ejecución de comandos maven

Revisamos las estadísticas que nos proporciona Pitest y vemos que tenemos 2% de mutaciones matadas, el resto ha sobrevivido:

Estadísticas que proporciona Pytest

Al finalizar la ejecución de los tests de mutación, Pitest crea una carpeta pit-reports con los resultados de las ejecuciones en /target.

Carpeta de Pitest, pit repost

Abrimos el index.html para visualizar el resultado de una forma más clara y comprensible:

Pit test coverage report

Después de revisar los resultados, vemos que la mayoría de las clases sin Mutation Coverage son los autogenerados:

Nombre de la clase Generado por
*RDTO OpenAPI codegen
*MapperImpl MapStruct
ApiUtil OpenAPI codegen

Son Mutantes ruidosos, así que vamos a excluirlos.

Excluir los Mutantes ruidosos

De nuevo modificamos nuestro pom.xml para excluir las clases autogeneradas:

<plugin>
 <groupId>org.pitest</groupId>
 <artifactId>pitest-maven</artifactId>
 <version>1.7.6</version>
 <dependencies>
   <dependency>
     <groupId>org.pitest</groupId>
     <artifactId>pitest-junit5-plugin</artifactId>
     <version>0.16</version>
   </dependency>
 </dependencies>
 <configuration>
   <excludedClasses>
     <param>**.*Application</param>
     <param>**.*RDTO</param>
     <param>**.*MapperImpl</param>
     <param>**.controller.api.ApiUtil</param>
   </excludedClasses>
   <excludedTestClasses>
     <param>**.*IntegrationTest</param>
   </excludedTestClasses>
 </configuration>
</plugin>

Ejecutamos de nuevo los tests de mutación y vemos que el tiempo de mutación ha bajado y el porcentaje de mutaciones matadas ha subido a 16%. No está mal, esto significa que vamos por buen camino.

Estadísticas Pitest

Ahora sí que han quedado los mutantes que no se pueden matar y/o que llevan información útil.

¿Cómo optimizar Pitest?

Una de las cosas que se puede hacer es optimizar Pitest. Para esto lo configuramos para hacer las siguiente optimizaciones:

Aplicamos estas mejoras en nuestro pom.xml:

<plugin>
 <groupId>org.pitest</groupId>
 <artifactId>pitest-maven</artifactId>
 <version>1.7.6</version>
 <dependencies>
   <dependency>
     <groupId>org.pitest</groupId>
     <artifactId>pitest-junit5-plugin</artifactId>
     <version>0.16</version>
   </dependency>
 </dependencies>
 <configuration>
   <timestampedReports>false</timestampedReports>
   <threads>8</threads>
   <withHistory>true</withHistory>
   <mutationThreshold>80</mutationThreshold>
   <excludedClasses>
     <param>**.*Application</param>
     <param>**.*RDTO</param>
     <param>**.*MapperImpl</param>
     <param>**.controller.api.ApiUtil</param>
   </excludedClasses>
   <excludedTestClasses>
     <param>**.*IntegrationTest</param>
   </excludedTestClasses>
 </configuration>
</plugin>

Ejecutamos el mutationCoverage y vemos que tarda menos tiempo, pero el resultado es fallo, porque estamos por debajo del umbral que hemos definido:

Resultado mutation coverage

Gracias a que hemos habilitado el historial de análisis, si ejecutamos de nuevo los tests de mutación tardaría mucho menos tiempo.

Matando a los mutantes

Ahora que tenemos todo bien configurado, podemos empezar a matar a los mutantes. Para esto analizaremos los resultados de las pruebas de mutación.

Revisamos cuál es el problema con BasketController. Pitest ha reemplazado con null los return de los métodos del controlador y los mutantes han sobrevivido.

Problema basket controller

Revisando el test, vemos que solo se comprueba el status code sin comprobar la respuesta.

Vamos a hacerlo:

@Test
void return_basket_without_items() throws Exception {

   Basket basket = new Basket(1L, 1L, new Items());

   when(basketService.getBy(1L)).thenReturn(basket);

   this.mockMvc
       .perform(get("/users/1/basket"))
       .andExpect(status().isOk())
       .andExpect(jsonPath("$.id").value(1))
       .andExpect(jsonPath("$.userId").value(1))
       .andExpect(jsonPath("$.items.products.size()").value(0));
}

Ahora el mutationCoverage nos da un 37% y todos los mutantes de los en el package .infrastructure.controller están capturados, ¡no está mal!

Pit test coverage report

Seguimos mejorando los asserts de los tests en los package application y domain.

Ahora, solo queda cubrir con tests la clase GlobalExceptionHandler porque solo queda ella con 6 mutantes que sobreviven.

Empezamos añadiendo un nuevo test en ProductControllerTest:

@ExtendWith(SpringExtension.class)
@WebMvcTest
@ContextConfiguration(classes = {ProductController.class, GlobalExceptionHandler.class})
@Import(ProductMapperImpl.class)
class ProductControllerTest {

   @Test
   void return_basket_not_found() throws Exception {
       doThrow(ProductNotFoundException.class).when(productService).getProductBy(100L);

       this.mockMvc
           .perform(get("/products/100"))
           .andExpect(status().isNotFound())
           .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
           .andExpect(jsonPath("$.title").value("Out of Stock"))
           .andExpect(jsonPath("$.detail").value("Something went wrong!"))
           .andExpect(jsonPath("$.type").value("https://example.org/out-of-stock"));
   }

}

Hacemos lo mismo para la casuística que el carrito no existe. Ejecutamos los tests de mutación y ¡voilà! Se han creado 19 mutantes y todos capturados. ¡Tenemos todo en verde!

Pit test coverage report

Hemos visto cómo se puede mejorar la calidad de nuestros tests utilizando Mutation Testing.

Incrementando la velocidad de los Mutations tests

En proyectos grandes, los Mutations Test pueden tardar mucho tiempo y es difícil tenerlos en los pipelines.

Por ejemplo, Pitest crea para el core de Apache Flink 43.619 mutantes y los mutation tests duran 2:29:45 horas. Nadie quiere esperar dos horas y media en un paso de la pipeline.

Hay un proyecto de investigación colaborativo, STAMP (Software testing amplification for DevOps), del cual ha surgido Descartes, que es un motor de mutación para Pitest.

Pruebas de mutación extrema

En las pruebas de mutación extrema (Extreme Mutation Testing), se elimina toda la lógica del método que se prueba.

Se eliminan todas las declaraciones en un método void. En otro caso, el cuerpo se reemplaza por una declaración de devolución única. Este enfoque genera menos mutantes.

¿Cómo funciona Descartes?

El objetivo de Descartes es llevar una implementación efectiva de este tipo de operador de mutación al mundo de Pitest y verificar su desempeño en proyectos del mundo real.

Si comparamos el performance de Descartes y Gregor (el motor de mutación por defecto de Pitest), veremos que para el core de Apache Flink, Descartes crea 4.935 mutantes y los tests duran solo 14:04 minutos. ¡Es impresionante!

Usar Descartes como motor de mutación

Modificamos la configuración del plugin de Pites en nuestro pom.xml para cambiar el motor de mutaciones por Descartes:

<plugin>
 <groupId>org.pitest</groupId>
 <artifactId>pitest-maven</artifactId>
 <version>1.7.6</version>
 <dependencies>
   <dependency>
     <groupId>org.pitest</groupId>
     <artifactId>pitest-junit5-plugin</artifactId>
     <version>0.16</version>
   </dependency>
   <dependency>
     <groupId>eu.stamp-project</groupId>
     <artifactId>descartes</artifactId>
     <version>1.3.2</version>
   </dependency>
 </dependencies>
 <configuration>
   <mutationEngine>descartes</mutationEngine>
   <timestampedReports>false</timestampedReports>
   <threads>8</threads>
   <withHistory>true</withHistory>
   <mutationThreshold>80</mutationThreshold>
   <excludedClasses>
     <param>**.*Application</param>
     <param>**.*RDTO</param>
     <param>**.*MapperImpl</param>
     <param>**.controller.api.ApiUtil</param>
   </excludedClasses>
   <excludedTestClasses>
     <param>**.*IntegrationTest</param>
   </excludedTestClasses>
 </configuration>
</plugin>

Ejecutamos los tests de mutación y observamos que, esta vez, se han generado 10 mutantes en lugar de 19, y todos han sido eliminados con éxito.

Es importante recordar que cada mutante se somete a la ejecución de todos los tests, por lo que reducir la cantidad de mutantes también disminuye el tiempo de ejecución.

Pit test coverage report

Actualizar Pitest y Descartes

El motor de mutación Descartes funciona muy bien, pero llevaba cuatro años sin publicar una nueva release y no era compatible con las últimas versiones de Pitest… hasta el mes pasado. Realicé algunas mejoras para que sea compatible con la última versión de Pitest y, por fin, tenemos una nueva versión: Descartes 1.3.3.

Vamos a actualizar las versiones de Pitest y Descartes a las últimas disponible hasta la fecha:

Así quedarían los cambios en nuestro pom.xml:

<plugin>
 <groupId>org.pitest</groupId>
 <artifactId>pitest-maven</artifactId>
 <version>1.20.2</version>
 <dependencies>
   <dependency>
     <groupId>org.pitest</groupId>
     <artifactId>pitest-junit5-plugin</artifactId>
     <version>1.2.3</version>
   </dependency>
   <dependency>
     <groupId>eu.stamp-project</groupId>
     <artifactId>descartes</artifactId>
     <version>1.3.3</version>
   </dependency>
 </dependencies>
 <configuration>
   <mutationEngine>descartes</mutationEngine>
   <timestampedReports>false</timestampedReports>
   <threads>8</threads>
   <withHistory>true</withHistory>
   <mutationThreshold>80</mutationThreshold>
   <excludedClasses>
     <param>**.*Application</param>
     <param>**.*RDTO</param>
     <param>**.*MapperImpl</param>
     <param>**.controller.api.ApiUtil</param>
   </excludedClasses>
   <excludedTestClasses>
     <param>**.*IntegrationTest</param>
   </excludedTestClasses>
 </configuration>
</plugin>

Ejecutamos de nuevo los tests de mutación y vemos que no hay cambios, se han generado 10 mutantes y todos han sido eliminados con éxito.

Esta versión de Pitest nos da más información como:

Código donde se muestra que se han generado 10 mutantes y todos han sido eliminados con éxito

Conclusiones

La implementación efectiva de Mutation Testing en proyectos con Spring Boot 3 nos permite mejorar significativamente la calidad de nuestras pruebas unitarias y la robustez del código.

A lo largo de este post, hemos visto cómo configurar y optimizar Pitest para obtener resultados precisos y reducir el impacto en los tiempos de ejecución.
Algunas conclusiones clave:

En definitiva, Mutation Testing no solo valida la calidad de nuestros tests, sino también nos ayuda a mejorar nuestros tests unitarios.

Podéis ver todo el código con los commits por pasos en el repositorio GitHub: spring-boot-3-mutation-testing-project.

Enlaces de interés

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