Desarrollo Guiado por Pruebas (TDD) en Python

Un requisito implícito en el trabajo de todo programador es el de producir código confiable y seguro. Esto implica que siempre tendremos que probar nuestro código, de una forma u otra, para asegurarnos de que cumple con las especificaciones y de que en realidad hace lo que se supone que debe hacer. Por esta razón, la realización de pruebas al código ha formado parte del desarrollo de software desde sus inicios. Sin embargo, no fue hasta 1998, cuando el gurú de Smalltalk, Kent Beck, publicara su trabajo “Guide to Better Smalltalk”, donde presentaba un framework para automatización de pruebas llamado SUnit; que la comunidad de programadores vivió un despertar en esta rama de la programación y se generó toda una avalancha de frameworks para automatización de pruebas, incluidos JUnit (para Java), PyUnit (para Python) y muchos otros en dependencia del lenguaje y de la plataforma, todo lo cual devino en el “Movimiento xUnit”.

En la actualidad, la automatización de pruebas constituye uno de los pilares de las llamadas metodologías de desarrollo ágil y ha ido evolucionando hasta conformar lo que se ha dado en llamar “Desarrollo Guiado por Pruebas” (TDD por sus siglas en inglés).

Niveles de Pruebas

A lo largo de los años, la construcción de pruebas ha ido incrementando su alcance y han aflorado diferentes niveles de prueba, entre ellos tenemos:

  • Pruebas unitarias (Unit testing): son pruebas que se realizan a la menor unidad de código posible. En muchos casos estas unidades de código son las propias funciones y/o métodos, que constituyen fragmentos indivisibles de código

  • Pruebas de integración (Integration testing): estas pruebas están encaminadas a comprobar las interacciones entre unidades de código estrechamente relacionadas

  • Pruebas de sistema (System testing): están destinadas a chequear el funcionamiento del sistema o de parte de este, una vez que sus componente han sido puestos en su lugar. Son pruebas que necesitan el respaldo de las pruebas unitaria y de integración

  • Pruebas de aceptación (Acceptance testing): pruebas escritas para confirmar que el programa hace lo que se espera que haga. Estas pruebas pueden ser escritas en cualquiera de los niveles anteriores y constituyen la forma de asegurarnos de que el programa que estamos creando es realmente el que se nos ha especificado o solicitado

  • Pruebas de regresión (Regression testing): estamos en presencia de una regresión cuando una parte de nuestro código que funcionaba correctamente, deja de hacerlo. Las regresiones generalmente son el resultado de las modificaciones al código relacionado con la porción de código que ahora nos da problemas. Las pruebas dirigidas a detectar este tipo de problema se denominan pruebas de regresión

  • Desarrollo Guiado por Pruebas (Test-Driven Development): cuando combinamos todos los elementos anteriores llegamos al Desarrollo Guiado por Pruebas. Veamos con más detalle en qué consiste este.

Desarrollo Guiado por Pruebas

El TDD es una práctica de ingeniería de software que involucra otras dos prácticas: Escribir las pruebas primero (Test First Development) y Refactorización (Refactoring). El propósito del Desarrollo Guiado por Pruebas es lograr un código limpio, confiable y que funcione correctamete. La idea subyacente es que los requisitos sean traducidos a pruebas, de este modo, cuando las pruebas pasen o sean satisfechas, se garantizará que el software cumple con los requisitos que se han establecido.

Si acogemos esta disciplina como estrategia de desarrollo, las pruebas serán el timón que guiará todo el proceso, es decir, escribiremos primero las pruebas y luego desarrollaremos el código estrictamente necesario para hacer que estas pruebas pasen.

Las Tres Leyes del TDD

  1. Debes escribir una prueba que falle, antes de que escribas algún código de producción

  2. No debes escribir más de una prueba que sea suficiente para fallar o para fallar en compilar

  3. No debes escribir más código de producción que el estrictamente necesario para hacer pasar la prueba fallida.

Ventajas del TDD

  • Los requerimientos de código quedan cumplidos al satisfacer todas las pruebas

  • Garantiza código confiable y que funciona correctamente

  • Para escribir las pruebas se necesita estudiar más a fondo el problema de programación, por lo que se logra un mejor entendimiento del mismo y las soluciones obtenidas probablemente serán de más calidad

  • Las pruebas constituyen parte importante de la documentación del código y facilitan la comprensión del mismo a clientes o colaboradores potenciales

  • Agiliza el proceso de desarrollo e incrementa la productividad a largo plazo de los desarrolladores

  • Evita la escritura de código innecesario (funcionalidades no requeridas)

  • Facilita el proceso de depuración con una detección temprana de errores y con información detallada y precisa sobre la falla detectada

  • Minimiza las regresiones.

Desventajas del TDD

  • Los desarrolladores tienden a evitar la escritura de pruebas por considerarla trabajo extra o pérdida de tiempo

  • Conjunto de pruebas incompletas que no prevean todos los posibles estados de funcionamiento del código

  • Consumo excesivo de recursos y de tiempo de ejecución en baterías de pruebas muy complejas

  • Costos de escritura y mantenimiento/actualización de las pruebas.

Rojo, Verde, Refactorizar

En la práctica, para aplicar coherentemente el TDD, es recomendable tomar como guía el llamado ciclo: Rojo, Verde, Refactorizar (Red, Green, Refactor/RGR).

En primer lugar, se escriben las pruebas y se verifica que fallan (Etapa Rojo). A continuación, se desarrolla el código que hace que las pruebas pasen satisfactoriamente (Etapa Verde) y seguidamente se refactoriza el código escrito (Etapa Refactorizar).

Este modo de proceder se basa en la idea de que nuestras mentes no son capaces de perseguir dos metas de forma simultánea, es decir: 1. Comportamiento correcto y 2. Estructura correcta. Entonces el ciclo RGR nos dice que nos concentremos primeramente en hacer que el código funcione correctamente y solo después de lograr esto, hacer que este código ya funcional, tenga una estructura que le permita sobrevivir en el tiempo.

La idea fundamental detrás del ciclo RGR es: Hazlo funcionar. Hazlo correcto. Hazlo rápido; la cual se deriva directamente de los trabajos de Kent Beck.

Herramientas de Automatización de Pruebas para Python

Una vez que hemos decidido asumir la filosofía de TDD, resulta necesario contar con herramientas que nos permitan automatizar el proceso de correr o ejecutar las pruebas que hemos escrito. En Python existen excelentes herramientas para lograr este objetivo.

  • unittest: el módulo unittest es la versión para Python del framework de pruebas unitarias originalmente desarrollado por Kent Beck para Smalltalk. Este módulo está disponible en la librería estándar y soporta: automatización de pruebas, funciones setUp() y tearDown() con código a ejecutar antes y después de las pruebas (esta característica es llamada Fixtures), agregación de pruebas en colecciones (test suites) e independencia de pruebas. El módulo exporta la clase unittest.TestCase de la cual se deben derivar todas las clases que representan los casos de prueba, que a su vez pueden contener una o más pruebas unitarias. Exporta además, un conjunto de funciones assert predefinidas, que facilitan la escritura de las pruebas

  • doctest: este módulo también forma parte de la librería estándar de Python. Tiene el propósito primario de permitirnos crear ejemplos de cómo usar correctamente el código. doctest busca en nuestras docstrings fragmentos de código escritos imitando una sección interactiva del intérprete Python con una primera línea que comienza con >>>, seguido de una sentencia o expresión y una segunda línea con la salida esperada. De esta forma podemos incluir, tutoriales, ejemplos de uso correcto del código y, por supuesto, pruebas unitarias

  • Nose: como se puede leer en su documentación, Nose extiende a unittest para hacer las pruebas más fáciles. Esta herramienta es capás de detectar pruebas escritas en unittest y doctest y ejecutarlas. Además, soporta la escritura de pruebas en forma de funciones simple y de clases de prueba no derivadas de unittest.TestCase. Nose permite escribir pruebas temporizadas y pruebas de excepciones. Nose soporta Fixtures (funciones setup y teardown) a nivel de paquete, módulo, clase y prueba, y agrega alguna funciones assert nuevas, para agilizar la escritura de las pruebas. Nose no estás disponible en la librería estándar, lo que significa que para usarla debemos instalarla primero. Al instalar Nose, se instala también el script nosetests, que permite la detección y ejecución automática de las pruebas. Este script soporta el empleo de un archivo de configuración llamado nose.cfg o .noserc y situado en nuestro directorio de trabajo (home), donde podemos personalizar las opciones de ejecución de la corrida de pruebas. Nose se integra totalmente con el módulo de distribución setuptools, ya sea con el argumento test_suite='nose.collector' de la función setuptools.setup() o directamente con python3 setup.py nosetests

  • Pytest: es un framework que facilita la construcción de pruebas simples y escalables. Soporta la detección y ejecución automática de pruebas. Es capás de correr las pruebas escritas para Nose y para unittest. Al igual que Nose, permite la escritura de pruebas en forma de funciones simple y de clases de prueba independientes de unittest. Pytest, también soporta Fixtures, llevándolos a un nivel superior de personalización y flexibilidad con el empleo de decoradores. Pytest no forma parte de la librería estándar, por tanto, debemos instalarlo para poder emplearlo. Pytest provee el script pytest (en algunas implementaciones debe emplearse py.test), que facilita la detección y ejecución de pruebas y acepta varias opciones desde la línea de comandos. Por último, a diferencia de Nose, Pytest mantiene un desarrollo activo y constituye la mejor opción para automatización de pruebas disponible en la actualidad.

Pruebas simples con assert, un ejemplo práctico

En ocasiones nos encontramos desarrollando algún script simple para automatizar tareas repetitivas o para ejecutar algún trabajo de poca complejidad. Como todo nuestro código debe ser probado antes de liberarlo para producción, nos preguntamos qué herramienta emplear para automatizar las pruebas. Sin embargo, nos damos cuenta de que implementar toda la parafernalia necesaria para emplear alguna de las soluciones ya vistas, resulta excesivo, pues nuestro código de prueba terminaría siendo más complejo que el script que deseamos probar; es como usar un cañón para matar a un mosquito. En estos casos Python también viene en nuestro auxilio con la sentencia assert, que nos puede servir para implementar una simple, pero efectiva batería de pruebas. Veamos un ejemplo práctico para ilustrar mejor el punto.

Supongamos que deseamos implementar una función que reciba como entrada una secuencia (lista, tupa, cadena) de datos y que nos devuelva un secuencia del mismo tipo, pero con sus elementos ordenados ascendentemente, es decir, algo así como:

>>> sort_sequence([3, 2, 1])

[1, 2, 3]

>>> sort_sequence('dcba')

'abcd'

Siguiendo la filosofía del TDD y el ciclo RGR, debemos escribir primeramente las pruebas, que en el caso de los dos ejemplos anteriores podrían ser escritas como:

>>> assert sort_sequence([3, 2, 1]) == [1, 2, 3]

>>> assert sort_sequence('dcba') == 'abcd'

Luego deberíamos escribir el código estrictamente necesario para pasar estas pruebas, es decir, implementar la función sort_sequence(). En aras de ahorrar tiempo y espacio veamos el código completo de nuestro ejemplo con la adición de algunas pruebas más.

sort_seq.py

def sort_sequence(seq):

    if not seq: # Si recibe una secuencia vacía deberá retornar None

        return None

    result = list(seq)

    lenght = len(seq)

    for i in range(lenght): # Ordenamos los datos de forma ascendente

        for j in range(i, lenght):

            if result[i] > result[j]:

                result[i], result[j] = result[j], result[i]

    if isinstance(seq, str): # Si seq sea una cadena, deberá retornar una cadena

        return ''.join(result)

    return type(seq)(result) # Con listas y tuplas retornará el tipo adecuado

if __name__ == '__main__':

    assert sort_sequence([]) is None

    assert sort_sequence([3, 2, 1]) == [1, 2, 3]

    assert sort_sequence([31, 41, 59, 26, 41, 58]) == [26, 31, 41, 41, 58, 59]

    assert sort_sequence([0]) == [0]

    assert sort_sequence([1, -8, 10, -2, 0]) == [-8, -2, 0, 1, 10]

    assert sort_sequence((2, -1, 3, -8)) == (-8, -1, 2, 3)

    assert sort_sequence('dcba') == 'abcd'

Si ejecutamos este script en una terminal, de la forma:

$ python3 sort_seq.py

Nos daremos cuenta de que no se obtiene ninguna salida en pantalla, lo que significa que nuestra función sort_sequence() ha superado exitosamente todas las pruebas que hemos diseñado. Si la implementación de esta función no fuese correcta, obtendríamos un AssertError con información relacionada con el fallo detectado. De esta forma hemos empleado la sentencia assert para construir nuestra batería de pruebas de manera rápida y simple, pero efectiva.

Lecturas Recomendadas

Para profundizar en el estudio de estos temas recomendamos la lectura de los apartados dedicados a unittest y doctest en la documentación oficial de Python, así como la consulta de la documentación de Nose y Pytest. Recomendamos además, la lectura de libro: “Python Unit Test Automation” por Ashwin Pajankar, publicado por la Editorial “Apress”.

Muy bien, esto es todo por ahora, si este artículo te resultó interesante y/o útil, compártelo para que otros también puedan acceder a él. Déjanos tus comentarios y podremos mejorar nuestros contenidos.

Gracias de antemano,

lpozo