¿Buscas nuestro logo?
Aquí te dejamos una copia, pero si necesitas más opciones o quieres conocer más, visita nuestra área de marca.
¿Buscas nuestro logo?
Aquí te dejamos una copia, pero si necesitas más opciones o quieres conocer más, visita nuestra área de marca.
dev
Ismail Ahmedov Hace 4 días Cargando comentarios…
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ñ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.
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:
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.
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:
Revisamos las estadísticas que nos proporciona Pitest y vemos que tenemos 2% de mutaciones matadas, el resto ha sobrevivido:
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.
Abrimos el index.html para visualizar el resultado de una forma más clara y comprensible:
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.
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.
Ahora sí que han quedado los mutantes que no se pueden matar y/o que llevan información útil.
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:
Gracias a que hemos habilitado el historial de análisis, si ejecutamos de nuevo los tests de mutación tardaría mucho menos tiempo.
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.
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!
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!
Hemos visto cómo se puede mejorar la calidad de nuestros tests utilizando Mutation Testing.
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.
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.
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!
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.
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:
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.
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.
Cuéntanos qué te parece.