DEV Community

Cover image for Python: pruebas de unidad
Baltasar García Perez-Schofield
Baltasar García Perez-Schofield

Posted on • Updated on

Python: pruebas de unidad

(El submarino en la imagen es el Peral, uno de los primeros submarinos del mundo, diseñado por el español Isaac Peral. Se movía mediante energía eléctrica.)

Las pruebas de unidad básicamente nos permiten comprobar que nuestro código funciona. Se trata de unas pruebas de caja negra, es decir, se comprueba si para unas ciertas entradas las salidas de nuestro código son las correctas.

Python, conocido por traer "pilas incluidas", tiene el módulo unittest en su librería estándar.

Supongamos que tenemos la clase Submarino, que mantiene dos coordenadas x e y para guardar su posición.

import math


class Submarino:
    def __init__(self, x=0.0, y=0.0):
        self.__x = x
        self.__y = y
    ...

    @property
    def x(self):
        return self.__x

    @property
    def y(self):
        return self.__y

    def __str__(self):
        return f"Sub({self.x: 5.2f}, {self.y: 5.2f})"
...
Enter fullscreen mode Exit fullscreen mode

Esta clase es sencilla: su inicializador acepta dos parámetros x e y que por defecto se inicializan a 0. Las coordenadas se guardan como __x e __y, pero son accesibles desde el exterior como x e y, de forma transparente al usuario de la clase, gracias a los métodos x() e y() y sus decoradores @property. El método str() devuelve las coordeadas formateadas, por ejemplo en 0, 0 nos devolvería: Sub( 0.00, 0.00).

Nos falta algo muy importante: una forma de mover el submarino. Vamos a recurrir a las coordenadas polares. Se trata de moverse una distancia en una determinada dirección. Esta dirección viene dada por un ángulo desde el punto actual. En resumen, es necesario cambiar el ángulo a radianes y aplicar las siguientes ecuaciones:

x = distancia * cos(angulo)
y = distancia * sin(angulo)
Enter fullscreen mode Exit fullscreen mode

En Python tenemos la conversión a radianes mediante la función radians() en el módulo math. Esta conversión será necesaria porque los ángulos en las funciones trigonométricas se manejan en radianes, en lugar de en grados. También en el mismo módulo tenemos sin() y cos(). Así, partiendo del código anterior:

class Submarino:
    def __init__(self):
        self.__x = 0.0
        self.__y = 0.0
    ...

    @property
    def x(self):
        return self.__x

    @property
    def y(self):
        return self.__y

    def avanza(self, angulo, distancia):
        angulo = math.radians(angulo)
        self.__x = distancia * math.cos(angulo)
        self.__y = distancia * math.sin(angulo)
    ...

    def __str__(self):
        return f"Sub({self.x: 5.2f}, {self.y: 5.2f})"
...
Enter fullscreen mode Exit fullscreen mode

De acuerdo, ¿lo hemos hecho bien? ¿Cómo podemos estar seguros? Sí, podríamos crear un objeto de la clase Submarino, moverlo y ver si las coordenadas tienen sentido. Pero lo ideal sería que tuviésemos un sistema lo más automatizado posible, de manera que cuando lo utilicemos, nos avise de si hay algún error. Este sistema existe, y como ya mencionamos está en la librería estándar en el módulo unittest.

Básicamente, crearemos una clase que derive de unittest.TestCase. Dentro de esta clase crearemos los métodos necesarios: aquellos que comiencen por test, serán ejecutados automáticamente. Adicionalmente, podemos crear un método setUp(), que se ejecutará antes de cada uno de los tests. Normalmente, se utiliza para realizar inicializaciones repetitivas de código.

Supongamos que queremos crear un test que compruebe que, efectivamente, un submarino creado se posiciona inicialmente en 0, 0.

import unittest
from submarino import Submarino


class Tests(unittest.TestCase):
    def setUp(self):
        self.sub = Submarino()
    ...

    def test_init(self):
        self.assertEqual(0, self.sub.x)
        self.assertEqual(0, self.sub.y)
    ...
...
Enter fullscreen mode Exit fullscreen mode

En el método setUp(), creamos un submarino como un atributo self.sub. Dado que setUp() se ejecutará siempre antes de cada test, nuestro submarino siempre debe de estar en la posición (0, 0), es decir, x == 0 e y == 0.

Al derivar nuestra clase Tests de unittest.TestCase, disponemos de todos los métodos assertXXX() que podamos necesitar. Es importante recordar que en los parámetros se pasa primero el valor esperado, y a continuación, el calculado. En caso de que la comparación sea correcta, no sucede nada. Pero si es incorrecta, entonces el programa se para.

Los métodos de aserción disponibles son los siguientes:

Método Explicación
assertTrue(cond) Comprueba si cond es True
assertFalse(con) Comprueba si cond es False
assertEqual(x, y) Comprueba x == y
assertNotEqual(x, y) Comprueba x != y
assertIsNone(x) Comprueba si x es None
assertIsNotNone(x) Comprueba si x no es None
assertIn(x, y) Comprueba x in y
assertNotIn(x, y) Comprueba x not in y
assertIsInstance(x, y) Comprueba isinstance(x, y)
assertNotIsInstance(x, y) Comprueba not isinstance(x, y)

Todos estos métodos aceptan un parámetro msg a mayores, que en caso de que no se cumpla la comprobación, es utilizado para indicar por qué.

self.assertTrue(0, self.sub.x, "el submarino no está en x == 0")
Enter fullscreen mode Exit fullscreen mode

En cualquier caso, lo interesante ahora es comprobar si el método avanza() realmente funciona. Si es así, entonces deberíamos poder predecir cuáles son las coordenadas de destino tras una llamada a avanza. Vamos a comprobar los valores siguiendo la tabla a continuación.

Ángulo Nombre Resultado tras avanzar 100
0 este x=100, y=0
180 oeste x=-100, y=0
90 norte x=100, y=0
270 sur x=-100, y=0

Es razonable pensar que si estos movimientos básicos son correctos, el resto también lo serán.

class Tests(unittest.TestCase):
    # Más cosas...

    def test_mov_este(self):
        self.sub.avanza(0, 100)
        self.assertEqual(100, self.sub.x)
        self.assertEqual(0, self.sub.y)
    ...
...
Enter fullscreen mode Exit fullscreen mode

El movimiento hacia el este es sencillo de comprobar, ya que los resultados de los cálculos son números enteros. El resto de los casos tiene una dificultad añadida: tras los cálculos, es posible que el número real resultante no sea exactamente el esperado. Por ejemplo, un movimiento de 100 unidades hacia el norte desde x=0, y=0, debería darnos como resultado x=0, y=100. Podemos establecer que si dos números reales son iguales hasta el cuarto decimal, el valor sigue siendo suficientemente bueno. Para ello, podemos utilizar la función de comparación math.isclose(x, y, abs_tol=0). Lo que hace esta función es comparar x e y aplicando una tolerancia dada por el tercer parámetro. Por ejemplo, en el caso que estábamos comentando, hasta el cuarto decimal, estaríamos hablando de, por ejemplo: math.isclose(0, self.sub.x, abs_tol=0.00001). Para hacer las verificaciones, podemos recurrir a TestCase.assertTrue().

class Tests(unittest.TestCase):
    # Más cosas...

    def test_mov_oeste(self):
        self.sub.avanza(180, 100)
        self.assertEqual(-100, self.sub.x)
        self.assertTrue(math.isclose(0, self.sub.y, abs_tol=0.1))
    ...

    def test_mov_norte(self):
        self.sub.avanza(90, 100)
        self.assertEqual(100, self.sub.y)
        self.assertTrue(math.isclose(0, self.sub.x, abs_tol=0.00001))
    ...

    def test_mov_sur(self):
        self.sub.avanza(270, 100)
        self.assertEqual(-100, self.sub.y)
        self.assertTrue(math.isclose(0, self.sub.x, abs_tol=0.00001))
    ...
...

if __name__ == "__main__":
    unittest.main()
...
Enter fullscreen mode Exit fullscreen mode

Ahora solo hace falta ejecutar el programa para que nos dé los resultados de los tests. Suponiendo que hayamos guardado la clase Submarino en submarino.py, y los tests en tests_submarino.py, la salida es la siguiente:

$ python tests_submarino.py
......
----------------------------------------------------------------------
Ran 6 tests in 0.000s

OK
Enter fullscreen mode Exit fullscreen mode

Dado que no hay problemas, no se muestra ningún error. En caso contrario, se indicaría la línea en la que se produjo el error, y el motivo que lo provocó (la condición que no se verificó). Nótese que cada punto justo al comienzo de la ejecución es un test ejecutado, y que además, nos dice que ha ejecutado 6 tests. Así, seis tests correctos se muestran como "......". Si hubiera fallado el último, veríamos en su lugar ".....F".

Las pruebas de unidad son una poderosa herramienta: cada vez que hagamos cualquier modificación a la clase Submarino, podemos ejecutar los tests para comprobar que la funcionalidad sigue intacta. Las modificaciones se deben a corrección de errores y nueva funcionalidad. En ambos casos debemos crear tests que demuesntren que no hay errores.

  • Corrección de un error: se debe crear un test que compruebe que ese bug no se sigue repitiendo. Por ejemplo, podría ser que al moverse hacia el noreste se produjese un error. En ese caso, crearíamos un test de forma que el submarino se moviese al noreste y comprobaríamos que las coordenadas son las correctas.

  • Adición de funcionalidad: se debe crear un test que compruebe que la nueva funcionalidad es correcta. Por ejemplo, el submarino podría tener la posibilidad de sumergirse. Deberíamos comprobar que al llamar al método sumerge(), la profundidad (un nuevo atributo), cambia adecuadamente.

Por último, una posibilidad muy interesante es la de crear los tests antes que la funcionalidad. Por ejemplo, en lugar de crear el nuevo atributo profundidad, y el método sumerge(), podemos empezar por crear el método test_inmersion(), llamar al método y comprobar que la nueva funcionalidad es la adecuada. La diferencia es sutil, pero muy interesante: en lugar de continuar con el papel del desarrollador de la clase Submarino, nos colocamos sin querer en el papel del programador que emplea la clase Submarino, con lo que nos concentramos en cómo debería ofrecerse la funcionalidad, en lugar de crear la funcionalidad de la manera más simple posible desde el punto de vista del desarrollo.

Top comments (0)