Pruebas que te entierran vivo. Un caso práctico
Las pruebas y el diseño desacoplado que obtenemos con TDD, facilita la refactorización y cambiar con la seguridad de que las pruebas nos avisarán si nos equivocamos.
Sin embargo, mal aplicadas, las pruebas se convierten en palos en las ruedas que impiden el cambio. Un ejemplo de este tipo de pruebas le he encontrado en el proyecto PyTDDMon. A continuación relato mi experiencia.
Entendiendo PyTDDMon
Esta herramienta se encarga de monitorizar un sistema de ficheros y ejecutar automáticamente todas las pruebas que encuentre en dicho sistema cada vez que detecta un cambio en un fichero. Los resultados los muestra en una pequeña ventana como la que se muestra a continuación.
PyTDDMon está compuesto por un único módulo, 7 clases (3 de ellas dedicadas a la interfaz gráfica), 13 funciones de módulo y 33 pruebas unitarias. La cobertura de las pruebas es del 34% (calculada con nosetest y coverage 3.7).
La complejidad ciclomática (o CC)es baja. La mayoría de funciones y métodos tienen una CC de 1 con algunas excepciones como las que se muestran a continuación.
Fallo de compatibilidad en Python y metedura de pata
Cuando se ejecuta PyTDDMon con la opción ---gen-kata=Nombre, generar un archivo con dos casos de prueba, siempre los mismos. El código de estos casos de prueba está guardado en una gran variable de texto.
Sin embargo, la prueba test_another_thing pasa o falla dependiendo de si se ejecuta en Python 2.x o 3.x ya que la función range cambió a partir de la versión 3.x
En las versiones 2.x de Python, range devuelve una lista con los elementos indicados incluyendo el primero pero sin incluir el último. Pero en las versiones 3.x, range devuelve una función generadora que, para cada llamada, devuelve el siguiente elemento, de manera parecida a un iterador en Java.
Esto hace que la prueba test_another_thing falle, ya que se está comparando un tipo lista con un tipo función.
Una manera sencilla de arreglarlo (no la única) es utilizar un list comprehension como se muestra a continuación. Con este cambio, las pruebas funcionan tanto en Python 2.x como en 3.x
Inocente de mí, hice un pull request para añadir este cambio a PyTDDMon. El resultado ya os lo imaginas: rechazado porque las pruebas fallaban.
De rojo a verde
El código de las pruebas que genera PyTDDMon está guardado en un atributo de la clase Kata.
Repasando las pruebas veo que hay un módulo test_genkata con pruebas para verificar la clase Kata. Esta clase es la encargada de generar el nombre y el contenido del fichero con las pruebas que hemos visto en la sección anterior. Algunas de estas pruebas se muestran a continuación.
Estas pruebas solo verifican el contenido de la cadena de texto que contiene el código que vuelva en el fichero que genera. Normal que algunas pruebas fallaran al cambiar la cadena de texto.
La solución más rápida es repetir en test_contains_an_list_equality_assertion el cambio anterior y, ahora sí, todo funciona correctamente.
Enterrado vivo en mis pruebas
El código de test_genkata es un MAL ejemplo de cómo NO hacer pruebas. Estas pruebas no verifican funcionalidad ni el resultado del código. En cambio, estamos comprobando que un atributo tiene un valor, y como hemos visto, ese valor es incorrecto.
Estamos duplicado código (el horror, el horror), ya que todo lo que pongamos, quitemos, o cambiemos en el código que genera también tendremos que hacerlo en las pruebas.
Estas pruebas no son un seguro para cambiar cosas con tranquilidad. En cambio, son un sarcófago de cemento que construimos alrededor del código y que hacen que hasta un cambio en una coma o en un espacio en blanco haga las pruebas fallar.
Estas pruebas no aportan nada.
Testopia, mejora tus pruebas
¿Cómo podríamos escribir buenas pruebas para este caso? El objetivo del código es crear un fichero de texto con dos pruebas válidas. Una primera idea sería comprobar si el fichero se crea y su contenido es el correcto, pero eso nos lleva a las mismas pruebas que hemos visto antes, con código duplicado que dificulta el avance.
Démosle una segunda vuelta. El objetivo del fichero generado es que contenga dos pruebas que pasan con éxito. Esta puede ser nuestras pruebas.
Vamos a ejecutar el código, a cercar un fichero a ejecutar el contenido y a verificar que el resultado es que dos pruebas se ejecuten correctamente. Además, podemos escribir dos juegos de prueba, uno de ellos para que ejecute el código en un entorno Python 2.x y otro para que lo ejecute en un entorno Python 3.x. Un ejemplo de una prueba así se muestra a continuación.
Si las pruebas no se ejecutan con éxito, el valor devuelto por el segundo call será distinto de 0.
Por último, refactorizamos estas pruebas para ilustrar el objetivo de la prueba ocultando los detalles técnicos. Un posible resultado se muestra a continuación.
Si hubiéramos contado con estas pruebas desde el principio, enseguida habíamos descubierto el fallo al usar range
Conclusiones
Las nuevas pruebas que hemos escrito no son perfectas. Son frágiles porque dependen de la configuración del sistema y si esta configuración cambia, o las llevas a otro ordenador, pueden fallar.
Sin embargo son las únicas pruebas que a mí se me ocurren y que aportan valor. Recuerda que el código que estamos probando se limita a crear un fichero y volcar dentro un texto predefinido.
Estas pruebas sí nos facilitan el cambio. Si queremos añadir nuevas pruebas, o nuevos entornos, por ejemplo una nueva versión e Python, nuevas librerías de prueba, etc., no es necesario modificar las pruebas ni reproducir en el código de prueba los cambios que hagamos en nuestro código de producción.
Bolaextra
Uno de los mejores pythonistas españoles (@Pybonacci en twitter y coordinando un estupendo blog http://pybonacci.wordpress.com/), también director del blog del mismo nombre) nos comenta que, otra alternativa para cambiar la prueba y que funcione en ambas ramas de Python es utilizar list de la siguiente manera: list(range[1, 3]). Además esta alternativa puede ser hasta el doble de rápida que utilizar una list comprehension
















