21 de diciembre de 2018

A las pruebas me remito

Siempre que escribimos un programa computacional necesitamos verificar que efectivamente funciona tal como se espera. Usualmente ejecutamos el código, si es necesario proporcionamos algunas entradas, y finalmente observamos si la salida obtenida es la que deseamos. De no ser así, hay que revisar el programa, hacer las correcciones necesarias, y repetir todo el proceso anterior. La acción de probar y verificar que un programa genera los resultados esperados usualmente se hace manualmente y no de manera automática. Esto puede resultar tedioso y consumir mucho tiempo cuando el software siendo escrito va más allá de una aplicación relativamente pequeña.

Image result for evidence
Fuente: www.thecaselawfirm.com

Afortunadamente, existen las pruebas unitarias automatizadas, las cuales son porciones de código diseñadas para comprobar que el código principal está funcionando de la manera esperada.

Veamos un ejemplo. Supongamos que deseamos escribir en Python una función que calcule la hipotenusa c de un triángulo rectángulo dados sus catetos a y b:


El teorema de Pitágoras establece que el cuadrado de la hipotenusa es igual a la suma de los cuadrados de las catetos respectivos, es decir:


En Python la función hipotenusa podría quedar codificada de la siguiente manera:
from math import sqrt

def hipotenusa(a, b):
    return sqrt(a ** 2 + b ** 2)
NOTA: Todo el código que aquí se presenta fue probado con Python 3.7.

El siguiente código pudiera considerarse una prueba unitaria encargada de verificar que la función hipotenusa devuelva el resultado esperado a partir de las entradas 3 y 4:
if hipotenusa(3.0, 4.0) == 5.0:
    print('Pasó la prueba')
else:
    print('Falló la prueba')
Sin embargo, escribir pruebas similares al ejemplo anterior no es recomendable dado que requiere teclear mucho código cuando deseamos elaborar múltiples pruebas y además no es la manera convencional de hacerlo.

Fuente: searchengineland.com

La distribución estándar de Python provee dos mecanismos que simplifican la escritura y automatización de nuestras pruebas unitarias: unittest y doctest. La primera opción es un módulo inspirado en los frameworks de pruebas unitarias desarrollados por Erich Gamma y Kent Beck para los lenguajes Java y Smalltalk. La segunda opción, doctest, generalmente se considera más sencilla de usar que unittest, aunque esta última puede ser más adecuada para pruebas más complejas.

A continuación realizaré una breve introducción al módulo de doctest con el fin de elaborar pruebas unitarias al momento de definir funciones en Python.

El doctest va dentro del docstring

Un docstring es una cadena de caracteres que se coloca como primer enunciado de un módulo, clase, método o función, con el fin de explicar su intención. Esto lo expliqué hace algunos años con más lujo de detalle en la entrada de este blog titulada Documentando programas en Python.

La función hipotenusa, definida anteriormente, podría tener el docstring que se muestra aquí:
from math import sqrt

def hipotenusa(a, b):
    '''Calcula la hipotenusa de un triángulo rectángulo.

    Utiliza el teorema de Pitágoras para determinar la
    hipotenusa a partir de los catetos de un triángulo.

    Parámetros:
    a -- primer cateto
    b -- segundo cateto

    '''
    return sqrt(a ** 2 + b ** 2)
Aquí el docstring es una cadena de caracteres multi-líneas, la cual comienza y termina con triples comillas sencillas (''') o dobles (""").

El módulo doctest busca en los docstrings fragmentos de texto que parezcan sesiones interactivas de Python con el fin de ejecutarlos y verificar que funcionan exactamente como se muestran.

Por ejemplo, la siguiente sesión interactiva de Python muestra la manera en que se comporta la función hipotenusa con diferentes argumentos:
>>> hipotenusa(3.0, 4.0)
5.0
>>> hipotenusa(0.0, 0.0)
0.0
>>> hipotenusa(8.0, 15.0)
17.0
>>> hipotenusa(39.0, 80.0)
89.0
>>> round(hipotenusa(1.0, 1.0), 4)
1.4142
NOTA: En el último caso se usó la función round para redondear el resultado a 4 cifras después del punto decimal. Se recomienda hacerlo de esta manera para evitar los problemas de precisión que surgen cuando aparecen números reales con una parte fraccionaria compuesta de muchas cifras.

El texto completo de esta sesión interactiva, incluyendo los indicadores (prompts) del intérprete (>>>), se coloca dentro del doctring, típicamente al final, aunque en realidad puede ir donde sea:
from math import sqrt

def hipotenusa(a, b):
    '''Calcula la hipotenusa de un triángulo rectángulo.

    Utiliza el teorema de Pitágoras para determinar la
    hipotenusa a partir de los catetos de un triángulo.

    Parámetros:
    a -- primer cateto
    b -- segundo cateto
    
    Ejemplos de uso:
    
    >>> hipotenusa(3.0, 4.0)
    5.0
    >>> hipotenusa(0.0, 0.0)
    0.0
    >>> hipotenusa(8.0, 15.0)
    17.0
    >>> hipotenusa(39.0, 80.0)
    89.0
    >>> round(hipotenusa(1.0, 1.0), 4)
    1.4142

    '''
    return sqrt(a ** 2 + b ** 2)

Corriendo las pruebas

Existen dos opciones para correr las pruebas. La primera opción consiste en usar la terminal del sistema y ejecutar un comando similar al siguiente  (suponiendo que el archivo que contiene nuestro código se llama pitagoras.py):
python3 -m doctest pitagoras.py
La opción -m doctest le dice al intérprete de Python que ejecute el módulo doctest. El comando no produce salida alguna en caso de pasar todas las pruebas.

Si alguna prueba llega a fallar, entonces se verá un mensaje con la información pertinente. Por ejemplo, si agregamos al doctring el siguiente caso, el cual tiene un resultado incorrecto:
>>> hipotenusa(2.0, 2.0)
2.0
al correr nuevamente las pruebas obtenemos un mensaje similar al siguiente:
************************************************************
File "pitagoras.py", line 25, in 
pitagoras.hipotenusa
Failed example:
    hipotenusa(2.0, 2.0)
Expected:
    2.0
Got:
    2.8284271247461903
************************************************************
1 items had failures:
   1 of   6 in pitagoras.hipotenusa
***Test Failed*** 1 failures.
Se puede añadir también la opción -v (habilitar modo verboso o detallado) en la línea de comando al momento de correr nuestras pruebas. En este caso se produce un reporte completo con la información de todas las pruebas, hayan sido exitosas o no:
python3 -m doctest pitagoras.py -v
La salida en este caso sería:
Trying:
    hipotenusa(3.0, 4.0)
Expecting:
    5.0
ok
Trying:
    hipotenusa(0.0, 0.0)
Expecting:
    0.0
ok
Trying:
    hipotenusa(8.0, 15.0)
Expecting:
    17.0
ok
Trying:
    hipotenusa(39.0, 80.0)
Expecting:
    89.0
ok
Trying:
    round(hipotenusa(1.0, 1.0), 4)
Expecting:
    1.4142
ok
Trying:
    hipotenusa(2.0, 2.0)
Expecting:
    2.0
************************************************************
File "pitagoras.py", line 25, in 
pitagoras.hipotenusa
Failed example:
    hipotenusa(2.0, 2.0)
Expected:
    2.0
Got:
    2.8284271247461903
1 items had no tests:
    pitagoras
************************************************************
1 items had failures:
   1 of   6 in pitagoras.hipotenusa
6 tests in 2 items.
5 passed and 1 failed.
***Test Failed*** 1 failures.
La segunda opción para correr las pruebas consiste en agregar las siguientes tres líneas de código al final del archivo pitagoras.py:
if __name__ == '__main__':
    import doctest
    doctest.testmod()
La instrucción if anterior revisa si el archivo actual se está ejecutando como un programa (cuando la variable __name__ es igual a la cadena '__main__') o si se importó como un módulo (cuando la variable __name__ es igual al nombre del módulo, en este caso la cadena 'pitagoras'). Solo deseamos llevar a cabo las pruebas cuando el archivo se corre como un programa y, cuando es así, realizamos la importación del módulo doctest seguido de la invocación de su función testmod.

Después de lo anterior podemos correr nuestro archivo como nos resulte más conveniente (por ejemplo, desde la terminal o usando nuestro ambiente de desarrollo predilecto). Si es desde la terminal, basta teclear el siguiente comando:
python3 pitagoras.py
Tal como vimos anteriormente, si no se produce salida alguna quiere decir que se pasaron todas las pruebas.

Si queremos ver todo el reporte de pruebas exitosas y fallidas podemos añadir nuevamente la opción -v en el comando de la terminal:
python3 pitagoras.py -v
Alternativamente, el mismo efecto se puede lograr modificando el código para añadir el parámetro opcional verbose con valor de verdadero (True) al momento de invocar la función testmod:
    doctest.testmod(verbose=True)
Ahora, cada vez que el archivo se corra como un programa, sin importar si se utilizó o no la opción -v desde la terminal, se obtendrá el reporte completo con el resultado de todas las pruebas.

Con todas las adecuaciones mencionadas, el archivo pitagoras.py queda en su versión final así:
from math import sqrt

def hipotenusa(a, b):
    '''Calcula la hipotenusa de un triángulo rectángulo.

    Utiliza el teorema de Pitágoras para determinar la
    hipotenusa a partir de los catetos de un triángulo.

    Parámetros:
    a -- primer cateto
    b -- segundo cateto
    
    Ejemplos de uso:
    
    >>> hipotenusa(3.0, 4.0)
    5.0
    >>> hipotenusa(0.0, 0.0)
    0.0
    >>> hipotenusa(8.0, 15.0)
    17.0
    >>> hipotenusa(39.0, 80.0)
    89.0
    >>> round(hipotenusa(1.0, 1.0), 4)
    1.4142
    >>> hipotenusa(2.0, 2.0)
    2.0

    '''
    return sqrt(a ** 2 + b ** 2)

if __name__ == '__main__':
    import doctest
    doctest.testmod(verbose=True)
Este versión del programa se puede correr desde la terminal, tal como ya se explicó, o desde cualquier editor o ambiente de desarrollo para Python (IDLE, Spyder, Visual Studio Code, etc.) utilizando los respectivos mecanismos disponibles para ejecutar código.

Mejores prácticas

Estas son algunas prácticas recomendadas al momento de escribir pruebas unitarias usando doctest:
  • Deben ser lógicamente lo más simples que se pueda.
  • Deben requerir muy poco tiempo para ejecutarse, de preferencia solo unos cuantos milisegundos. Si las pruebas toman mucho tiempo la gente optará por no correrlas.
  • Deben procurar abarcar todos los posibles caminos de ejecución del código. Por ejemplo, para cada instrucción if debe haber al menos un caso para probar cuando la condición es verdadera y otro caso para cuando la condición es falsa.
  • Todos las funciones, módulos, clases y métodos deben tener pruebas unitarias apropiadas.
  • Incluir casos de prueba para las situaciones de error en donde se lancen excepciones.
Veamos otro ejemplo para demostrar el último punto de arriba. Definamos la función reciproco, la cual obtiene el inverso multiplicativo de su argumento:
def reciproco(x):
    return 1 / x
Una sesión interactiva de Python utilizando esta función se muestra a continuación:
>>> reciproco(2)
0.5
>>> reciproco(4)
0.25
>>> reciproco(1)
1.0
>>> reciproco(0)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "otro_ejemplo.py", line 2, in reciproco
    return 1 / x
ZeroDivisionError: division by zero
La última expresión genera una excepción debido a que Python no permite dividir entre cero.

A partir de la salida de arriba podemos escribir el doctest para la función reciproco. Del mensaje de error solo es necesario conservar dos líneas: la primera (la que comienza con Traceback) y la última (la que contiene el nombre de la excepción y la explicación del error). Por claridad se recomienda eliminar las demás líneas del stack trace.

El docstring completo dentro de su función quedaría así:
def reciproco(x):
    '''Calcula el inverso multiplicativo de x.

    Se cumple que: x * reciproco(x) == 1.0

    Ejemplo de uso:

    >>> reciproco(2)
    0.5
    >>> reciproco(4)
    0.25
    >>> reciproco(1)
    1.0
    >>> reciproco(0)
    Traceback (most recent call last):
    ZeroDivisionError: division by zero
    
    '''
    return 1 / x
Para correr estas pruebas podemos usar cualquiera de las opciones explicadas anteriormente.

Fuente: www.iconarchive.com

Reflexiones finales

¿Qué beneficios ofrece la automatización de las pruebas unitarias? Según quintagroup, algunos ventajas son:
  • Detección temprana de problemas. Las pruebas unitarias permiten encontrar y eliminar errores de manera rápida y oportuna. Es bien sabido que un defecto de software es menos costoso entre más pronto se detecte y elimine.
  • Mitigación del cambio. Se tiene una certeza razonable de que nuestro programa funciona de cierta manera aún después de hacerle modificaciones, ya sea para corregir errores o añadir funcionalidad nueva.
  • Simplificación de la integración. La integración de los diferentes componentes de una aplicación es más sencilla si primero se prueban éstos por separado. 
  • Mejor documentación. Un programador que desea aprender qué funcionalidad proporciona una unidad y cómo usarla puede revisar las pruebas correspondientes para obtener una comprensión básica de su API.
No es un secreto que una clave para desarrollar software de calidad es ir probando el código a medida que se va escribiendo. De hecho, se recomienda primero escribir las prueba antes que el código de la aplicación. A esto se le conoce como desarrollo guiado por pruebas o TDD por sus siglas en inglés. Esta práctica exige que el programador piense más detenidamente sobre el problema a resolver y el diseño que requiere la solución.

Fuente: openclipart.org

Por último, es importante mencionar que la principal limitación de las pruebas unitarias es que no se pueden utilizar para detectar todos los errores en un programa, ya que es imposible evaluar todas sus rutas de ejecución para todos los posibles valores que pueden tomar las variables involucradas, salvo quizás en casos muy triviales. Las pruebas unitarias solo pueden mostrar la presencia o ausencia de errores particulares.

En resumen, las pruebas unitarias son una buena herramienta para facilitar el desarrollo de software de calidad, pero definitivamente no son una bala de plata.

Fuente: magazine.joomla.org

2 comentarios:

  1. Excelente entrada, me agrada la cultura de la documentación en los programas, sobretodo en métodos complejos para poder tener una mejor perspectiva de la lógica que implica cada uno de ellos, también estoy a favor de la automatización de pruebas, sin embargo, he notado que muchos desarrolladores en la industria son algo perezosos para realizar estas buenas prácticas para la correcta documentación y testeo de código, por lo que solamente nos queda a los buenos programadores tratar de lidiar con este problema para inculcar con las buenas prácticas de programación. Interesante artículo.

    ResponderEliminar
    Respuestas
    1. Gracias por tu comentario, Irvin. Concuerdo totalmente con lo que dices. Es necesario guiar a nuestros alumnos y colegas procurando ser un buen ejemplo a imitar.

      Eliminar