Prueba unitaria (Unit testing)



¿Qué es Unit testing?

Se trata de un método para determinar si un módulo o un conjunto de módulos de código funciona correctamente. El concepto de Unit testing no se limita a ningún lenguaje específico, sino que es una herramienta de la programación en general. Las pruebas unitarias se implementan a la par con el desarrollo de un módulo o proyecto, y se ejecutan cuando este último sufre modificaciones para garantizar su funcionamiento. Si bien el código mismo de la prueba unitaria puede contener errores, la clave está en la separación del código de un módulo de su respectiva prueba unitaria, de modo que puedan correr independientemente.

En otras palabras, es una forma de comprobar que un conjunto de funciones o clases (tantas como queramos) funcionan como esperamos. Lógicamente, las pruebas unitarias nunca pueden garantizar completamente el correcto funcionamiento de una porción de código. No obstante ello, serán capaces de detectar gran cantidad de anomalías y de ahorrarnos tiempo de depuración.

¿Cuándo debo utilizarlo?

Siempre que consideres necesario. Cuando desarrolles un módulo o un paquete que provee una API (conjunto de funciones y clases) probablemente quieras crear una prueba unitaria.

Si un proyecto de un tamaño mediano-grande se organiza en múltiples módulos, es una buena práctica crear unidades de prueba para cada uno de ellos.

¿Cómo implementarlo en Python?

Existen diversos frameworks para implementar pruebas unitarias en el lenguaje. En este artículo trataremos los dos que se incluyen en la librería estándar: unittest y doctest. El primero es un tanto arcaico y derivado de otros lenguajes, aunque no menos eficiente. El segundo es tal vez más pythonico. Veremos las características generales de ambos y quédate con el que te sientas más a gusto.

Para comenzar utilizaremos el siguiente módulo al que llamaré mymodule.py y que, por el momento, tendrá la siguiente función matemática.

#!/usr/bin/env python
# -*- coding: utf-8 -*-

def sum(a, b):
    for n in (a, b):
        if not isinstance(n, int) and not isinstance(n, float):
            raise TypeError
    return a + b

La función sum obtiene dos números (enteros o de coma flotante) y retorna el resultado de su suma. Si se ingresan valores que no sean del tipo comentado anteriormente, se lanza la excepción TypeError.

Prueba unitaria con unittest

Para la implementación de la prueba unitaria utilizando el módulo unittest, crearé un nuevo archivo llamado test_mymodule.py en el mismo directorio que el anterior.

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import unittest
import mymodule


class TestMyModule(unittest.TestCase):
    
    def test_sum(self):
        self.assertEqual(mymodule.sum(5, 7), 12)


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

¿Suena un tanto complicado? No te preocupes, veamos qué estamos haciendo.

En las primeras dos líneas importamos el módulo unittest necesario para crear las pruebas unitarias y a continuación el propio módulo que queremos probar.

Luego creamos la clase TestMyModule, una unidad de prueba que comprobará el comportamiento de nuestro módulo. Dentro de ésta creamos tantos métodos como funciones del módulo que queramos probar. Todos los métodos que comiencen con el nombre test serán ejecutados.

En nuestro caso, queremos comprobar que la función que creamos anteriormente, mymodule.sum, funciona correctamente. Para esto, utilizamos una operación básica: 5 + 7 es necesariamente 12. Si el resultado de mymodule.sum(5, 7) no es igual a 12 entonces nuestra función contiene algún error, y la prueba unitaria nos los hará saber.

Al llamar a la función assertEqual estamos indicando que el valor de retorno de mymodule.sum(5, 7) debe ser igual (equal) a 12.

La documentación provee una tabla con el resto de las funciones de unittest y su respectiva operación.

Función Operación equivalente
assertEqual(a, b) a == b
assertNotEqual(a, b) a != b
assertTrue(x) bool(x) is True
assertFalse(x) bool(x) is False
assertIs(a, b) a is b
assertIsNot(a, b) a is not b
assertIsNone(x) x is None
assertIsNotNone(x) x is not None
assertIn(a, b) a in b
assertNotIn(a, b) a not in b
assertIsInstance(a, b) isinstance(a, b)
assertNotIsInstance(a, b) not isinstance(a, b)

Ahora bien, si tu pregunta es ¿por qué no simplemente escribir la operación equivalente? Principalmente porque las funciones ofrecerán mayor detalle del error cuando el resultado no sea el esperado.

Retomando el código de ejemplo, por último llamamos a la función unittest.main para ejecutar la prueba unitaria. Llamamos a python test_mymodule.py y el resultado es el siguiente.

.
------------------------------------------------------
Ran 1 test in 0.000s
 
OK

El resultado indica que se corrió una prueba y no hubo ningún error. Haremos una pequeña modificación en la función original.

def sum(a, b):
    for n in (a, b):
        if not isinstance(n, int) and not isinstance(n, float):
            raise TypeError
    return a - b

Nótese return a - b. En este caso, de sum(5, 7) resultará -2, mientras que la prueba unitaria espera 12. Volvemos a correrla y obtenemos lo siguiente.

F
======================================================
FAIL: test_sum (__main__.TestMyModule)
------------------------------------------------------
Traceback (most recent call last):
  File "test_mymodule.py", line 11, in test_sum
    self.assertEqual(mymodule.sum(5, 7), 12)
AssertionError: -2 != 12
 
------------------------------------------------------
Ran 1 test in 0.000s
 
FAILED (failures=1)

El resultado indica cuántas pruebas fallaron y muestra dónde se produjo el error.

Además de la lista anterior de funciones, también se puede comprobar si nuestro método lanza una excepción en un caso determinado. Por ejemplo, no es posible sumar un número entero con una cadena, según nuestra función, en este caso debería lanzarse TypeError. Por ende, podríamos añadir la siguiente comprobación.

    def test_sum(self):
        self.assertEqual(mymodule.sum(5, 7), 12)
        self.assertRaises(TypeError, mymodule.sum, 5, "Python")

El primer argumento de asserRaises indica la excepción que se espera, el segundo la función que queremos probar y a continuación sus argumentos. También puede expresarse de la siguiente forma.

    def test_sum(self):
        self.assertEqual(mymodule.sum(5, 7), 12)
        with self.assertRaises(TypeError):
            mymodule.sum(5, "Python")

Resulta más agradable y legible.

Para finalizar con esta sección, añadimos a nuestro módulo de funciones matemáticas los siguientes métodos que desarrollamos en el artículo Obtener lista de números primos.

def get_prime_numbers(max_number):
    numbers = [True, True] + [True] * (max_number-1)
    last_prime_number = 2
    i = last_prime_number
    
    while last_prime_number**2 <= max_number:
        i += last_prime_number
        while i <= max_number:
            numbers[i] = False
            i += last_prime_number
        j = last_prime_number + 1
        while j < max_number:
            if numbers[j]:
                last_prime_number = j
                break
            j += 1
        i = last_prime_number
    
    return [i + 2 for i, not_crossed in enumerate(numbers[2:]) if not_crossed]


def is_prime(n):
    return n in get_prime_numbers(n)

Y en nuestro archivo de pruebas unitarias incluimos algunas comprobaciones.

    def test_get_prime_numbers(self):
        # Los números primos menores a 10 son necesariamente 2, 3, 5 y 7.
        self.assertEqual(mymodule.get_prime_numbers(10), [2, 3, 5, 7])
    
    def test_is_prime(self):
        # 5 es primo.
        self.assertTrue(mymodule.is_prime(5))
        # 6 no es primo.
        self.assertFalse(mymodule.is_prime(6))

Código completo:

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import unittest
import mymodule


class TestMyModule(unittest.TestCase):
    
    def test_get_prime_numbers(self):
        self.assertEqual(mymodule.get_prime_numbers(10), [2, 3, 5, 7])
    
    def test_is_prime(self):
        self.assertTrue(mymodule.is_prime(5))
        self.assertFalse(mymodule.is_prime(6))
    
    def test_sum(self):
        self.assertEqual(mymodule.sum(5, 7), 12)
        with self.assertRaises(TypeError):
            mymodule.sum(5, "Python")


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

Prueba unitaria con doctest

La implementación de la prueba unitaria utilizando doctest se realiza junto con la documentación de una función o clase. Por ejemplo, siguiendo con nuestra función original sum, añadiremos una breve descripción en la documentación.

def sum(a, b):
    """
    Retorna a + b.
    """
    for n in (a, b):
        if not isinstance(n, int) and not isinstance(n, float):
            raise TypeError
    return a + b

Debajo de la descripción agregamos el ejemplo que tratamos anteriormente.

def sum(a, b):
    """
    Retorna a + b.
    
    >>> sum(5, 7)
    12
    """

doctest ejecuta todas las operaciones que se encuentren luego de >>> y las compara con el resultado inmediatamente siguiente hasta otra operación o bien un espacio en blanco.

Esto resulta de gran comodidad ya que la documentación de nuestra función cumple un objetivo dual: ilustrar con un ejemplo y servir como prueba unitaria.

Para ejecutar doctest, añadiremos al final de nuestro módulo mymodule.py el siguiente fragmento.

if __name__ == "__main__":
    import doctest
    doctest.testmod(verbose=True)

De esta forma, cuando el módulo sea ejecutado (no así importado), doctest analizará la documentación de todas las funciones o clases del mismo y ejecutará las pruebas correspondientes. El resultado es el siguiente.

Trying:
    sum(5, 7)
Expecting:
    12
ok
1 items had no tests:
    __main__
1 items passed all tests:
    1 tests in __main__.sum
1 tests in 2 items.
1 passed and 0 failed.
Test passed.

Utilizando verbose=False, la prueba unitaria imprimirá en pantalla únicamente los mensajes de error.

Este tipo de prueba unitaria puede ser ejecutada desde la terminal, sin necesidad de incluir las últimas tres líneas de código que añadimos anteriormente.

python -m doctest -v mymodule.py

A través de la documentación de nuestra función también podemos indicar que se espera una excepción cuando intentamos sumar un entero y una cadena.

def sum(a, b):
    """
    Retorna a + b.
    
    >>> sum(5, 7)
    12
    >>> sum(5, "Python")
    Traceback (most recent call last):
        ...
    TypeError
    """

Dado que los mensajes de error contienen información que varía constantemente (nombre del archivo, número de línea, etc.), no es necesario indicarlo y en su lugar se colocan tres puntos.

Para finalizar completamos las otras dos funciones con su respectiva documentación y ejemplos.

def get_prime_numbers(max_number):
    """
    Retorna una lista de números primos.
    
    >>> get_prime_numbers(10)
    [2, 3, 5, 7]
    """

def is_prime(n):
    """
    Determina si n es primo.
    
    >>> is_prime(5)
    True
    >>> is_prime(6)
    False
    """

Código completo:

#!/usr/bin/env python
# -*- coding: utf-8 -*-

def get_prime_numbers(max_number):
    """
    Retorna una lista de números primos.
    
    >>> get_prime_numbers(10)
    [2, 3, 5, 7]
    """
    numbers = [True, True] + [True] * (max_number-1)
    last_prime_number = 2
    i = last_prime_number
    
    while last_prime_number**2 <= max_number:
        i += last_prime_number
        while i <= max_number:
            numbers[i] = False
            i += last_prime_number
        j = last_prime_number + 1
        while j < max_number:
            if numbers[j]:
                last_prime_number = j
                break
            j += 1
        i = last_prime_number
    
    return [i + 2 for i, not_crossed in enumerate(numbers[2:]) if not_crossed]


def is_prime(n):
    """
    Determina si n es primo.
    
    >>> is_prime(5)
    True
    >>> is_prime(6)
    False
    """
    return n in get_prime_numbers(n)


def sum(a, b):
    """
    Retorna a + b.
    
    >>> sum(5, 7)
    12
    >>> sum(5, "Python")
    Traceback (most recent call last):
        ...
    TypeError
    """
    for n in (a, b):
        if not isinstance(n, int) and not isinstance(n, float):
            raise TypeError
    return a + b


if __name__ == "__main__":
    import doctest
    doctest.testmod(verbose=False)

Conclusión

En este artículo hemos introducido las bases de las pruebas unitarias en Python con los dos módulos estándar. Al comienzo se encuentran los enlaces para la documentación de ambos en donde encontrarás más opciones para casos de pruebas mayores o de grandes proyectos.

Por un lado, unittest permite separar claramente la unidad de prueba del código que se está probando, contiene una API más desarrollada y por ende mayor flexibilidad. Por el otro, doctest simplifica la tarea utilizando los mismos ejemplos de la documentación de tus funciones o clases como pruebas unitarias. Ambas soluciones pueden ser implementadas simultáneamente e incluso combinadas con otros frameworks.



Deja un comentario