unittest es un marco de trabajo que permite realizar pruebas unitarias para un software en Python. Está inspirado en JUnit, por lo que guarda cierta similitud, y permite automatizar, compartir y configurar pruebas unitarias.Este marco permite preparar los datos para hacer las pruebas, definir casos de pruebas, agrupar casos de pruebas y ejecutar los casos para ver los resultados obtenidos.

Este documento presenta los conceptos básicos de unittest, parte de una clase de ejemplo sobre la que se van a ejecutar algunas pruebas, continúa con la creación de una clase para agrupar las pruebas, muestra los métodos para verificar las pruebas y cierra con ejemplos de pruebas unitarias.

Para una guía completa, el lector puede visitar la siguiente dirección: https://docs.python.org/3/library/unittest.html

El propósito de este tutorial es desarrollar pruebas unitarias en Python utilizando unittest.

Para iniciar el tutorial, se debe tener instalado SQLAlchemy para las operaciones de almacenamiento y recuperación. Luego de la instalación, se debe crear un paquete llamado Comunidad, el cual va a contener un archivo llamado base.py, con las definiciones del motor, sesión y base de SQLAlchemy como se muestra a continuación:

from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker

engine = create_engine('sqlite:///comunidad.sqlite')
Session = sessionmaker(bind=engine)
Base = declarative_base()

Para probar las funcionalidades de unittest, se utilizará la definición de la clase Persona, la cual cuenta con dos propiedades: Nombre y edad. A partir de estas se tienen los métodos para asignar y obtener las propiedades, determinar el año de nacimiento de la persona, almacenar una persona en una base de datos y recuperar la primera persona de la base de datos que cumpla con una condición. Esta clase se crea dentro de un paquete llamado Comunidad y la definición de la clase con sus métodos es la siguiente:

import datetime
from Comunidad.base import Session, engine, Base
from sqlalchemy import Column, Integer, String

class Persona(Base):

   __tablename__ = 'persona'
   id = Column(Integer, primary_key=True)
   nombre = Column(String)
   edad = Column(Integer)

   def __init__(self, nombre, edad):
       self.nombre = nombre
       self.edad = edad

   def asignar_edad(self, edad):
       self.edad = edad

   def asignar_nombre(self, nombre):
       self.nombre = nombre

   def dar_edad(self):
       return(self.edad)

   def dar_nombre(self):
       return(self.nombre)

   def calcular_anio_nacimiento(self, ya_cumplio_anios):
       anio_actual = datetime.datetime.now().year
       if ya_cumplio_anios:
           return (anio_actual - self.edad)
       else:
           return (anio_actual - self.edad + 1)

   def almacenar(self):
       Base.metadata.create_all(engine)
       session = Session()
       session.add(self)
       session.commit()
       session.close()

   def recuperar(self, nombre, edad):
       session = Session()
       persona = session.query(Persona).filter(Persona.nombre == nombre and Persona.edad == edad).first()
       session.close()
       self.nombre = persona.nombre
       self.edad = persona.edad
       self.id = persona.id

Este marco de trabajo viene instalado para su uso en python. Para comprobar su funcionamiento se puede ejecutar l siguiente instrucción:

python -m unittest -h

Se mostrará en la salida la información de ayuda de unittest.

Para hacer las pruebas se recomienda agruparlas en una clase. Esta clase debe crearse dentro de un paquete llamado tests en la raíz del proyecto, y la clase debe llamarse <<clase>>TestCase, dónde <<clase>> hace referencia a la clase cuyos métodos de van a probar, y dicha clase debe heredar de unittest.TestCase para dirigir las pruebas y los métodos que unittest utiliza para verificar dichas pruebas. Para este tutorial se creará la clase PersonaTestCase, se almacenará en el archivo persona_test_case.py dentro del paquete tests, y probará los métodos de la clase Persona.

Para crear la clase de pruebas en el archivo test_persona.py, primero se deben importar las funcionalidades de unittest, así:

import unittest

Y a continuación se importa la clase persona:

from Comunidad.persona import Persona

Luego se crea la clase de prueba, en este caso, PersonaTestCase, así:

class PersonaTestCase(unittest.TestCase):

Y a continuación se crean los métodos de la clase que se utilizarán para hacer las pruebas, donde cada método se define como un método en python, con la excepción que que cada método debe comenzar con la palabra test.

Para probar que la clase con las pruebas ya permite crearlas, se puede crear el siguiente método:

def test_prueba(self):
   self.assertEqual(0, 0)

Las pruebas deben incluir un llamado a alguno de los métodos assert, ya que con estos llamados es que se verifica que la prueba fue o no exitosa. Luego, para ejecutarla, utiliza la siguiente instrucción desde la raíz del proyecto:

python -m unittest tests/test_persona.py

Debe mostrar un resultado similar al siguiente:

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

El cual muestra que las pruebas se ejecutaron satisfactoriamente sin fallos o errores. Si se modifica el método, y se escribe de la siguiente manera:

def test_prueba(self):
   self.assertEqual(0, 1)

La prueba debe fallar al ejecutarla, y debe mostrar un mensaje similar al siguiente:

F
======================================================================
FAIL: test_prueba (tests.test_persona.PersonaTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\path\to\project\tests\test_persona.py", line 7, in test_prueba
    self.assertEqual(0, 1)
AssertionError: 0 != 1
----------------------------------------------------------------------
Ran 1 test in 0.005s

FAILED (failures=1)

El resultado especifica en cuál archivo de las pruebas ocurre el error, la prueba que falló y la verificación que causó el fallo. En ambos casos se puede observar el resultado de los tests, cuales funcionaron adecuadamente y cuales fallaron (Con las causas de los fallos).

Dentro de las opciones para ejecutar las pruebas existen opciones como las siguientes:

python -m unittest -v tests/test_persona.py
python -m unittest
python -m unittest -h

Las clases que implementan las pruebas unitarias heredan de la clase TestCase, la cual tiene tres tipos de métodos: Los utilizados para ejecutar las pruebas, los encargados de verificar las pruebas, y un tercer grupo que permiten consultar y recoger información sobre la prueba en sí. Para este tutorial, solamente se abordarán los dos primeros tipos de métodos

Los dos métodos principales para ejecutar las pruebas son:

Los métodos utilizados para verificar las pruebas también se conocen como los métodos assert, los cuales permiten, al momento de correr las pruebas, verificar las condiciones que se establecen en el llamado al método, y en caso de que estas condiciones no se cumplan, se encargan de desencadenar o reportar los fallos.

Los métodos assert más utilizados según el sitio https://docs.python.org/3/library/unittest.html son los siguientes:

Adicionalmente, estos métodos pueden incluir un parámetro adicional, el cual es una cadena de caracteres que representa un mensaje que le aparecerá al usuario cuando ejecute las pruebas y la verificación con el assert falle.

Las pruebas son métodos que al final verifican si una o varias situaciones ses están presentando luego de la ejecución de código. Esas situaciones se verifican a través del uso de los métodos assert, los cuales verifican una condición, que al cumplirse, hacen que la prueba pase. Un método de prueba puede incluir varios métodos assert y la prueba que se ejecuta en ese método puede arrojar una combinación de condiciones acertadas y erradas.

Adicionalmente, cada método de pruebas, puede procesar información antes de ejecutar los assert, lo que permite preparar datos para el desarrollo de las pruebas que no se hacen de manera general en el método setUp().

A continuación se presenta un conjunto de pruebas sobre la clase Persona, donde se verificarán que los métodos funcionen adecuadamente. El primer paso es configurar los objetos que se utilizarán para la prueba en el método setUp():

def setUp(self):
   self.persona1 = Persona(nombre='Alejandra', edad=25)
   self.persona2 = Persona(nombre='Diego', edad=22)
   self.persona3 = Persona(nombre='Alejandra', edad=25)
   self.persona4 = Persona(nombre='Diana', edad=25)
   self.grupo = [self.persona1, self.persona2, self.persona3]

Aquí se crearon cinco objetos, los cuatro primeros son instancias de la clase persona y el último es un conjunto de personas. Como caso particular, persona1 y persona3 tienen las mismas propiedades, pero no son el mismo objeto. Estos objetos permitirán desarrollar las verificaciones correspondientes.

En un primer caso, se verificará que el constructor almacenó de manera adecuada los datos al crear el objeto. Para esto se se verifica que los datos de persona1 sean los adecuados con assertEqual y que no son iguales que los de la persona 2 con assertNotEqual:

def test_constructor(self):
   self.assertEqual(self.persona1.dar_nombre(), 'Alejandra')
   self.assertEqual(self.persona1.dar_edad(), 25)

Las pruebas también sirven para verificar que ciertos cálculos se hagan de manera adecuada por los métodos que se están probando, por ejemplo, en la prueba del año de nacimiento, se verifica que la persona1 nació hace 22 años si ya cumplió años, o que el valor no corresponde si aun no los ha cumplido, como se muestra a continuación:

def test_anio_nacimiento(self):
   self.assertEqual(self.persona1.calcular_anio_nacimiento(True), datetime.datetime.now().year - 25)
   self.assertNotEqual(self.persona1.calcular_anio_nacimiento(False), datetime.datetime.now().year - 25)
   self.assertEqual(self.persona1.calcular_anio_nacimiento(False), datetime.datetime.now().year - 25 + 1)
   self.assertNotEqual(self.persona1.calcular_anio_nacimiento(True), datetime.datetime.now().year - 25 + 1)

También se puede verificar si al cambiar los datos, los datos almacenados en realidad se actualizaron y no almacenan datos anteriores. Para esto se probarán los métodos para asignar nombre y edad, y se probará que los datos anteriores no existen con assertFalse y que los nuevos quedaron asignados con assertTrue:

def test_asignacion(self):
  self.persona2.asignar_edad(28)
  self.persona2.asignar_nombre("Felipe")
  self.assertFalse(self.persona2.dar_nombre()=='Diego')
  self.assertFalse(self.persona2.dar_edad()==22)
  self.assertTrue(self.persona2.dar_nombre()=='Felipe')
  self.assertTrue(self.persona2.dar_edad()==28)

En las pruebas se puede verificar si dos objetos son el mismo, en este caso, revisar que persona1 no es el mismo objeto que persona3 así sus propiedades sean iguales, y que solamente se considerarán el mismo objeto si guardan la misma referencia. Para esto se duplicará la referencia a persona1 y se verificará que no sea la misma persona3 así:

def test_objetos_iguales(self):
   persona_nueva = self.persona1
   self.assertIsNot(self.persona1, self.persona3)
   self.assertIs(self.persona1, persona_nueva)

En las pruebas se puede verificar si un objeto está contenido en una colección, en este caso, se revisará que persona4 no se encuentre en el grupo y que persona1 si se encuentre, así:

def test_elemento_en_conjunto(self):
   self.assertIn(self.persona3, self.grupo)
   self.assertNotIn(self.persona4, self.grupo)

Las pruebas también pueden verificar si un objeto es de una clase, para esto se verificará que persona1 sea de la clase Persona, y grupo no es de dicha clase, así:

def test_instancia_clase(self):
   self.assertIsInstance(self.persona1, Persona)
   self.assertNotIsInstance(self.grupo, Persona)

En las pruebas se puede revisar si la información almacenada en la base de datos quedó guardada correctamente. Para esto la prueba almacena la información con el método almacenar, y luego se recupera la información consultando y filtrando los datos almacenados. Con los assert se verifica que los datos almacenados son los correctos, como se ven en la siguiente prueba:

def test_alamacenar(self):
   self.persona1.almacenar()

   session = Session()
   persona = session.query(Persona).filter(Persona.nombre == 'Alejandra' and Persona.edad == 25).first()

   self.assertEqual(persona.dar_nombre(),'Alejandra')
   self.assertEqual(persona.dar_edad(),25)

También se pueden utilizar sentencias de sqlAlchemy para almacenar un objeto en la base de datos, y probar que el método recuperar de la clase está consultando los datos almacenados según la consulta, como se aprecia en el siguiente caso de prueba:

def test_recuperar(self):
   session = Session()
   session.add(self.persona2)
   session.commit()
   session.close()

   persona = Persona("",0)
   persona.recuperar("Diego", 22)

   self.assertEqual(persona.dar_nombre(),'Diego')
   self.assertEqual(persona.dar_edad(),22)

Al ejecutar los casos con la instrucción python -m unittest -v tests/test_persona.py, se verá un mensaje como el siguiente:

test_alamacenar (tests.test_persona.PersonaTestCase) ... ok
test_anio_nacimiento (tests.test_persona.PersonaTestCase) ... ok
test_asingacion (tests.test_persona.PersonaTestCase) ... ok
test_constructor (tests.test_persona.PersonaTestCase) ... ok
test_elemento_en_conjunto (tests.test_persona.PersonaTestCase) ... ok
test_instancia_clase (tests.test_persona.PersonaTestCase) ... ok
test_objetos_iguales (tests.test_persona.PersonaTestCase) ... ok
test_prueba (tests.test_persona.PersonaTestCase) ... ok
test_recuperar (tests.test_persona.PersonaTestCase) ... ok

---------------------------------------------------------------------
Ran 9 tests in 0.324s

OK

Al momento de realizar las pruebas, una buena práctica consiste en utilizar generadores aleatorios de datos para garantizar la posibilidad de poner a prueba nuestros desarrollos con más casos y más valores.

En esta sección vamos a implementar las pruebas anteriores usando Faker, una librería de Python que genera valores aleatorios. Usaremos Faker para crear una versión idéntica de las pruebas anteriores que generen 10 usuarios distintos sin preocuparnos por los valores.

Para instalar Faker basta con usar pip:

pip install faker

Entonces, modifiquemos el método setUp para instanciar a Faker y crear 10 personas con sus nombres y edades generados aleatoriamente:

def setUp(self):
        self.data_factory = Faker()
        self.data = [] 
        self.personas = []
        for i in range(0,10):
            self.data.append((self.data_factory.name(), self.data_factory.random_number()))
            self.personas.append(Persona(nombre = self.data[-1][0], edad = self.data[-1][-1]))

Con esto, podemos definir nuestro test_constructor para que verifique los datos que generamos aleatoriamente son los que se usaron para cada crear cada persona de nuestra lista de personas:

def test_constructor(self):
        for persona, dat in zip(self.personas, self.data):
            self.assertEqual(persona.dar_nombre(), dat[0])
            self.assertEqual(persona.dar_edad(), dat[-1])

De la misma manera podemos automatizar la verificación de años de nacimiento utilizando los datos generados y la información de los objetos que creamos

def test_anio_nacimiento(self):
   for persona, dat in zip(self.personas, self.data):
            self.assertEqual(persona.calcular_anio_nacimiento(True), datetime.datetime.now().year - dat[-1])

Podemos probar la asignación también usando algunos nuevos datos aleatorios para modificar la asignación. Notemos que, en este tipo de pruebas existe la posibilidad de que nuestro generador aleatorio cree fechas o nombres idénticos a los que queremos reemplazar, debemos añadir una instrucción para regenerar los valores en el caso en que coincidan.

def test_asignacion(self):
       original_data = (self.data_factory.name(), self.data_factory.random_number())
        persona_prueba = Persona(nombre = original_data[0], edad = original_data[-1])
        new_data = (self.data_factory.name(), self.data_factory.random_number())
        while new_data[0] == original_data[0] or new_data[-1] == original_data[-1]:
            new_data = (self.data_factory.name(), self.data_factory.random_number())
        persona_prueba.asignar_nombre(new_data[0])
        persona_prueba.asignar_edad(new_data[-1])    
        self.assertFalse(persona_prueba.dar_nombre()==original_data[0])
        self.assertFalse(persona_prueba.dar_edad()==original_data[-1])
        self.assertTrue(persona_prueba.dar_nombre()==new_data[0])
        self.assertTrue(persona_prueba.dar_edad()==new_data[-1])

Redefinir el test_objetos_iguales no requiere mayor trabajo, y nos implica solamente buscar elementos en nuestra lista de persona. Esta prueba funcionará siempre que tengamos más de una persona en nuestra lista de personas.

def test_objetos_iguales(self):
        persona_nueva = self.personas[-1]
        self.assertIsNot(persona_nueva, self.personas[0])
        self.assertIs(persona_nueva, self.personas[-1])

De igual manera volvemos a usar nuestra fábrica de datos para crear un elemento para nuestro test_elemento_en_conjunto.

def test_elemento_en_conjunto(self):
        original_data = (self.data_factory.name(), self.data_factory.random_number())
        persona_prueba = Persona(nombre = original_data[0], edad = original_data[-1])
        self.assertIn(self.personas[0], self.personas)
        self.assertNotIn(persona_prueba, self.personas)

De igual manera para verificar si un objeto es una instancia de la clase podemos usar la lista que generamos inicialmente:

def test_instancia_clase(self):
        self.assertIsInstance(self.personas[0], Persona)
        self.assertNotIsInstance(self.personas, Persona)

Y finalmente las pruebas sobre la base de datos usando SQLAlchemy:

def test_almacenar(self):
        self.personas[0].almacenar()

        session = Session()
        persona = session.query(Persona).filter(Persona.nombre == self.data[0][0] and Persona.edad == self.data[0][1]).first()

        self.assertEqual(persona.dar_nombre(),self.data[0][0])
        self.assertEqual(persona.dar_edad(),self.data[0][-1])

    def test_recuperar(self):
        session = Session()
        session.add(self.personas[0])
        session.commit()
        session.close()

        persona = Persona("",0)
        persona.recuperar(self.data[0][0], self.data[0][-1])

        self.assertEqual(persona.dar_nombre(),self.data[0][0])
        self.assertEqual(persona.dar_edad(),self.data[0][-1])

Al ejecutar los casos con la instrucción python -m unittest -v tests/test_persona.py, con la generación de datos aleatorios correctamente obtendremos entonces:

test_almacenar (tests.test_persona.PersonaTestCase) ... ok
test_anio_nacimiento (tests.test_persona.PersonaTestCase) ... ok
test_asignacion (tests.test_persona.PersonaTestCase) ... ok
test_constructor (tests.test_persona.PersonaTestCase) ... ok
test_elemento_en_conjunto (tests.test_persona.PersonaTestCase) ... ok
test_instancia_clase (tests.test_persona.PersonaTestCase) ... ok
test_objetos_iguales (tests.test_persona.PersonaTestCase) ... ok
test_prueba (tests.test_persona.PersonaTestCase) ... ok
test_recuperar (tests.test_persona.PersonaTestCase) ... ok

---------------------------------------------------------------------
Ran 9 tests in 0.324s

OK

Antes de realizar las pruebas, se recomienda enunciar los casos de prueba, es decir, una lista de pruebas a realizar, con las situaciones a verificar y los resultados esperados. Para los casos del apartado anterior, la lista de pruebas corresponde a cada ejemplo, posteriormente, se diseñaron las pruebas a partir de situaciones con los datos de objetos de la clase Persona, y luego los resultados esperados con los assert. Cuando no se cuenta con estas lista y estos casos, las pruebas pueden tomar mucho tiempo, al no tener claridad sobre qué se quiere probar. Los criterios de aceptación son, en muchos casos, una fuente de casos de prueba.

Coverage.py es una herramienta que monitorea el código y analiza cuáles partes han sido ejecutadas. Generalmente se utiliza en los procesos de pruebas unitarias para determinar el porcentaje de código que estas cubren.

Para instalar esta herramienta se debe ejecutar la siguiente instrucción:

pip install coverage

Para ejecutar coverage, se cambia python por coverage run en la instrucción que permite ejecutar las pruebas. Por ejemplo, si las pruebas se ejecutan con la siguiente instrucción:

python -m unittest tests/test_persona.py

Para verificar la cobertura de código se utiliza la siguiente instrucción:

coverage run -m unittest tests/test_persona.py

En la salida, se verá el resultado de ejecución de las pruebas como si se hubiera ejecutado el comando python para correr las pruebas, pero, luego de ejecutar coverage, se puede generar un reporte por pantalla del porcentaje de código cubierto con la siguiente instrucción:

coverage report -m

Al ejecutarla, se mostrará un reporte por pantalla similar al siguiente:

Name                    Stmts   Miss  Cover   Missing
-----------------------------------------------------
Comunidad\__init__.py      0      0   100%
Comunidad\base.py          6      0   100%
Comunidad\persona.py      37      0   100%
tests\__init__.py          0      0   100%
tests\test_persona.py     56      0   100%
-----------------------------------------------------
TOTAL                     99      0   100%

El reporte anterior es solo un ejemplo, ya que se omitieron varias líneas, las cuales muestran los segmentos de reporte de cubrimiento de código de SQLAlchemy que no se cubrió en las pruebas. Este reporte muestra por cada línea la siguiente información:

En este caso, las pruebas cubren todo el código, sin embargo, si se quiere ver qué aparece cuando el código no es totalmente cubierto, primero, se debe borrar la información de la cobertura de las pruebas con la siguiente instrucción:

coverage erase

Luego, se puede borrar el método de pruebas llamado test_anio_nacimiento del archivo test_persona.py, se ejecutan nuevamente las pruebas con la instrucción coverage run -m unittest tests/test_persona.py, y, finalmente, se genera de nuevo el reporte con coverage report -m, para obtener el siguiente reporte:

Name                    Stmts   Miss  Cover   Missing
-----------------------------------------------------
Comunidad\__init__.py       0      0   100%
Comunidad\base.py           6      0   100%
Comunidad\persona.py       37      4    89%   29-33
tests\__init__.py           0      0   100%
tests\test_persona.py      51      0   100%
-----------------------------------------------------
TOTAL                      94      4    92%

En el reporte se observa que en el archivo persona.py contiene 37 instrucciones, de las cuales 4 no se están ejecutando, lo que da un 89% de cobertura. Las líneas que no se ejecutarían con las pruebas son las líneas eliminadas al quitar el método de pruebas.

Sin embargo, si se quieren verificar las líneas no que no han sido ejecutadas en un formato que no sea por consola, se puede utilizar la siguiente instrucción:

coverage html

Esta instrucción genera una carpeta llamada htmlcov con un archivo en formato html por cada archivo de salida mostrado en el reporte. Por ejemplo, el archivo llamado htmlcov/Comunidad_persona_py.html corresponde a los resultados del reporte sobre Comunidad\persona.py. Al abrir el archivo en formato html se puede observar la cobertura de la siguiente manera:

El archivo en formato html muestra a la izquierda los números de línea de la clase o módulo, al frente del número de línea muestra una barra verde o roja que identifica una línea de código ejecutable y a continuación de cada barra la línea de código. Cuando la barra es de color verde, indica que la línea de código fue ejecutada durante las pruebas, y cuando es roja, indica que esta no se ejecutó. El archivo html muestra también, en la parte superior, la información general del archivo y el resumen de la cantidad de líneas que tiene, las ejecutadas y no ejecutadas, así como las excluidas.