En el mundo del software existen muchos tipos de pruebas, y cada tipo se realiza con un objetivo diferente (¿Pruebas de integración, funcionales, de carga…? ¡Qué jaleo! ¿Qué diferencias hay? ). En función de las características de nuestra aplicación, tendremos que poner más foco en ciertas pruebas o en otras.
No obstante, uno de los tipos que en mi opinión es de los más útiles para detectar fallos en etapas tempranas del desarrollo (a la par que uno de los que menos veo que realizan las empresas por las que paso), son las pruebas unitarias (“Vamos a automatizar pruebas”. ¿Qué significa esto? ¿Realmente por dónde deberíamos empezar a automatizar?).
Creo que las pruebas unitarias no se suelen hacer, porque cuesta más adquirir la costumbre de hacerlas, mantenerlas y ver su utilidad.
Pero si quieres mejorar la calidad del software y refactorizar código, las necesitas para poder asegurarte de que los cambios que has hecho funcionan igual que el código que había antes.
Si tu equipo se embarca en la aventura de hacer pruebas unitarias, o ya las hacéis, puede venirte a la cabeza la siguiente pregunta: ya que estoy invirtiendo tiempo en ellas, ¿realmente son las adecuadas?¿cómo sé si estoy haciendo buenas pruebas unitarias? ¿cómo sé si son las necesarias para poder tener la seguridad de que si modifico el código no rompo nada?
Por otro lado, ten en cuenta que las pruebas unitarias son código, y como tal, hay que mantenerlo, modificarlo con el paso del tiempo y adaptarlo a la par que el código fuente de la propia aplicación.
Entonces, ¿cómo gestiono los cambios en las pruebas unitarias para seguir teniendo esa seguridad de que son efectivas?
Cobertura de pruebas
Ante esa pregunta, mucha gente te diría que si tienes una cobertura de pruebas alta (muchas herramientas de análisis de código sacan esta métrica, por ejemplo SonarQube), puedes llegar a tener seguridad de que tus pruebas son de cierta calidad y que puedes refactorizar sin problema.
¡Error! La cobertura de pruebas, no se utiliza para eso. Lo único que te dice esta métrica es qué líneas de código se han ejecutado al menos una vez al lanzar las pruebas unitarias y qué líneas no se han ejecutado nunca.
Que se haya ejecutado esa línea, no quiere decir que se hayan probado en ese punto todas las combinaciones posibles donde podría fallar la aplicación, o la parte más significativa de ellas. Al fin y al cabo, los desarrolladores elaboran las pruebas unitarias, y no son testers, no tienen por qué tener esa mentalidad de testing.
Por lo tanto, que esta métrica te diga que una línea de código está cubierta por un test, no quiere decir que se haya probado todo.
No obstante, si una línea no se ha ejecutado, podemos asegurar que tampoco se ha probado.
Para mí la utilidad de la métrica de cobertura de pruebas, es ver en qué puntos de la aplicación falta por desarrollar pruebas unitarias, pero no para ver la calidad de las pruebas que ya hemos hecho. Ya que además, me he encontrado gente intentando inflar los resultados de esta métrica, creando pruebas sobre métodos get, o cosas que no merecen la pena que tengan una prueba unitaria.
Entonces, ¿existe alguna otra métrica para ver si realmente pruebo el código con la prueba unitaria?
Lo cierto es que sí que existe una técnica llamada “mutation testing”, que sí se utiliza para ver en qué grado la prueba unitaria es capaz de detectar ciertos fallos en el código.
Digamos que la mejor manera de ver la calidad/eficacia de una prueba unitaria, o comprobar si va a responder bien ante un cambio en el código que está probando, es introducir un error aposta, y ver si lo detecta.
En esto se basa el “mutation testing”. En esta técnica, introducimos pequeñas mutaciones/cambios en el código, y vemos si la ejecución de las pruebas unitarias los detecta. Si el test falla, es porque hemos hecho suficientes pruebas para cubrir ese código, que han conseguido detectar ese mal comportamiento, esa mutación del código.
Si el test pasa, y no detecta la mutación introducida, puede ser por dos motivos:
– la mutación equivale al código inicial que hemos cambiado y por eso pasa la prueba.
Voy a ilustrarte este caso con un código un poco mal hecho, pero para que entiendas el concepto. Imagínate este método:
Insertamos esta mutación en el segundo if (cambiamos < por >):
Como puedes ver, justo con esta mutación el segundo if comprueba la misma condición que el primer if; son equivalentes, por lo que el test seguiría pasando.
En este caso de equivalencia la conclusión que podríamos sacar es que realmente este segundo if no es necesario.
– necesitamos algún caso de prueba más para cubrir los casos importantes de ese código.
Imagina este código:
Con estos tests, en los que comprobamos que i empieza valiendo 0, el comportamiento del método cuando i vale por encima y por debajo del límite de 100:
E introducimos esta mutación, eliminamos el = del if:
En este caso, algún test debería fallarme, indicando que hay algo raro en el código, pero la suite pasa.
Aquí detectamos que nos falta un test más, el que comprueba cuando i vale el valor del límite (100):
Pero, oye Ana, si esta técnica es tan útil, ¿por qué no la conozco o parece que nadie la usa?
Si te digo la verdad, hasta hace unos días no hubiera recomendado utilizar esta técnica.
Aunque la idea es muy buena y útil, y se lleva investigando desde hace mucho (creo que se empezó en 1971), el principal impedimento para llevarla a cabo son las herramientas que permiten hacer esto.
Jester, era la herramienta que yo conocía para llevar a cabo mutation testing, y para mí, es muy lenta e ineficiente.
Digamos que esta herramienta genera automáticamente las mutaciones (por ejemplo, cambiar >= por > en un if, eliminar una línea de código, etc.) y cada vez que genera una mutación vuelve a compilar el código. Después, lanza los test sobre cada mutación.
Si tenemos un código muy grande, puede llegar a generar miles y miles de mutaciones (cosa que tarda su tiempo), compilar todo (más tiempo), y ejecutar los tests por cada mutación (mucho más tiempo).
Casi imposible para mí utilizar esta herramienta en ciertos proyectos.
No obstante, tengo que dar las gracias, porque la semana pasada, leí un artículo en Testeando Software de Jesús Badenas, que hablaba sobre esta técnica, y contaba una herramienta que no conocía: Pitest.
Estuve investigando un poco, y realmente la recomiendo. Se integra bien con herramientas como Maven (Simple y rápido. Entiende qué es Maven en menos de 10 min.), Gradle, y tiene un plugin para SonarQube, que indica qué porcentaje de mutaciones no se han detectado con las pruebas unitarias.
Además, es mucho más rápida, ya que entre otras cosas, no vuelve a compilar el código al generar las mutaciones, sino que introduce modificaciones a nivel de código ya compilado y optimiza el proceso con otras estrategias.
De hecho voy a intentar introducir esa métrica y esta técnica en alguna de las empresas en las que estoy en 233 Grados de TI, que vayan a desarrollar y mantener pruebas unitarias.
Terminando…
Para concluir, me gustaría que te quedaras con estas dos frases, para que tengas claro para qué sirve la métrica de cobertura de pruebas y qué la diferencia con la técnica de “mutation testing”.
La cobertura de pruebas resalta código que no se ha testeado. Por otra parte, el “mutation testing” nos indica qué código realmente se ha testeado.
¡Conozcamos ambas métricas y usémoslas con cabeza para mejorar la calidad de nuestro software!
- Debes crear apps sin saber programar (no hay que saber nada) + Crea Test con IA + Scrum es el nuevo Excel - 12 septiembre, 2024
- Las 6 técnicas prompting + 1ª Ley del Manager Oscuro + Mantenlo sencillo, estúpido - 5 septiembre, 2024
- Guía de Métricas Ágiles (versión agosto 2024) - 22 agosto, 2024
Buenos días,
Quizas soy yo que estoy medio dormido pero… :
if ( i>= 200) System.out….
if ( i > 200) System.out
Para i = 201 se cumplirán las dos condiciones (al margen del comentario de https://www.javiergarzas.com/wp-content/uploads/2015/02/equivalent2.png )
Por otra parte:
En el método ‘shouldCountIntegerAboveHundred’ la ‘assention equals’ fallará, ya que en la iteración del método tested.methodA(101) (entiendo que es ésta la verdadera llamada y no la de tested.count=101 ) se cumplirá que count=2
Con el método ‘shouldNotCountIntegerBeLowHundred’ entiendo que la llamada también sería tested.methodA(99) en cuyo caso la ‘assertion’ también fallaria ya que el ‘new’ de la clase no está en un @BerforeMethod
No sé si es que necesito al igual un café (es viernes) 🙂
Un saludo a todos !!!
Interesante articulo! Aunque me surge una duda: en el caso que ya tengamos la capacidad de obtener la cobertura MCDCen las pruebas unitarias, crees que seguiria siendo util/necesario el mutation testing?
Hola Ana, muy buen artículo!
Te comparto algunas reflexiones también sobre cobertura de pruebas
http://blog.abstracta.com.uy/2013/11/la-automatizacion-ayuda-mejorar-la.html
Sobre mutation testing, vi de cerca el trabajo de Pedro Reales y Macario Polo, de la Universidad de Castilla-La Mancha, y realmente el problema siempre apunta a cómo hacer que sean más eficientes como para que no se esté una vida para poder obtener la métrica en un sistema de mediano porte.
Había leído sobre esa herramienta que nombras, pero como en el mismo sitio se habla de que puede llevar mucho tiempo ejecutarla me dije que es más de lo mismo…
En cuanto a la pregunta de Josep, creo que el muatation testing va más allá que cualquier nivel de cobertura sobre código, ya sea que consideremos branches, decisiones, etc. Según los operadores de mutación que se definan, podrías llegar a probar muchas más posibles fallas, o al menos asegurar que tenés una prueba que la descubre.
Nada quita la posibilidad de que igual hayan fallos, pero creo que es un nivel más alto, que tal vez se pueda aplicar a alguna parte muy muy crítica del sistema.
Da para seguir pensando, pero esperemos que se siga investigando como para obtener mejores herramientas.
Saludos!