Pruebas en Python

Para asegurar en la medida de lo posible el correcto funcionamiento y la calidad del software se suelen utilizar distintos tipos de pruebas, como pueden ser las pruebas unitarias, las pruebas de integración, o las pruebas de regresión.

A lo largo de este capítulo nos centraremos en las pruebas unitarias, mediante las que se comprueba el correcto funcionamiento de las unidades lógicas en las que se divide el programa, sin tener en cuenta la interrelación con otras unidades.

La solución más extendida para las pruebas unitarias en el mundo Python es unittest, a menudo combinado con doctest para pruebas más sencillas. Ambos módulos están incluídos en la librería estándar de Python.

Doctest

Como es de suponer por el nombre del módulo, doctest permite combinar las pruebas con la documentación. Esta idea de utilizar las pruebas unitarias para probar el código y también a modo de documentación permite realizar pruebas de forma muy sencilla, propicia el que las pruebas se mantengan actualizadas, y sirve a modo de ejemplo de uso del código y como ayuda para entender su propósito.

Cuando doctest encuentra una línea en la documentación que comienza con ‘>>>‘ se asume que lo que le sigue es código Python a ejecutar, y que la respuesta esperada se encuentra en la línea o líneas siguientes, sin >>>. El texto de la prueba termina cuando se encuentra una línea en blanco, o cuando se llega al final de la cadena de documentación.

Tomemos como ejemplo la siguiente función, que devuelve una lista con los cuadrados de todos los números que componen la lista pasada como parámetro:

def cuadrados(lista):
    """Calcula el cuadrado de los numeros de una lista"""

    return [n ** 2 for n in lista]

Podríamos crear una prueba como la siguiente, en la que comprobamos que el resultado al pasar la lista [0, 1, 2, 3] es el que esperábamos:

def cuadrados(lista):
    """Calcula el cuadrado de los numeros de una lista

    >>> l = [0, 1, 2, 3]
    >>> cuadrados(l)
    [0, 1, 4, 9]
    """

    return [n ** 2 for n in lista]

Lo que hacemos en este ejemplo es indicar a doctest que cree un lista l con valor [0, 1, 2, 3], que llame a continuación a la función cuadrados con l como argumento, y que compruebe que el resultado devuelto sea igual a [0, 1, 4, 9].

Para ejecutar las pruebas se utiliza la función testmod del módulo, a la que se le puede pasar opcionalmente el nombre de un módulo a evaluar (parámetro name). En el caso de que no se indique ningún argumento, como en este caso, se evalúa el módulo actual:

def cuadrados(lista):
    """Calcula el cuadrado de los numeros de una lista

    >>> l = [0, 1, 2, 3]
    >>> cuadrados(l)
    [0, 1, 4, 9]
    """

    return [n ** 2 for n in lista]


def _test():
    import doctest
    doctest.testmod()


if __name__ == "__main__":
    _test()

En el caso de que el código no pase alguna de las pruebas que hemos definido, doctest mostrará el resultado obtenido y el resultado esperado. En caso contrario, si todo es correcto, no se mostrará ningún mensaje, a menos que añadamos la opción -v al llamar al script o el parámetro verbose=True a la función tesmod, en cuyo caso se mostrarán todas las pruebas ejecutadas, independientemente de si se ejecutaron con éxito.

Este sería el aspecto de la salida de doctest utilizando el parámetro -v:

Trying:
    l = [0, 1, 2, 3]
Expecting nothing
ok
Trying:
    cuadrados(l)
Expecting:
    [0, 1, 4, 9]
ok
2 items had no tests:
    __main__
    __main__._test
1 items passed all tests:
   2 tests in __main__.cuadrados
2 tests in 3 items.
2 passed and 0 failed.
Test passed.

Ahora vamos a introducir un error en el código de la función para ver el aspecto de un mensaje de error de doctest. Supongamos, por ejemplo, que hubieramos escrito un operador de multiplicación (‘*’) en lugar de uno de exponenciación (‘**’):

def cuadrados(lista):
    """Calcula el cuadrado de los numeros de una lista

    >>> l = [0, 1, 2, 3]
    >>> cuadrados(l)
    [0, 1, 4, 9]
    """

    return [n * 2 for n in lista]


def _test():
    import doctest
    doctest.testmod()


if __name__ == "__main__":
    _test()

Obtendríamos algo parecido a esto:

**********************************************************************
File "ejemplo.py", line 5, in __main__.cuadrados
Failed example:
    cuadrados(l)
Expected:
    [0, 1, 4, 9]
Got:
    [0, 2, 4, 6]
**********************************************************************
1 items had failures:
   1 of   2 in __main__.cuadrados
***Test Failed*** 1 failures.

Como vemos, el mensaje nos indica que ha fallado la prueba de la línea 5, al llamar a cuadrados(l), cuyo resultado debería ser [0, 1, 4, 9], y sin embargo obtuvimos [0, 2, 4, 6].

Veamos por último cómo utilizar sentencias anidadas para hacer cosas un poco más complicadas con doctest. En el ejemplo siguiente nuestra función calcula el cuadrado de un único número pasado como parámetro, y diseñamos una prueba que compruebe que el resultado es el adecuado para varias llamadas con distintos valores. Las sentencias anidadas comienzan con “...” en lugar de “>>>“:

def cuadrado(num):
    """Calcula el cuadrado de un numero.

    >>> l = [0, 1, 2, 3]
    >>> for n in l:
    ...     cuadrado(n)
    [0, 1, 4, 9]
    """

    return num ** 2


def _test():
    import doctest
    doctest.testmod()


if __name__ == "__main__":
    _test()

unittest / PyUnit

unittest, también llamado PyUnit, forma parte de una familia de herramientas conocida colectivamente como xUnit, un conjunto de frameworks basados en el software SUnit para Smalltalk, creado por Kent Beck, uno de los padres de la eXtreme Programming. Otros ejemplos de herramientas que forman parte de esta familia son JUnit para Java, creada por el propio Kent Beck junto a Erich Gamma, o NUnit, para .NET.

El uso de unittest es muy sencillo. Para cada grupo de pruebas tenemos que crear una clase que herede de unittest.TestCase, y añadir una serie de métodos que comiencen con test, que serán cada una de las pruebas que queremos ejecutar dentro de esa batería de pruebas.

Para ejecutar las pruebas, basta llamar a la función main() del módulo, con lo que se ejecutarán todos los métodos cuyo nombre comience con test, en orden alfanumérico. Al ejecutar cada una de las pruebas el resultado puede ser:

  • OK: La prueba ha pasado con éxito.
  • FAIL: La prueba no ha pasado con éxito. Se lanza una excepción AssertionError para indicarlo.
  • ERROR: Al ejecutar la prueba se lanzó una excepción distinta de AssertionError

En el siguiente ejemplo, dado que el método que modela nuestra prueba no lanza ninguna excepción, la prueba pasaría con éxito.

import unittest

class EjemploPruebas(unittest.TestCase):
    def test(self):
        pass

if __name__ == "__main__":
    unittest.main()

En este otro, sin embargo, fallaría:

import unittest

class EjemploPruebas(unittest.TestCase):
    def test(self):
        raise AssertionError()

if __name__ == "__main__":
    unittest.main()

Nada nos impide utilizar cláusulas if para evaluar las condiciones que nos interesen y lanzar una excepción de tipo AssertionError cuando no sea así, pero la clase TestCase cuenta con varios métodos que nos pueden facilitar la tarea de realizar comprobaciones sencillas. Son los siguientes:

  • assertAlmostEqual(first, second, places=7, msg=None): Comprueba que los objetos pasados como parámetros sean iguales hasta el séptimo decimal (o el número de decimales indicado por places).
  • assertEqual(first, second, msg=None): Comprueba que los objetos pasados como parámetros sean iguales.
  • assertFalse(expr, msg=None): Comprueba que la expresión sea falsa.
  • assertNotAlmostEqual(first, second, places=7, msg=None): Comprueba que los objetos pasados como parámetros no sean iguales hasta el séptimo decimal (o hasta el número de decimales indicado por places).
  • assertNotEqual(first, second, msg=None): Comprueba que los objetos pasados como parámetros no sean iguales.
  • assertRaises(excClass, callableObj, *args, **kwargs): Comprueba que al llamar al objeto callableObj con los parámetros definidos por *args y **kwargs se lanza una excepción de tipo excClass.
  • assertTrue(expr, msg=None): Comprueba que la expresión sea cierta.
  • assert_(expr, msg=None): Comprueba que la expresión sea cierta.
  • fail(msg=None): Falla inmediatamente.
  • failIf(expr, msg=None): Falla si la expresión es cierta.
  • failIfAlmostEqual(first, second, places=7, msg=None): Falla si los objetos pasados como parámetros son iguales hasta el séptimo decimal (o hasta el número de decimales indicado por places).
  • failIfEqual(first, second, msg=None): Falla si los objetos pasados como parámetros son iguales.
  • failUnless(expr, msg=None): Falla a menos que la expresión sea cierta.
  • failUnlessAlmostEqual(first, second, places=7, msg=None): Falla a menos que los objetos pasados como parámetros sean iguales hasta el séptimo decimal (o hasta el número de decimales indicado por places).
  • failUnlessEqual(first, second, msg=None): Falla a menos que los objetos pasados como parámetros sean iguales.
  • failUnlessRaises(excClass, callableObj, *args, **kwargs): Falla a menos que al llamar al objeto callableObj con los parámetros definidos por *args y **kwargs se lance una excepción de tipo excClass.

Como vemos todos los métodos cuentan con un parámetro opcional msg con un mensaje a mostrar cuando dicha comprobación falle.

Retomemos nuestra pequeña función para calcular el cuadrado de un número. Para probar el funcionamiento de la función podríamos hacer, por ejemplo, algo así:

import unittest

def cuadrado(num):
    """Calcula el cuadrado de un numero."""

    return num ** 2


class EjemploPruebas(unittest.TestCase):
    def test(self):
        l = [0, 1, 2, 3]
        r = [cuadrado(n) for n in l]
        self.assertEqual(r, [0, 1, 4, 9])

if __name__ == "__main__":
    unittest.main()

Preparación del contexto

En ocasiones es necesario preparar el entorno en el que queremos que se ejecuten las pruebas. Por ejemplo, puede ser necesario introducir unos valores por defecto en una base de datos, crear una conexión con una máquina, crear algún archivo, etc. Esto es lo que se conoce en el mundo de xUnit como test fixture.

La clase TestCase proporciona un par de métodos que podemos sobreescribir para construir y desconstruir el entorno y que se ejecutan antes y después de las pruebas definidas en esa clase. Estos métodos son setUp() y tearDown().

class EjemploFixture(unittest.TestCase):
    def setUp(self):
        print "Preparando contexto"
        self.lista = [0, 1, 2, 3]

    def test(self):
        print "Ejecutando prueba"
        r = [cuadrado(n) for n in self.lista]
        self.assertEqual(r, [0, 1, 4, 9])

    def tearDown(self):
        print "Desconstruyendo contexto"
        del self.lista


Comentarios
  1. Es genial cómo te estás currando los tutoriales sobre Python.

    Todavía nunca he usado pruebas unitarias en ningún proyecto y la verdad que no me imagino como se pueden hacer sin gastar mucho tiempo. Normalmente en los tutoriales se ponen casos facilitos pero en otras funciones más complejas, no me lo imagino.

    Responder

  2. Buenas Raul, me acabo de enganchar a tu genial tutorial de python, pero tengo problemas de dependencias y no consigo instalar pydev en Ubuntu Hardy Heron.
    ¿tú lo utilizas?
    un saludo crack

    Responder

  3. Que tal, apenas estoy probando tu tutorial, me parece un trabajo bien hecho, y sobre todo que lo distribuyes gratuitamente, bien por ti, que si hago algo gracias a ese tutorial será Open Source al 100%.

    Gracias por tu colaboración.

    Responder

  4. @zinitri yo suelo utilizar Wing IDE Professional. Puedes pedir una licencia gratis si has escrito algún programa open source 🙂

    Responder

  5. gracias artista, me he descargado el Wing IDE 101 que es para principiantes, porque aún no he hecho nada open source
    salu2

    Responder

  6. eneida

    Probando…

    Bien por la gente como tú que nos ayuda a utilizar las cosas que otros mal-documentan.

    Buen trabajo y GRACIAS!

    Responder

  7. Patri

    que bien muchas gracias por el tutorial de unitest ^^ Me has ahorrado comeduras de coco. Con lo bien explicado que está. =)

    Responder

Deja un comentario